feat: Energiediagramm adaptieren
Tests / test (push) Successful in 51s

This commit is contained in:
dh
2026-09-27 08:48:59 +00:00
parent 75650c0621
commit db704dd011
16 changed files with 1576 additions and 30 deletions
+56
View File
@@ -0,0 +1,56 @@
# Energiediagramm
Das Energiediagramm stellt Produktion, Hausverbrauch, Netzbezug und
Einspeisung aus archivierten Energiezaehlern dar. Zwei Kreisdiagramme zeigen
Eigenverbrauchsquote und Autarkiegrad.
## Voraussetzungen
- IP-Symcon ab Version 8.0
- aktiviertes Archiv
- fortlaufende Integer- oder Floatzaehler
- Zaehlerwerte in kWh oder ein passender gemeinsamer Zaehlerfaktor
## Konfiguration
Produktion, Einspeisung und Netzbezug sind Pflichtquellen. Ein vorhandener
Hausverbrauchszaehler kann optional direkt verwendet werden. Ohne diese Quelle
wird der Hausverbrauch als Produktion minus Einspeisung plus Netzbezug
berechnet.
Der Standardzeitraum kann auf aktuellen Tag, aktuelle Woche, aktuellen Monat,
aktuelles Jahr oder Gesamt gesetzt werden. In der Visualisierung stehen
zusaetzlich ein frei waehlbarer inklusiver Datumsbereich und die Navigation
durch abgeschlossene Zeitraeume zur Verfuegung.
Die Farben fuer Eigenverbrauchsquote und Autarkiegrad werden mit den beiden
Farbauswahlen in der Instanzkonfiguration festgelegt.
## Darstellung
Die Kachel und ihre vergroesserte Ansicht erhalten beim Aufbau sofort einen
vollstaendigen Datenstand. Beim Oeffnen und beim erneuten Sichtbarwerden wird
die Darstellung aktualisiert. Das Diagrammraster wechselt bei geringer Breite
automatisch von zwei Spalten auf eine Spalte.
## Berechnung
- Hausverbrauch: direkter Zaehler oder Produktion - Einspeisung + Netzbezug
- Eigenverbrauch: Hausverbrauch - Netzbezug, begrenzt auf die Produktion
- Eigenverbrauchsquote: Eigenverbrauch / Produktion
- Autarkiegrad: Eigenverbrauch / Hausverbrauch
Negative Zaehlerdifferenzen, etwa nach einem Zaehlerwechsel, werden fuer den
betroffenen Zeitraum auf 0 kWh begrenzt.
## Migration aus Enelix 1
Uebernommen wurden die drei Pflichtzaehler, die Zeitraumsauswahl, die
Datumsnavigation, die Energiebilanz und beide Kreisdiagramme. Neu sind die
optionale direkte Hausverbrauchsquelle, der konfigurierbare Standardzeitraum,
der freie Datumsbereich, waehlbare Farben, eingebettete Initialdaten fuer die
vergroesserte Ansicht und das responsive Ein-/Zweispaltenlayout.
Das Enelix-1-Modul wird nicht in-place aktualisiert. Es muss eine neue
Energiediagramm-Instanz angelegt und mit den bisherigen Zaehlern konfiguriert
werden.
+97
View File
@@ -0,0 +1,97 @@
{
"elements": [
{
"type": "Label",
"caption": "Energiezaehler muessen als fortlaufende kWh-Zaehler im Archiv protokolliert werden."
},
{
"type": "SelectVariable",
"name": "ProduktionZaehlerID",
"caption": "Produktion"
},
{
"type": "SelectVariable",
"name": "EinspeisungZaehlerID",
"caption": "Einspeisung"
},
{
"type": "SelectVariable",
"name": "NetzbezugZaehlerID",
"caption": "Netzbezug"
},
{
"type": "SelectVariable",
"name": "VerbrauchZaehlerID",
"caption": "Hausverbrauch (optional)"
},
{
"type": "NumberSpinner",
"name": "Zaehlerfaktor",
"caption": "Zaehlerfaktor auf kWh",
"minimum": 0.000001,
"maximum": 1000000,
"digits": 6
},
{
"type": "Select",
"name": "Standardzeitraum",
"caption": "Standardzeitraum",
"options": [
{ "caption": "Aktueller Tag", "value": "day" },
{ "caption": "Aktuelle Woche", "value": "week" },
{ "caption": "Aktueller Monat", "value": "month" },
{ "caption": "Aktuelles Jahr", "value": "year" },
{ "caption": "Gesamt", "value": "total" }
]
},
{
"type": "NumberSpinner",
"name": "Aktualisierungsintervall",
"caption": "Aktualisierungsintervall",
"minimum": 10,
"maximum": 3600,
"suffix": " Sekunden"
},
{
"type": "RowLayout",
"items": [
{
"type": "SelectColor",
"name": "FarbeEigenverbrauch",
"caption": "Eigenverbrauchsquote",
"allowTransparent": false
},
{
"type": "SelectColor",
"name": "FarbeAutarkie",
"caption": "Autarkiegrad",
"allowTransparent": false
}
]
},
{
"type": "CheckBox",
"name": "Debug",
"caption": "Debug-Logging aktivieren"
}
],
"actions": [
{
"type": "Button",
"caption": "Darstellung aktualisieren",
"onClick": "IPS_RequestAction($id, 'Refresh', true);"
}
],
"status": [
{
"code": 201,
"icon": "error",
"caption": "Konfiguration ungueltig"
},
{
"code": 202,
"icon": "error",
"caption": "Archiv nicht verfuegbar"
}
]
}
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\Energiediagramm;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
final class EnergyChartCalculator
{
public const RANGES = ['day', 'week', 'month', 'year', 'total', 'custom'];
public static function isValidDate(string $date): bool
{
$value = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
$errors = DateTimeImmutable::getLastErrors();
return $value !== false
&& ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0))
&& $value->format('Y-m-d') === $date;
}
/**
* @return array{0: int, 1: int}
*/
public static function rangeBounds(
string $range,
string $date,
string $customStart,
string $customEnd,
?int $now = null
): array {
if (!in_array($range, self::RANGES, true)) {
throw new InvalidArgumentException('Unbekannter Zeitraum.');
}
$now ??= time();
if ($range === 'total') {
return [0, $now];
}
if ($range === 'custom') {
$start = self::date($customStart);
$end = self::date($customEnd);
if ($start > $end) {
throw new InvalidArgumentException('Das Startdatum liegt nach dem Enddatum.');
}
return [$start->getTimestamp(), $end->modify('+1 day')->getTimestamp()];
}
$base = self::date($date);
switch ($range) {
case 'day':
$start = $base;
$end = $start->modify('+1 day');
break;
case 'week':
$start = $base->modify('monday this week');
$end = $start->modify('+1 week');
break;
case 'month':
$start = $base->modify('first day of this month');
$end = $start->modify('first day of next month');
break;
case 'year':
$start = $base->setDate((int) $base->format('Y'), 1, 1);
$end = $start->modify('+1 year');
break;
default:
throw new InvalidArgumentException('Unbekannter Zeitraum.');
}
return [$start->getTimestamp(), $end->getTimestamp()];
}
/**
* @return array{
* Produktion: float,
* Einspeisung: float,
* Netzbezug: float,
* Hausverbrauch: float,
* Eigenverbrauch: float,
* Eigenverbrauchsquote: float,
* Autarkiegrad: float
* }
*/
public static function metrics(
float $production,
float $feedIn,
float $grid,
?float $consumption = null
): array {
$production = max(0.0, $production);
$feedIn = max(0.0, $feedIn);
$grid = max(0.0, $grid);
$house = $consumption === null
? max(0.0, $production - $feedIn + $grid)
: max(0.0, $consumption);
$selfConsumption = min($production, max(0.0, $house - $grid));
return [
'Produktion' => $production,
'Einspeisung' => $feedIn,
'Netzbezug' => $grid,
'Hausverbrauch' => $house,
'Eigenverbrauch' => $selfConsumption,
'Eigenverbrauchsquote' => $production > 0.0
? min(100.0, ($selfConsumption / $production) * 100.0)
: 0.0,
'Autarkiegrad' => $house > 0.0
? min(100.0, ($selfConsumption / $house) * 100.0)
: 0.0,
];
}
public static function normalizeColor(string $color, string $fallback): string
{
return preg_match('/^#[0-9A-Fa-f]{6}$/', $color) === 1
? strtoupper($color)
: strtoupper($fallback);
}
private static function date(string $date): DateTimeImmutable
{
if (!self::isValidDate($date)) {
throw new InvalidArgumentException('Ungültiges Datum.');
}
return new DateTimeImmutable($date . ' 00:00:00', new DateTimeZone(date_default_timezone_get()));
}
}
+477
View File
@@ -0,0 +1,477 @@
<div id="energy-app">
<div class="toolbar">
<label class="field">
<span>Zeitraum</span>
<select id="range">
<option value="day">Tag</option>
<option value="week">Woche</option>
<option value="month">Monat</option>
<option value="year">Jahr</option>
<option value="total">Gesamt</option>
<option value="custom">Datumsbereich</option>
</select>
</label>
<label id="date-field" class="field">
<span>Datum</span>
<input id="date" type="date">
</label>
<div id="navigation" class="button-row" aria-label="Zeitraum wechseln">
<button id="previous" class="icon-button" type="button" title="Vorheriger Zeitraum" aria-label="Vorheriger Zeitraum">&#8249;</button>
<button id="today" type="button">Heute</button>
<button id="next" class="icon-button" type="button" title="Nächster Zeitraum" aria-label="Nächster Zeitraum">&#8250;</button>
</div>
<div id="custom-range" class="custom-range" hidden>
<label class="field">
<span>Von</span>
<input id="custom-start" type="date">
</label>
<label class="field">
<span>Bis</span>
<input id="custom-end" type="date">
</label>
<button id="apply-range" type="button">Anzeigen</button>
</div>
</div>
<div id="period" class="period" aria-live="polite"></div>
<div id="message" class="message" role="status" hidden></div>
<div id="summary" class="summary"></div>
<div id="charts" class="charts">
<section class="chart" aria-labelledby="self-consumption-title">
<h3 id="self-consumption-title">Eigenverbrauchsquote</h3>
<div id="self-consumption-chart" class="donut"></div>
</section>
<section class="chart" aria-labelledby="autarky-title">
<h3 id="autarky-title">Autarkiegrad</h3>
<div id="autarky-chart" class="donut"></div>
</section>
</div>
<div id="error" class="error" role="alert"></div>
</div>
<style>
:root {
color-scheme: light dark;
}
html,
body {
min-width: 0;
min-height: 100%;
margin: 0;
}
#energy-app {
width: 100%;
min-height: 100%;
box-sizing: border-box;
padding: 14px;
overflow: auto;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: inherit;
background: transparent;
}
.toolbar,
.custom-range,
.button-row,
.summary {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 10px;
}
.field {
display: grid;
min-width: 128px;
gap: 4px;
font-size: 13px;
font-weight: 600;
}
select,
input,
button {
min-height: 36px;
box-sizing: border-box;
border: 1px solid rgba(127, 127, 127, 0.45);
border-radius: 6px;
padding: 7px 10px;
color: inherit;
background: rgba(127, 127, 127, 0.08);
font: inherit;
}
button {
cursor: pointer;
font-weight: 650;
}
button:focus-visible,
select:focus-visible,
input:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.icon-button {
width: 38px;
padding: 0;
font-size: 25px;
line-height: 1;
}
.period {
min-height: 22px;
margin-top: 14px;
font-size: 15px;
font-weight: 700;
}
.message {
margin-top: 10px;
padding: 10px 0;
border-top: 1px solid rgba(127, 127, 127, 0.35);
border-bottom: 1px solid rgba(127, 127, 127, 0.35);
}
.summary {
align-items: stretch;
margin-top: 12px;
}
.metric {
min-width: 130px;
flex: 1 1 150px;
padding: 8px 0;
border-bottom: 2px solid rgba(127, 127, 127, 0.32);
}
.metric span,
.metric strong {
display: block;
}
.metric span {
font-size: 12px;
opacity: 0.72;
}
.metric strong {
margin-top: 3px;
font-size: 16px;
}
.charts {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 22px;
align-items: start;
margin-top: 20px;
}
.charts.narrow {
grid-template-columns: minmax(0, 1fr);
}
.chart {
min-width: 0;
text-align: center;
}
.chart h3 {
margin: 0 0 10px;
font-size: 15px;
letter-spacing: 0;
}
.donut {
width: 176px;
height: 176px;
margin: 0 auto;
}
.donut svg {
display: block;
width: 176px;
height: 176px;
overflow: visible;
}
.donut-track {
stroke: rgba(127, 127, 127, 0.28);
}
.donut-value {
transition: stroke-dasharray 180ms ease;
}
.donut-label {
fill: currentColor;
font-size: 22px;
font-weight: 750;
text-anchor: middle;
dominant-baseline: middle;
}
.error {
min-height: 18px;
margin-top: 8px;
color: #d14343;
font-size: 13px;
}
[hidden] {
display: none !important;
}
@media (max-width: 560px) {
#energy-app {
padding: 10px;
}
.toolbar,
.custom-range {
align-items: stretch;
}
.field {
flex: 1 1 145px;
}
.charts {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
<script>
(function () {
'use strict';
const initialState = __ENELIX_INITIAL_STATE__;
const app = document.getElementById('energy-app');
const range = document.getElementById('range');
const date = document.getElementById('date');
const dateField = document.getElementById('date-field');
const navigation = document.getElementById('navigation');
const previous = document.getElementById('previous');
const today = document.getElementById('today');
const next = document.getElementById('next');
const customRange = document.getElementById('custom-range');
const customStart = document.getElementById('custom-start');
const customEnd = document.getElementById('custom-end');
const applyRange = document.getElementById('apply-range');
const period = document.getElementById('period');
const message = document.getElementById('message');
const summary = document.getElementById('summary');
const charts = document.getElementById('charts');
const error = document.getElementById('error');
const selfConsumptionChart = document.getElementById('self-consumption-chart');
const autarkyChart = document.getElementById('autarky-chart');
function send(action, value) {
if (typeof requestAction !== 'function') {
return;
}
try {
requestAction(action, value);
error.textContent = '';
} catch (exception) {
error.textContent = exception && exception.message
? exception.message
: String(exception);
}
}
function asNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function formatEnergy(value) {
return asNumber(value).toLocaleString('de-CH', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}) + ' kWh';
}
function formatDate(value) {
const parts = String(value).split('-');
return parts.length === 3
? parts[2] + '.' + parts[1] + '.' + parts[0]
: '';
}
function periodLabel(data) {
if (data.range === 'total') {
return 'Gesamter Zählerstand';
}
if (data.range === 'custom') {
return formatDate(data.customStart) + ' bis ' + formatDate(data.customEnd);
}
const start = new Date(asNumber(data.tStart) * 1000);
const end = new Date((asNumber(data.tEnd) * 1000) - 1000);
if (data.range === 'day') {
return 'Tag: ' + start.toLocaleDateString('de-CH');
}
if (data.range === 'week') {
return 'Woche: ' + start.toLocaleDateString('de-CH')
+ ' bis ' + end.toLocaleDateString('de-CH');
}
if (data.range === 'month') {
return 'Monat: ' + start.toLocaleDateString('de-CH', {
month: 'long',
year: 'numeric'
});
}
if (data.range === 'year') {
return 'Jahr: ' + start.getFullYear();
}
return '';
}
function donut(target, percent, color) {
const value = Math.max(0, Math.min(100, asNumber(percent)));
const radius = 58;
const circumference = 2 * Math.PI * radius;
const filled = (value / 100) * circumference;
target.innerHTML = [
'<svg viewBox="0 0 176 176" role="img" aria-label="'
+ value.toFixed(1) + ' Prozent">',
'<circle class="donut-track" cx="88" cy="88" r="' + radius
+ '" fill="none" stroke-width="18"></circle>',
'<circle class="donut-value" cx="88" cy="88" r="' + radius
+ '" fill="none" stroke="' + color + '" stroke-width="18"'
+ ' stroke-linecap="round" transform="rotate(-90 88 88)"'
+ ' stroke-dasharray="' + filled + ' ' + circumference + '"></circle>',
'<text class="donut-label" x="88" y="91">'
+ value.toFixed(1) + '%</text>',
'</svg>'
].join('');
}
function metric(label, value) {
return '<div class="metric"><span>' + label + '</span><strong>'
+ formatEnergy(value) + '</strong></div>';
}
function render(data) {
if (!data || typeof data !== 'object') {
return;
}
range.value = data.range || 'day';
date.value = data.date || '';
customStart.value = data.customStart || '';
customEnd.value = data.customEnd || '';
const currentMode = range.value;
const custom = currentMode === 'custom';
const total = currentMode === 'total';
dateField.hidden = custom || total;
navigation.hidden = custom || total;
customRange.hidden = !custom;
period.textContent = periodLabel(data);
const values = data.values || {};
summary.innerHTML = [
metric('Produktion', values.Produktion),
metric('Hausverbrauch', values.Hausverbrauch),
metric('Netzbezug', values.Netzbezug),
metric('Einspeisung', values.Einspeisung)
].join('');
const colors = data.colors || {};
donut(
selfConsumptionChart,
values.Eigenverbrauchsquote,
colors.selfConsumption || '#2F80ED'
);
donut(
autarkyChart,
values.Autarkiegrad,
colors.autarky || '#22A06B'
);
message.hidden = data.hasData !== false;
message.textContent = data.hasData === false
? (data.noDataHint || 'Keine Archivwerte verfügbar.')
: '';
}
window.handleMessage = function (data) {
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (exception) {
error.textContent = 'Die Aktualisierung enthält ungültige Daten.';
return;
}
}
render(data);
};
range.addEventListener('change', function () {
send('SetRange', range.value);
});
date.addEventListener('change', function () {
send('SetDate', date.value);
});
previous.addEventListener('click', function () {
send('Prev', true);
});
next.addEventListener('click', function () {
send('Next', true);
});
today.addEventListener('click', function () {
send('Today', true);
});
applyRange.addEventListener('click', function () {
send('SetCustomRange', JSON.stringify({
start: customStart.value,
end: customEnd.value
}));
});
const now = new Date();
const currentDate = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0')
].join('-');
date.max = currentDate;
customStart.max = currentDate;
customEnd.max = currentDate;
if (typeof ResizeObserver === 'function') {
const observer = new ResizeObserver(function (entries) {
const width = entries[0] ? entries[0].contentRect.width : app.clientWidth;
charts.classList.toggle('narrow', width < 520);
});
observer.observe(app);
}
document.addEventListener('visibilitychange', function () {
if (!document.hidden) {
send('Refresh', true);
}
});
render(initialState);
window.setTimeout(function () {
send('Refresh', true);
}, 0);
})();
</script>
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{F73B8F8D-5E36-4B23-B379-4632A517A1D6}",
"name": "Energiediagramm",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Energy Pie"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIXENERGY",
"url": ""
}
+482
View File
@@ -0,0 +1,482 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/libs/EnergyChartCalculator.php';
use Belevo\EnelixUtils\Energiediagramm\EnergyChartCalculator;
class Energiediagramm extends IPSModule
{
private const ARCHIVE_MODULE_ID = '{43192F0B-135B-4CE7-A0A7-1475603F3060}';
private const ATTR_RANGE = 'SelectedRange';
private const ATTR_DATE = 'SelectedDate';
private const ATTR_CUSTOM_START = 'CustomStart';
private const ATTR_CUSTOM_END = 'CustomEnd';
private const COLOR_SELF_CONSUMPTION = '#2F80ED';
private const COLOR_AUTARKY = '#22A06B';
public function Create(): void
{
parent::Create();
$this->RegisterPropertyInteger('ProduktionZaehlerID', 0);
$this->RegisterPropertyInteger('EinspeisungZaehlerID', 0);
$this->RegisterPropertyInteger('NetzbezugZaehlerID', 0);
$this->RegisterPropertyInteger('VerbrauchZaehlerID', 0);
$this->RegisterPropertyFloat('Zaehlerfaktor', 1.0);
$this->RegisterPropertyString('Standardzeitraum', 'day');
$this->RegisterPropertyInteger('Aktualisierungsintervall', 60);
$this->RegisterPropertyString('FarbeEigenverbrauch', self::COLOR_SELF_CONSUMPTION);
$this->RegisterPropertyString('FarbeAutarkie', self::COLOR_AUTARKY);
$this->RegisterPropertyBoolean('Debug', false);
$today = date('Y-m-d');
$this->RegisterAttributeString(self::ATTR_RANGE, 'day');
$this->RegisterAttributeString(self::ATTR_DATE, $today);
$this->RegisterAttributeString(self::ATTR_CUSTOM_START, $today);
$this->RegisterAttributeString(self::ATTR_CUSTOM_END, $today);
$this->SetVisualizationType(1);
$this->RegisterTimer(
'Refresh',
0,
'IPS_RequestAction($_IPS["TARGET"], "Refresh", true);'
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$today = date('Y-m-d');
$defaultRange = $this->ReadPropertyString('Standardzeitraum');
if (!in_array($defaultRange, ['day', 'week', 'month', 'year', 'total'], true)) {
$defaultRange = 'day';
}
$this->WriteAttributeString(self::ATTR_RANGE, $defaultRange);
$this->WriteAttributeString(self::ATTR_DATE, $today);
if (!$this->validStoredDate($this->ReadAttributeString(self::ATTR_CUSTOM_START))) {
$this->WriteAttributeString(self::ATTR_CUSTOM_START, $today);
}
if (!$this->validStoredDate($this->ReadAttributeString(self::ATTR_CUSTOM_END))) {
$this->WriteAttributeString(self::ATTR_CUSTOM_END, $today);
}
$interval = $this->ReadPropertyInteger('Aktualisierungsintervall');
$this->SetTimerInterval('Refresh', max(10, min(3600, $interval)) * 1000);
if ($this->requiredSourceIdsAreEmpty()) {
$this->SetStatus(104);
} elseif (!$this->configurationIsValid()) {
$this->SetStatus(201);
} elseif ($this->archiveId() === 0) {
$this->SetStatus(202);
} else {
$this->SetStatus(102);
}
$this->recalculateAndPush();
}
public function GetVisualizationTile(): string
{
$path = __DIR__ . '/module.html';
$html = @file_get_contents($path);
if ($html === false) {
return '<div style="padding:12px">Darstellung nicht verfügbar.</div>';
}
try {
$initial = json_encode(
$this->buildPayload(),
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR
);
} catch (Throwable $error) {
$initial = '{"hasData":false,"noDataHint":"Darstellung konnte nicht geladen werden."}';
}
return str_replace('__ENELIX_INITIAL_STATE__', $initial, $html);
}
public function GetVisualizationPopup(): string
{
return $this->GetVisualizationTile();
}
public function RequestAction($Ident, $Value): void
{
try {
switch ((string) $Ident) {
case 'SetRange':
$this->setRange((string) $Value);
break;
case 'SetDate':
$this->setDate((string) $Value);
break;
case 'SetCustomRange':
$this->setCustomRange((string) $Value);
break;
case 'Prev':
case 'Next':
case 'Today':
$this->shiftDate((string) $Ident);
break;
case 'Refresh':
break;
default:
throw new InvalidArgumentException('Unbekannte Aktion.');
}
$this->recalculateAndPush();
} catch (Throwable $error) {
$this->debug('Aktion abgewiesen', $error->getMessage());
}
}
private function setRange(string $range): void
{
if (!in_array($range, EnergyChartCalculator::RANGES, true)) {
throw new InvalidArgumentException('Unbekannter Zeitraum.');
}
$this->WriteAttributeString(self::ATTR_RANGE, $range);
}
private function setDate(string $date): void
{
if (!$this->validStoredDate($date)) {
throw new InvalidArgumentException('Ungültiges oder zukünftiges Datum.');
}
$this->WriteAttributeString(self::ATTR_DATE, $date);
}
private function setCustomRange(string $json): void
{
try {
$value = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $error) {
throw new InvalidArgumentException('Ungültiger Datumsbereich.', 0, $error);
}
$start = is_array($value) ? (string) ($value['start'] ?? '') : '';
$end = is_array($value) ? (string) ($value['end'] ?? '') : '';
if (!$this->validStoredDate($start) || !$this->validStoredDate($end)) {
throw new InvalidArgumentException('Ungültiger oder zukünftiger Datumsbereich.');
}
if ($start > $end) {
throw new InvalidArgumentException('Das Startdatum liegt nach dem Enddatum.');
}
$this->WriteAttributeString(self::ATTR_CUSTOM_START, $start);
$this->WriteAttributeString(self::ATTR_CUSTOM_END, $end);
$this->WriteAttributeString(self::ATTR_RANGE, 'custom');
}
private function shiftDate(string $action): void
{
$range = $this->ReadAttributeString(self::ATTR_RANGE);
if ($range === 'total' || $range === 'custom') {
return;
}
if ($action === 'Today') {
$this->WriteAttributeString(self::ATTR_DATE, date('Y-m-d'));
return;
}
$date = $this->ReadAttributeString(self::ATTR_DATE);
$base = strtotime($date . ' 00:00:00');
if ($base === false) {
$base = strtotime(date('Y-m-d') . ' 00:00:00');
}
$previous = $action === 'Prev';
$modifier = [
'day' => $previous ? '-1 day' : '+1 day',
'week' => $previous ? '-1 week' : '+1 week',
'month' => $previous ? 'first day of previous month' : 'first day of next month',
'year' => $previous ? 'first day of january previous year' : 'first day of january next year',
][$range] ?? 'today';
$shifted = strtotime($modifier, $base);
if ($shifted === false || $shifted > time()) {
$shifted = time();
}
$this->WriteAttributeString(self::ATTR_DATE, date('Y-m-d', $shifted));
}
private function recalculateAndPush(): void
{
try {
$payload = $this->buildPayload();
$this->UpdateVisualizationValue(json_encode($payload, JSON_THROW_ON_ERROR));
} catch (Throwable $error) {
$this->debug('Aktualisierung fehlgeschlagen', $error->getMessage());
}
}
/**
* @return array<string, mixed>
*/
private function buildPayload(): array
{
$range = $this->ReadAttributeString(self::ATTR_RANGE);
if (!in_array($range, EnergyChartCalculator::RANGES, true)) {
$range = $this->ReadPropertyString('Standardzeitraum');
}
if (!in_array($range, EnergyChartCalculator::RANGES, true)) {
$range = 'day';
}
$date = $this->ReadAttributeString(self::ATTR_DATE);
if (!$this->validStoredDate($date)) {
$date = date('Y-m-d');
}
$customStart = $this->ReadAttributeString(self::ATTR_CUSTOM_START);
$customEnd = $this->ReadAttributeString(self::ATTR_CUSTOM_END);
if (!$this->validStoredDate($customStart)) {
$customStart = date('Y-m-d');
}
if (!$this->validStoredDate($customEnd)) {
$customEnd = date('Y-m-d');
}
[$start, $end] = EnergyChartCalculator::rangeBounds(
$range,
$date,
$customStart,
$customEnd
);
$payload = [
'range' => $range,
'date' => $date,
'customStart' => $customStart,
'customEnd' => $customEnd,
'tStart' => $start,
'tEnd' => $end,
'hasData' => false,
'noDataHint' => 'Energiezähler konfigurieren.',
'colors' => [
'selfConsumption' => EnergyChartCalculator::normalizeColor(
$this->ReadPropertyString('FarbeEigenverbrauch'),
self::COLOR_SELF_CONSUMPTION
),
'autarky' => EnergyChartCalculator::normalizeColor(
$this->ReadPropertyString('FarbeAutarkie'),
self::COLOR_AUTARKY
),
],
'values' => EnergyChartCalculator::metrics(0.0, 0.0, 0.0),
];
if (!$this->configurationIsValid()) {
return $payload;
}
$archiveId = $this->archiveId();
if ($archiveId === 0) {
$payload['noDataHint'] = 'Das IP-Symcon-Archiv ist nicht verfügbar.';
return $payload;
}
$factor = $this->ReadPropertyFloat('Zaehlerfaktor');
$total = $range === 'total';
$production = $this->readEnergy(
$archiveId,
$this->ReadPropertyInteger('ProduktionZaehlerID'),
$start,
$end,
$total
);
$feedIn = $this->readEnergy(
$archiveId,
$this->ReadPropertyInteger('EinspeisungZaehlerID'),
$start,
$end,
$total
);
$grid = $this->readEnergy(
$archiveId,
$this->ReadPropertyInteger('NetzbezugZaehlerID'),
$start,
$end,
$total
);
$consumption = null;
$consumptionAvailable = true;
$consumptionId = $this->ReadPropertyInteger('VerbrauchZaehlerID');
if ($consumptionId > 0) {
$consumptionResult = $this->readEnergy($archiveId, $consumptionId, $start, $end, $total);
$consumptionAvailable = $consumptionResult['available'];
if ($consumptionAvailable) {
$consumption = $consumptionResult['value'] * $factor;
}
}
$payload['hasData'] = $production['available']
&& $feedIn['available']
&& $grid['available']
&& $consumptionAvailable;
$payload['values'] = EnergyChartCalculator::metrics(
$production['value'] * $factor,
$feedIn['value'] * $factor,
$grid['value'] * $factor,
$consumption
);
if (!$payload['hasData']) {
$lastLog = max(
$this->lastLogTimestamp($archiveId, $this->ReadPropertyInteger('ProduktionZaehlerID')),
$this->lastLogTimestamp($archiveId, $this->ReadPropertyInteger('EinspeisungZaehlerID')),
$this->lastLogTimestamp($archiveId, $this->ReadPropertyInteger('NetzbezugZaehlerID')),
$this->lastLogTimestamp($archiveId, $consumptionId)
);
$payload['noDataHint'] = $lastLog > 0
? 'Letzter Archivwert: ' . date('d.m.Y H:i', $lastLog)
: 'Für diesen Zeitraum sind keine Archivwerte vorhanden.';
} else {
$payload['noDataHint'] = '';
}
return $payload;
}
/**
* @return array{value: float, available: bool}
*/
private function readEnergy(
int $archiveId,
int $variableId,
int $start,
int $end,
bool $total
): array {
if (!$this->isNumericVariable($variableId)) {
return ['value' => 0.0, 'available' => false];
}
if ($total) {
$value = GetValue($variableId);
return [
'value' => is_numeric($value) ? max(0.0, (float) $value) : 0.0,
'available' => is_numeric($value),
];
}
$before = @AC_GetLoggedValues($archiveId, $variableId, 0, $start, 1);
$within = @AC_GetLoggedValues($archiveId, $variableId, $start, min($end, time()), 0);
$before = is_array($before) ? $before : [];
$within = is_array($within) ? $within : [];
usort($before, static fn (array $left, array $right): int =>
((int) $left['TimeStamp']) <=> ((int) $right['TimeStamp'])
);
usort($within, static fn (array $left, array $right): int =>
((int) $left['TimeStamp']) <=> ((int) $right['TimeStamp'])
);
if ($before === [] && $within === []) {
return ['value' => 0.0, 'available' => false];
}
$startValue = $before !== []
? (float) $before[count($before) - 1]['Value']
: (float) $within[0]['Value'];
$endValue = $within !== []
? (float) $within[count($within) - 1]['Value']
: $startValue;
return [
'value' => max(0.0, $endValue - $startValue),
'available' => true,
];
}
private function lastLogTimestamp(int $archiveId, int $variableId): int
{
if (!$this->isNumericVariable($variableId)) {
return 0;
}
$values = @AC_GetLoggedValues($archiveId, $variableId, 0, time(), 1);
return is_array($values) && isset($values[0]['TimeStamp'])
? (int) $values[0]['TimeStamp']
: 0;
}
private function configurationIsValid(): bool
{
$requiredIds = [
$this->ReadPropertyInteger('ProduktionZaehlerID'),
$this->ReadPropertyInteger('EinspeisungZaehlerID'),
$this->ReadPropertyInteger('NetzbezugZaehlerID'),
];
foreach ($requiredIds as $variableId) {
if (!$this->isNumericVariable($variableId)) {
return false;
}
}
$consumptionId = $this->ReadPropertyInteger('VerbrauchZaehlerID');
if ($consumptionId > 0 && !$this->isNumericVariable($consumptionId)) {
return false;
}
return $this->ReadPropertyFloat('Zaehlerfaktor') > 0.0
&& $this->ReadPropertyInteger('Aktualisierungsintervall') >= 10
&& $this->ReadPropertyInteger('Aktualisierungsintervall') <= 3600
&& in_array(
$this->ReadPropertyString('Standardzeitraum'),
['day', 'week', 'month', 'year', 'total'],
true
)
&& EnergyChartCalculator::normalizeColor(
$this->ReadPropertyString('FarbeEigenverbrauch'),
''
) !== ''
&& EnergyChartCalculator::normalizeColor(
$this->ReadPropertyString('FarbeAutarkie'),
''
) !== '';
}
private function requiredSourceIdsAreEmpty(): bool
{
return $this->ReadPropertyInteger('ProduktionZaehlerID') === 0
&& $this->ReadPropertyInteger('EinspeisungZaehlerID') === 0
&& $this->ReadPropertyInteger('NetzbezugZaehlerID') === 0;
}
private function archiveId(): int
{
$archives = IPS_GetInstanceListByModuleID(self::ARCHIVE_MODULE_ID);
return isset($archives[0]) ? (int) $archives[0] : 0;
}
private function isNumericVariable(int $variableId): bool
{
if ($variableId <= 0 || !IPS_VariableExists($variableId)) {
return false;
}
$variable = IPS_GetVariable($variableId);
return in_array((int) ($variable['VariableType'] ?? -1), [1, 2], true);
}
private function validStoredDate(string $date): bool
{
return EnergyChartCalculator::isValidDate($date)
&& $date <= date('Y-m-d');
}
private function debug(string $title, string $message): void
{
if ($this->ReadPropertyBoolean('Debug')) {
$this->SendDebug($title, $message, 0);
}
}
}
+6 -5
View File
@@ -4,16 +4,16 @@ Unabhaengige Zusatzmodule fuer IP-Symcon. Dieses Repository ist nicht vom Enelix
## Status ## Status
Das Repository befindet sich im Aufbau. Verbrauchskostenreport und Shelly Das Repository befindet sich im Aufbau. Verbrauchskostenreport,
Modul sind als installierbare IP-Symcon-Module enthalten; die weiteren Module Energiediagramm und Shelly Modul sind als installierbare IP-Symcon-Module
sind derzeit als Diskussionsentwürfe dokumentiert. enthalten; die weiteren Module sind derzeit als Diskussionsentwürfe dokumentiert.
## Module ## Module
- Verbrauchskostenreport (implementiert) - Verbrauchskostenreport (implementiert)
- Virtuelle Batterie - Virtuelle Batterie
- CC100 Hardware - CC100 Hardware
- Energiediagramm - Energiediagramm (implementiert)
- VGT-Schnittstelle - VGT-Schnittstelle
- Shelly Modul (implementiert) - Shelly Modul (implementiert)
@@ -56,9 +56,10 @@ da IP-Symcon sonst URL, Branch und Aktualisierungsstatus nicht verwalten kann.
## Aktualisierung und Prüfung ## Aktualisierung und Prüfung
Aktualisierungen werden ausschließlich über die IP-Symcon-Modulverwaltung Aktualisierungen werden ausschließlich über die IP-Symcon-Modulverwaltung
bezogen. Nach einem Branchwechsel oder Update müssen beide implementierten bezogen. Nach einem Branchwechsel oder Update müssen alle implementierten
Module geladen sein: Module geladen sein:
- `Energiediagramm`
- `Shelly Modul` - `Shelly Modul`
- `Verbrauchskostenreport` - `Verbrauchskostenreport`
+49 -19
View File
@@ -1,29 +1,59 @@
# Energiediagramm # Energiediagramm
> Status: Diskussionsentwurf. Übernimmt `Energy_Pie` ohne EMS-Abhängigkeit. > Status: Implementiert fuer IP-Symcon 8.0+.
Der Zeitraum wird einstellbar. Öffnen beziehungsweise Aufklappen aktualisiert Das Modul adaptiert `Energy_Pie` als unabhaengiges Enelix-Utils-Modul. Es
dieselbe Darstellung wie die bestehende Aktualisieren-Aktion. wertet fortlaufende Energiezaehler aus dem IP-Symcon-Archiv aus und zeigt die
Energiebilanz sowie Eigenverbrauchsquote und Autarkiegrad in einer individuellen
## Variablen HTML-SDK-Darstellung.
Keine klassischen Pflichtvariablen. Energiewerte und Auswahl erscheinen in der
individuellen Darstellung; Zeitraum und Datum bleiben intern gespeichert.
## Properties ## Properties
| Ident | Typ | Standard / Beschreibung | | Ident | Typ | Standard / Beschreibung |
| --- | --- | --- | | --- | --- | --- |
| `ProduktionZaehlerID` | Integer | `0`; alt `VarProduction`. | | `ProduktionZaehlerID` | Integer | Pflichtquelle fuer produzierte Energie |
| `VerbrauchZaehlerID` | Integer | `0`; alt `VarConsumption`. | | `EinspeisungZaehlerID` | Integer | Pflichtquelle fuer eingespeiste Energie |
| `EinspeisungZaehlerID` | Integer | `0`; alt `VarFeedIn`. | | `NetzbezugZaehlerID` | Integer | Pflichtquelle fuer bezogene Energie |
| `NetzbezugZaehlerID` | Integer | `0`; alt `VarGrid`. | | `VerbrauchZaehlerID` | Integer | Optionale direkte Hausverbrauchsquelle |
| `Zaehlerfaktor` | Float | `1`; auf kWh normieren. | | `Zaehlerfaktor` | Float | `1.0`; gemeinsamer Faktor auf kWh |
| `Standardzeitraum` | String/Auswahl | `Tag`; Tag, Woche, Monat, Jahr oder Gesamt. | | `Standardzeitraum` | String | `day`; Tag, Woche, Monat, Jahr oder Gesamt |
| `Aktualisierungsintervall` | Integer | `60` s. | | `Aktualisierungsintervall` | Integer | `60 s`; 10 bis 3600 Sekunden |
| `FarbeEigenverbrauch` | String | Farbe der Eigenverbrauchsquote |
| `FarbeAutarkie` | String | Farbe des Autarkiegrads |
| `Debug` | Boolean | Zusaetzliche Diagnoseausgaben |
## Verhalten und offene Punkte ## Zeitraeume und Bedienung
- Nutzt Energiezähler und Symcon-Archivdaten. Die Visualisierung bietet Tag, Woche, Monat, Jahr, Gesamt und Datumsbereich.
- Fehlende Historie darf nicht als echter Nullverbrauch dargestellt werden. Der freie Datumsbereich umfasst Start- und Enddatum jeweils vollstaendig. Fuer
- Aktualisierung beim Öffnen in den verfügbaren Symcon-Visualisierungen testen. die Kalenderzeitraeume kann vor- und zuruecknavigiert oder direkt zum aktuellen
Zeitraum gesprungen werden. Zukuenftige Datumswerte werden nicht akzeptiert.
## Darstellung
Kachel und vergroesserte Ansicht enthalten bereits beim ersten HTML-Aufbau
einen vollstaendigen Datenstand. Ein nachgelagertes Refresh aktualisiert die
Werte; beim erneuten Sichtbarwerden wird ebenfalls aktualisiert. Das
Diagrammraster zeigt bei ausreichender Breite zwei Kreisdiagramme nebeneinander
und bei geringer Breite automatisch untereinander.
## Daten und Fehlerverhalten
Die drei Pflichtzaehler muessen numerisch und im Archiv vorhanden sein. Der
Hausverbrauch wird aus einer optionalen direkten Quelle gelesen oder aus
Produktion minus Einspeisung plus Netzbezug berechnet. Bei fehlendem Archiv
oder fehlenden Werten zeigt die Darstellung einen Diagnosehinweis statt einer
leeren Flaeche.
## Adaption aus Enelix 1
| Bereich | Behandlung |
| --- | --- |
| Archivierte Produktions-, Einspeise- und Netzbezugszaehler | angepasst uebernommen |
| Tag, Woche, Monat, Jahr und Gesamt | uebernommen |
| Frei waehlbarer Datumsbereich | neu implementiert |
| Standardzeitraum in der Konfiguration | neu implementiert |
| Farben der Kreisdiagramme | neu konfigurierbar |
| Vergroesserte Ansicht | mit eingebetteten Initialdaten stabilisiert |
| Starres Zwei-Spalten-Layout | durch responsives Raster ersetzt |
| Erzwungener Sprung auf Heute beim Laden | verworfen |
+3 -3
View File
@@ -1,14 +1,14 @@
# Modulübersicht Enelix Utils # Modulübersicht Enelix Utils
> Status: Verbrauchskostenreport und Shelly Modul sind implementiert. Die > Status: Verbrauchskostenreport, Energiediagramm und Shelly Modul sind
> übrigen Module sind als Diskussionsentwürfe dokumentiert. > implementiert. Die übrigen Module sind als Diskussionsentwürfe dokumentiert.
| Modul | Herkunft | EMS-Abhängigkeit | | Modul | Herkunft | EMS-Abhängigkeit |
| --- | --- | --- | | --- | --- | --- |
| [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine | | [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine |
| [Virtuelle Batterie](Virtuelle-Batterie/README.md) | Bat_EV_SDL_V4 | Optionaler Vertrag, keine Code-Abhängigkeit | | [Virtuelle Batterie](Virtuelle-Batterie/README.md) | Bat_EV_SDL_V4 | Optionaler Vertrag, keine Code-Abhängigkeit |
| [CC100 Hardware](CC100-Hardware/README.md) | CC100_HW | Keine | | [CC100 Hardware](CC100-Hardware/README.md) | CC100_HW | Keine |
| [Energiediagramm](Energiediagramm/README.md) | Energy_Pie | Keine | | [Energiediagramm](Energiediagramm/README.md) | Energy_Pie (implementiert) | Keine |
| [VGT-Schnittstelle](VGT-Schnittstelle/README.md) | MQTTPVSDL + MQTTBatterySDL | Optional konfigurierbar | | [VGT-Schnittstelle](VGT-Schnittstelle/README.md) | MQTTPVSDL + MQTTBatterySDL | Optional konfigurierbar |
| [Shelly Modul](Shelly-Modul/README.md) | Shelly_Parser_MQTT | Keine | | [Shelly Modul](Shelly-Modul/README.md) | Shelly_Parser_MQTT | Keine |
+2 -1
View File
@@ -42,7 +42,8 @@ werden, wenn sie vor dem Lauf nicht existierten.
- `single`: genau die als Auswahl übergebenen Module - `single`: genau die als Auswahl übergebenen Module
- `affected`: die durch geänderte Pfade ermittelten Module - `affected`: die durch geänderte Pfade ermittelten Module
Verfügbare Module: `ShellyModul`, `Verbrauchskostenreport`. Verfügbare Module: `Energiediagramm`, `ShellyModul`,
`Verbrauchskostenreport`.
Die Utils-Module werden jeweils eigenständig und ohne Abhängigkeit zu Enelix EMS getestet. Die Utils-Module werden jeweils eigenständig und ohne Abhängigkeit zu Enelix EMS getestet.
+1 -1
View File
@@ -15,7 +15,7 @@ final class DokumentationsstrukturTest extends TestCase
'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'], 'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'],
'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Diskussionsentwurf'], 'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Diskussionsentwurf'],
'CC100 Hardware' => ['CC100-Hardware', 'Status: Diskussionsentwurf'], 'CC100 Hardware' => ['CC100-Hardware', 'Status: Diskussionsentwurf'],
'Energiediagramm' => ['Energiediagramm', 'Status: Diskussionsentwurf'], 'Energiediagramm' => ['Energiediagramm', 'Status: Implementiert'],
'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'], 'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'],
'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'], 'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'],
]; ];
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\Tests;
use PHPUnit\Framework\TestCase;
final class EnergiediagrammModulstrukturTest extends TestCase
{
public function testModuleMetadataAndFormExposeRequiredConfiguration(): void
{
$module = json_decode(
file_get_contents(__DIR__ . '/../Energiediagramm/module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$form = json_decode(
file_get_contents(__DIR__ . '/../Energiediagramm/form.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame('{F73B8F8D-5E36-4B23-B379-4632A517A1D6}', $module['id']);
self::assertSame('Energiediagramm', $module['name']);
self::assertSame('ENELIXENERGY', $module['prefix']);
$serialized = json_encode($form, JSON_THROW_ON_ERROR);
foreach ([
'ProduktionZaehlerID',
'EinspeisungZaehlerID',
'NetzbezugZaehlerID',
'VerbrauchZaehlerID',
'Standardzeitraum',
'FarbeEigenverbrauch',
'FarbeAutarkie',
] as $property) {
self::assertStringContainsString($property, $serialized);
}
self::assertSame(2, substr_count($serialized, 'SelectColor'));
}
public function testHtmlStartsWithDataAndRespondsToAvailableWidth(): void
{
$html = file_get_contents(__DIR__ . '/../Energiediagramm/module.html');
self::assertNotFalse($html);
self::assertStringContainsString('__ENELIX_INITIAL_STATE__', $html);
self::assertStringContainsString('ResizeObserver', $html);
self::assertStringContainsString('grid-template-columns: minmax(0, 1fr)', $html);
self::assertStringContainsString('SetCustomRange', $html);
self::assertStringNotContainsString("addEventListener('load'", $html);
}
public function testModuleEmbedsInitialPayloadForTileAndPopup(): void
{
$module = file_get_contents(__DIR__ . '/../Energiediagramm/module.php');
self::assertNotFalse($module);
self::assertStringContainsString(
"str_replace('__ENELIX_INITIAL_STATE__'",
$module
);
self::assertStringContainsString('GetVisualizationPopup', $module);
self::assertStringContainsString('Standardzeitraum', $module);
self::assertStringContainsString('CustomStart', $module);
self::assertStringContainsString('CustomEnd', $module);
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\Tests;
use Belevo\EnelixUtils\Energiediagramm\EnergyChartCalculator;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../Energiediagramm/libs/EnergyChartCalculator.php';
final class EnergiediagrammTest extends TestCase
{
private string $timezone;
protected function setUp(): void
{
$this->timezone = date_default_timezone_get();
date_default_timezone_set('Europe/Zurich');
}
protected function tearDown(): void
{
date_default_timezone_set($this->timezone);
}
public function testCalendarRangesUseLocalBoundaries(): void
{
[$dayStart, $dayEnd] = EnergyChartCalculator::rangeBounds(
'day',
'2026-09-27',
'2026-09-01',
'2026-09-02'
);
self::assertSame('2026-09-27 00:00', date('Y-m-d H:i', $dayStart));
self::assertSame('2026-09-28 00:00', date('Y-m-d H:i', $dayEnd));
[$weekStart, $weekEnd] = EnergyChartCalculator::rangeBounds(
'week',
'2026-09-27',
'2026-09-01',
'2026-09-02'
);
self::assertSame('2026-09-21', date('Y-m-d', $weekStart));
self::assertSame('2026-09-28', date('Y-m-d', $weekEnd));
[$monthStart, $monthEnd] = EnergyChartCalculator::rangeBounds(
'month',
'2026-09-27',
'2026-09-01',
'2026-09-02'
);
self::assertSame('2026-09-01', date('Y-m-d', $monthStart));
self::assertSame('2026-10-01', date('Y-m-d', $monthEnd));
[$yearStart, $yearEnd] = EnergyChartCalculator::rangeBounds(
'year',
'2026-09-27',
'2026-09-01',
'2026-09-02'
);
self::assertSame('2026-01-01', date('Y-m-d', $yearStart));
self::assertSame('2027-01-01', date('Y-m-d', $yearEnd));
}
public function testCustomRangeIncludesCompleteEndDateAcrossDstChange(): void
{
[$start, $end] = EnergyChartCalculator::rangeBounds(
'custom',
'2026-03-29',
'2026-03-28',
'2026-03-30'
);
self::assertSame('2026-03-28 00:00', date('Y-m-d H:i', $start));
self::assertSame('2026-03-31 00:00', date('Y-m-d H:i', $end));
}
public function testInvalidCustomRangeIsRejected(): void
{
$this->expectException(InvalidArgumentException::class);
EnergyChartCalculator::rangeBounds(
'custom',
'2026-09-27',
'2026-09-28',
'2026-09-27'
);
}
public function testMetricsUseDerivedOrExplicitConsumption(): void
{
$derived = EnergyChartCalculator::metrics(20.0, 5.0, 3.0);
self::assertSame(18.0, $derived['Hausverbrauch']);
self::assertSame(15.0, $derived['Eigenverbrauch']);
self::assertSame(75.0, $derived['Eigenverbrauchsquote']);
self::assertEqualsWithDelta(83.3333, $derived['Autarkiegrad'], 0.001);
$explicit = EnergyChartCalculator::metrics(20.0, 5.0, 3.0, 16.0);
self::assertSame(16.0, $explicit['Hausverbrauch']);
self::assertSame(13.0, $explicit['Eigenverbrauch']);
self::assertSame(65.0, $explicit['Eigenverbrauchsquote']);
self::assertSame(81.25, $explicit['Autarkiegrad']);
}
public function testColorsAreValidatedAndNormalized(): void
{
self::assertSame(
'#2F80ED',
EnergyChartCalculator::normalizeColor('#2f80ed', '#000000')
);
self::assertSame(
'#22A06B',
EnergyChartCalculator::normalizeColor('transparent', '#22a06b')
);
}
}
+6
View File
@@ -3,6 +3,12 @@
declare(strict_types=1); declare(strict_types=1);
return [ return [
'Energiediagramm' => [
'file' => __DIR__ . '/modules/Energiediagramm.php',
'paths' => [
'Energiediagramm/*',
],
],
'ShellyModul' => [ 'ShellyModul' => [
'file' => __DIR__ . '/modules/ShellyModul.php', 'file' => __DIR__ . '/modules/ShellyModul.php',
'paths' => [ 'paths' => [
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use Belevo\EnelixUtils\SymconTest\TestContext;
return static function (TestContext $test): void {
$productionId = $test->createVariable(2, 'Produktion', 1200.0);
$feedInId = $test->createVariable(2, 'Einspeisung', 350.0);
$gridId = $test->createVariable(2, 'Netzbezug', 180.0);
$moduleId = $test->createInstance(
'{F73B8F8D-5E36-4B23-B379-4632A517A1D6}',
'Energiediagramm',
[
'ProduktionZaehlerID' => $productionId,
'EinspeisungZaehlerID' => $feedInId,
'NetzbezugZaehlerID' => $gridId,
'Standardzeitraum' => 'month',
'FarbeEigenverbrauch' => '#1259A7',
'FarbeAutarkie' => '#178A55',
]
);
$test->runCase('Instanz und Eigenschaften werden angelegt', static function (
TestContext $test
) use ($moduleId): void {
$test->assertInstanceStatus($moduleId);
$test->assertSame('month', IPS_GetProperty($moduleId, 'Standardzeitraum'));
$test->assertSame('#1259A7', IPS_GetProperty($moduleId, 'FarbeEigenverbrauch'));
$test->assertSame('#178A55', IPS_GetProperty($moduleId, 'FarbeAutarkie'));
});
$test->runCase('Konfigurationsformular ist gültiges JSON', static function (
TestContext $test
) use ($moduleId): void {
$form = json_decode(
IPS_GetConfigurationForm($moduleId),
true,
512,
JSON_THROW_ON_ERROR
);
$test->assertTrue(is_array($form));
});
$test->runCase('Zeitraum und Datumsbereich werden angenommen', static function (
TestContext $test
) use ($moduleId): void {
IPS_RequestAction($moduleId, 'SetRange', 'year');
IPS_RequestAction(
$moduleId,
'SetCustomRange',
json_encode(
['start' => '2026-01-01', 'end' => '2026-01-31'],
JSON_THROW_ON_ERROR
)
);
IPS_RequestAction($moduleId, 'Refresh', true);
$test->assertSame(102, (int) IPS_GetInstance($moduleId)['InstanceStatus']);
});
};
+1 -1
View File
@@ -58,7 +58,7 @@ final class SymconTestContractTest extends TestCase
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
{ {
self::assertSame( self::assertSame(
['ShellyModul', 'Verbrauchskostenreport'], ['Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'],
$this->resolveAffectedModules(['tests/Symcon/TestContext.php']) $this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
); );
} }