diff --git a/Energiediagramm/README.md b/Energiediagramm/README.md
new file mode 100644
index 0000000..aced640
--- /dev/null
+++ b/Energiediagramm/README.md
@@ -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.
diff --git a/Energiediagramm/form.json b/Energiediagramm/form.json
new file mode 100644
index 0000000..80dcaa1
--- /dev/null
+++ b/Energiediagramm/form.json
@@ -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"
+ }
+ ]
+}
diff --git a/Energiediagramm/libs/EnergyChartCalculator.php b/Energiediagramm/libs/EnergyChartCalculator.php
new file mode 100644
index 0000000..fb6c0af
--- /dev/null
+++ b/Energiediagramm/libs/EnergyChartCalculator.php
@@ -0,0 +1,134 @@
+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()));
+ }
+}
diff --git a/Energiediagramm/module.html b/Energiediagramm/module.html
new file mode 100644
index 0000000..f6bb330
--- /dev/null
+++ b/Energiediagramm/module.html
@@ -0,0 +1,477 @@
+
+
+
+
+
+
+
+
+
+
+ Eigenverbrauchsquote
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Energiediagramm/module.json b/Energiediagramm/module.json
new file mode 100644
index 0000000..76a2305
--- /dev/null
+++ b/Energiediagramm/module.json
@@ -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": ""
+}
diff --git a/Energiediagramm/module.php b/Energiediagramm/module.php
new file mode 100644
index 0000000..d17ff27
--- /dev/null
+++ b/Energiediagramm/module.php
@@ -0,0 +1,482 @@
+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 'Darstellung nicht verfügbar.
';
+ }
+
+ 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
+ */
+ 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);
+ }
+ }
+}
diff --git a/README.md b/README.md
index 02acbef..fcab138 100644
--- a/README.md
+++ b/README.md
@@ -4,16 +4,16 @@ Unabhaengige Zusatzmodule fuer IP-Symcon. Dieses Repository ist nicht vom Enelix
## Status
-Das Repository befindet sich im Aufbau. Verbrauchskostenreport und Shelly
-Modul sind als installierbare IP-Symcon-Module enthalten; die weiteren Module
-sind derzeit als Diskussionsentwürfe dokumentiert.
+Das Repository befindet sich im Aufbau. Verbrauchskostenreport,
+Energiediagramm und Shelly Modul sind als installierbare IP-Symcon-Module
+enthalten; die weiteren Module sind derzeit als Diskussionsentwürfe dokumentiert.
## Module
- Verbrauchskostenreport (implementiert)
- Virtuelle Batterie
- CC100 Hardware
-- Energiediagramm
+- Energiediagramm (implementiert)
- VGT-Schnittstelle
- Shelly Modul (implementiert)
@@ -56,9 +56,10 @@ da IP-Symcon sonst URL, Branch und Aktualisierungsstatus nicht verwalten kann.
## Aktualisierung und Prüfung
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:
+- `Energiediagramm`
- `Shelly Modul`
- `Verbrauchskostenreport`
diff --git a/docs/module/Energiediagramm/README.md b/docs/module/Energiediagramm/README.md
index 5ddafe1..4ac64ff 100644
--- a/docs/module/Energiediagramm/README.md
+++ b/docs/module/Energiediagramm/README.md
@@ -1,29 +1,59 @@
# 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
-dieselbe Darstellung wie die bestehende Aktualisieren-Aktion.
-
-## Variablen
-
-Keine klassischen Pflichtvariablen. Energiewerte und Auswahl erscheinen in der
-individuellen Darstellung; Zeitraum und Datum bleiben intern gespeichert.
+Das Modul adaptiert `Energy_Pie` als unabhaengiges Enelix-Utils-Modul. Es
+wertet fortlaufende Energiezaehler aus dem IP-Symcon-Archiv aus und zeigt die
+Energiebilanz sowie Eigenverbrauchsquote und Autarkiegrad in einer individuellen
+HTML-SDK-Darstellung.
## Properties
| Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
-| `ProduktionZaehlerID` | Integer | `0`; alt `VarProduction`. |
-| `VerbrauchZaehlerID` | Integer | `0`; alt `VarConsumption`. |
-| `EinspeisungZaehlerID` | Integer | `0`; alt `VarFeedIn`. |
-| `NetzbezugZaehlerID` | Integer | `0`; alt `VarGrid`. |
-| `Zaehlerfaktor` | Float | `1`; auf kWh normieren. |
-| `Standardzeitraum` | String/Auswahl | `Tag`; Tag, Woche, Monat, Jahr oder Gesamt. |
-| `Aktualisierungsintervall` | Integer | `60` s. |
+| `ProduktionZaehlerID` | Integer | Pflichtquelle fuer produzierte Energie |
+| `EinspeisungZaehlerID` | Integer | Pflichtquelle fuer eingespeiste Energie |
+| `NetzbezugZaehlerID` | Integer | Pflichtquelle fuer bezogene Energie |
+| `VerbrauchZaehlerID` | Integer | Optionale direkte Hausverbrauchsquelle |
+| `Zaehlerfaktor` | Float | `1.0`; gemeinsamer Faktor auf kWh |
+| `Standardzeitraum` | String | `day`; Tag, Woche, Monat, Jahr oder Gesamt |
+| `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.
-- Fehlende Historie darf nicht als echter Nullverbrauch dargestellt werden.
-- Aktualisierung beim Öffnen in den verfügbaren Symcon-Visualisierungen testen.
+Die Visualisierung bietet Tag, Woche, Monat, Jahr, Gesamt und Datumsbereich.
+Der freie Datumsbereich umfasst Start- und Enddatum jeweils vollstaendig. Fuer
+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 |
diff --git a/docs/module/README.md b/docs/module/README.md
index dea99f4..fad1ea2 100644
--- a/docs/module/README.md
+++ b/docs/module/README.md
@@ -1,14 +1,14 @@
# Modulübersicht Enelix Utils
-> Status: Verbrauchskostenreport und Shelly Modul sind implementiert. Die
-> übrigen Module sind als Diskussionsentwürfe dokumentiert.
+> Status: Verbrauchskostenreport, Energiediagramm und Shelly Modul sind
+> implementiert. Die übrigen Module sind als Diskussionsentwürfe dokumentiert.
| Modul | Herkunft | EMS-Abhängigkeit |
| --- | --- | --- |
| [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine |
| [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 |
-| [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 |
| [Shelly Modul](Shelly-Modul/README.md) | Shelly_Parser_MQTT | Keine |
diff --git a/docs/testing/README.md b/docs/testing/README.md
index 5a61761..827d15e 100644
--- a/docs/testing/README.md
+++ b/docs/testing/README.md
@@ -42,7 +42,8 @@ werden, wenn sie vor dem Lauf nicht existierten.
- `single`: genau die als Auswahl übergebenen 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.
diff --git a/tests/DokumentationsstrukturTest.php b/tests/DokumentationsstrukturTest.php
index d7dde0d..7ab9325 100644
--- a/tests/DokumentationsstrukturTest.php
+++ b/tests/DokumentationsstrukturTest.php
@@ -15,7 +15,7 @@ final class DokumentationsstrukturTest extends TestCase
'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'],
'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Diskussionsentwurf'],
'CC100 Hardware' => ['CC100-Hardware', 'Status: Diskussionsentwurf'],
- 'Energiediagramm' => ['Energiediagramm', 'Status: Diskussionsentwurf'],
+ 'Energiediagramm' => ['Energiediagramm', 'Status: Implementiert'],
'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'],
'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'],
];
diff --git a/tests/EnergiediagrammModulstrukturTest.php b/tests/EnergiediagrammModulstrukturTest.php
new file mode 100644
index 0000000..efa28ec
--- /dev/null
+++ b/tests/EnergiediagrammModulstrukturTest.php
@@ -0,0 +1,69 @@
+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')
+ );
+ }
+}
diff --git a/tests/Symcon/manifest.php b/tests/Symcon/manifest.php
index 6d833dd..1af28a0 100644
--- a/tests/Symcon/manifest.php
+++ b/tests/Symcon/manifest.php
@@ -3,6 +3,12 @@
declare(strict_types=1);
return [
+ 'Energiediagramm' => [
+ 'file' => __DIR__ . '/modules/Energiediagramm.php',
+ 'paths' => [
+ 'Energiediagramm/*',
+ ],
+ ],
'ShellyModul' => [
'file' => __DIR__ . '/modules/ShellyModul.php',
'paths' => [
diff --git a/tests/Symcon/modules/Energiediagramm.php b/tests/Symcon/modules/Energiediagramm.php
new file mode 100644
index 0000000..a3f89cc
--- /dev/null
+++ b/tests/Symcon/modules/Energiediagramm.php
@@ -0,0 +1,61 @@
+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']);
+ });
+};
diff --git a/tests/SymconTestContractTest.php b/tests/SymconTestContractTest.php
index 6ce37cb..5965c83 100644
--- a/tests/SymconTestContractTest.php
+++ b/tests/SymconTestContractTest.php
@@ -58,7 +58,7 @@ final class SymconTestContractTest extends TestCase
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
{
self::assertSame(
- ['ShellyModul', 'Verbrauchskostenreport'],
+ ['Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'],
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
);
}