6 Commits
Author SHA1 Message Date
dh 5b6125d086 fix(energy-pie): represent indeterminate shares and preserve prepared styling
Tests / test (push) Successful in 50s
2026-10-06 17:40:42 +00:00
dh c5f924bd19 fix(metadata): set Enelix Utils author to Enelix
Tests / test (push) Successful in 50s
2026-10-06 13:38:01 +00:00
dh 82860c0eb9 fix(energiediagramm): Visualisierungsthema übernehmen
Tests / test (push) Successful in 1m51s
2026-10-01 15:34:23 +00:00
dh 59eec738ab chore(release): erste Beta vorbereiten
Tests / test (push) Successful in 51s
2026-09-29 06:25:29 +00:00
dh 40c6a17dd1 fix(energiediagramm): Symcon-8-Darstellung korrigieren 2026-09-29 06:24:29 +00:00
dh 1f945be8f1 feat: Verbrauchskostenreport lizenzieren
Tests / test (push) Successful in 45s
2026-09-27 12:33:36 +00:00
20 changed files with 739 additions and 55 deletions
+8
View File
@@ -21,6 +21,14 @@ jobs:
- name: Abhaengigkeiten installieren - name: Abhaengigkeiten installieren
run: composer install --no-interaction --no-progress --prefer-dist run: composer install --no-interaction --no-progress --prefer-dist
- name: Composer-Metadaten pruefen
run: composer validate --strict
- name: Abhaengigkeiten auf Sicherheitsmeldungen pruefen
run: |
composer audit --no-interaction
composer audit --working-dir=Verbrauchskostenreport/libs --locked --no-interaction
- name: Unit-Tests und Syntaxpruefung ausfuehren - name: Unit-Tests und Syntaxpruefung ausfuehren
run: composer check run: composer check
+34
View File
@@ -0,0 +1,34 @@
# Changelog
Alle wesentlichen Aenderungen an Enelix Utils werden in dieser Datei dokumentiert.
## Unreleased
- Energiediagramm zeigt wieder den urspruenglichen dunklen Energy-Pie-Hintergrund
mit blauen und violetten Farbverlaeufen, auch bei hellem Visualisierungsthema.
Bedienelemente bleiben lesbar; Konfiguration und Berechnungen sind unveraendert.
## 0.1 Build 1 - Erste Beta - 2026-09-29
### Enthalten
- Verbrauchskostenreport mit PDF-Ausgabe und Schweizer QR-Zahlteil nach SIX 2.3.
- Verbrauchskostenreport mit Grundlizenz sowie Strom- und Nebenzaehlerkontingenten.
- Virtuelle Batterie und VGT-Schnittstelle.
- CC100 Hardware und generisches Shelly Modul.
- Energiediagramm fuer die IP-Symcon-Kachelvisualisierung.
### Beta-Aenderungen
- Energiediagramm uebernimmt Kachel- und Textfarbe der Visualisierung; Zeitraum-
und Datumsauswahl bleiben in hellen und dunklen Themen lesbar.
- Dezimale IP-Symcon-Farbwerte werden im Energiediagramm korrekt verarbeitet.
- Kachelabstand fuer den Symcon-Kopf und versionsabhaengiger Vollbildmodus.
- Vollstaendige PHP-, Struktur- und Symcon-Modultests fuer alle Module.
- Composer-Metadaten- und Abhaengigkeitspruefung in der CI.
### Hinweise
- Diese Version ist fuer kontrollierte Anlagen- und Feldtests bestimmt.
- Vor produktivem Einsatz sind Sicherung und anlagenspezifische Abnahme erforderlich.
- Korrekturen werden vorwaerts ueber `develop` und anschliessend `beta` verteilt.
+19 -5
View File
@@ -24,14 +24,28 @@ zusaetzlich ein frei waehlbarer inklusiver Datumsbereich und die Navigation
durch abgeschlossene Zeitraeume zur Verfuegung. durch abgeschlossene Zeitraeume zur Verfuegung.
Die Farben fuer Eigenverbrauchsquote und Autarkiegrad werden mit den beiden Die Farben fuer Eigenverbrauchsquote und Autarkiegrad werden mit den beiden
Farbauswahlen in der Instanzkonfiguration festgelegt. Farbauswahlen in der Instanzkonfiguration festgelegt. Das Modul verarbeitet
sowohl aktuelle Hex-Farbwerte als auch die von IP-Symcon 8.0 gespeicherten
dezimalen Farbwerte.
## Darstellung ## Darstellung
Die Kachel und ihre vergroesserte Ansicht erhalten beim Aufbau sofort einen Der Inhalt beginnt unterhalb des von Symcon eingeblendeten Kachelkopfs, damit
vollstaendigen Datenstand. Beim Oeffnen und beim erneuten Sichtbarwerden wird Zeitraum und Datum vollstaendig bedienbar bleiben. Der urspruengliche Energy-Pie-
die Darstellung aktualisiert. Das Diagrammraster wechselt bei geringer Breite Hintergrund mit dunkler Grundfarbe und blauen/violetten Farbverlaeufen bleibt
automatisch von zwei Spalten auf eine Spalte. auch in einem hellen Visualisierungsthema sichtbar. Text, Auswahlfelder und
native Datumselemente verwenden innerhalb des Moduls dazu passende helle
Schrift und dunkle Bedienflaechen. Die konfigurierten Diagrammfarben bleiben
unveraendert; eine Konfigurationsmigration ist nicht erforderlich. Die
Hintergrundebene blockiert weder Klicks noch Scrollen. Beim Oeffnen und beim
erneuten Sichtbarwerden wird die Darstellung aktualisiert. Das
Diagrammraster wechselt bei geringer Breite automatisch von zwei Spalten auf
eine Spalte.
IP-Symcon 8.x unterstuetzt HTML-SDK-Darstellungen nur in der normalen Kachel.
Der vom Client angebotene Vergroesserungsdialog kann deshalb leer bleiben.
Ab IP-Symcon 9.0 aktiviert das Modul automatisch den zusaetzlichen
HTML-SDK-Vollbildmodus.
## Berechnung ## Berechnung
+18
View File
@@ -0,0 +1,18 @@
# Nicht eindeutig bestimmbare Energieanteile
Die optionale Property `EnergieanteileBerechenbar` ist standardmaessig true und
erhaelt das bisherige Verhalten. Setzt ein aufrufender Manager sie auf false,
bleiben gemessene Summen erhalten. Eigenverbrauch und zugehoerige Anteile werden
als null geliefert und als nicht bestimmbar dargestellt, niemals als 0 Prozent.
Anwendungsfall: Netzbezug enthaelt SDL, die Hauslast wurde um SDL bereinigt.
Ohne eindeutige Zuordnung laesst sich der solare Anteil nicht exakt bestimmen.
Die Policy gehoert in EMS; Utils implementiert nur die generische Darstellung.
Individuelle Quellen, Farben und Energiezaehler werden nicht veraendert. Die
bereits vorbereitete Wiederherstellung des dunklen Pie-Hintergrunds bleibt
erhalten. Utils vor einem Manager mit dieser neuen Property aktualisieren.
Validierung: 58 PHPUnit-Tests und vier zusaetzliche JavaScript-Visualisierungstests
erfolgreich; unbekannte, alte numerische und spaeter wieder verfuegbare Anteile
sind abgedeckt.
+9
View File
@@ -32,6 +32,15 @@
"maximum": 1000000, "maximum": 1000000,
"digits": 6 "digits": 6
}, },
{
"type": "CheckBox",
"name": "EnergieanteileBerechenbar",
"caption": "Eigenverbrauch und Autarkie aus gemeinsamer Energiebilanz berechnen"
},
{
"type": "Label",
"caption": "Bei abweichenden Messgrenzen oder nicht zuordenbaren Speicherfluessen ausschalten. Energiesummen bleiben sichtbar; Anteile werden als nicht bestimmbar gekennzeichnet."
},
{ {
"type": "Select", "type": "Select",
"name": "Standardzeitraum", "name": "Standardzeitraum",
+23 -12
View File
@@ -82,16 +82,17 @@ final class EnergyChartCalculator
* Einspeisung: float, * Einspeisung: float,
* Netzbezug: float, * Netzbezug: float,
* Hausverbrauch: float, * Hausverbrauch: float,
* Eigenverbrauch: float, * Eigenverbrauch: float|null,
* Eigenverbrauchsquote: float, * Eigenverbrauchsquote: float|null,
* Autarkiegrad: float * Autarkiegrad: float|null
* } * }
*/ */
public static function metrics( public static function metrics(
float $production, float $production,
float $feedIn, float $feedIn,
float $grid, float $grid,
?float $consumption = null ?float $consumption = null,
bool $sharesAvailable = true
): array { ): array {
$production = max(0.0, $production); $production = max(0.0, $production);
$feedIn = max(0.0, $feedIn); $feedIn = max(0.0, $feedIn);
@@ -106,21 +107,31 @@ final class EnergyChartCalculator
'Einspeisung' => $feedIn, 'Einspeisung' => $feedIn,
'Netzbezug' => $grid, 'Netzbezug' => $grid,
'Hausverbrauch' => $house, 'Hausverbrauch' => $house,
'Eigenverbrauch' => $selfConsumption, 'Eigenverbrauch' => $sharesAvailable ? $selfConsumption : null,
'Eigenverbrauchsquote' => $production > 0.0 'Eigenverbrauchsquote' => !$sharesAvailable ? null : ($production > 0.0
? min(100.0, ($selfConsumption / $production) * 100.0) ? min(100.0, ($selfConsumption / $production) * 100.0)
: 0.0, : 0.0),
'Autarkiegrad' => $house > 0.0 'Autarkiegrad' => !$sharesAvailable ? null : ($house > 0.0
? min(100.0, ($selfConsumption / $house) * 100.0) ? min(100.0, ($selfConsumption / $house) * 100.0)
: 0.0, : 0.0),
]; ];
} }
public static function normalizeColor(string $color, string $fallback): string public static function normalizeColor(string $color, string $fallback): string
{ {
return preg_match('/^#[0-9A-Fa-f]{6}$/', $color) === 1 $color = trim($color);
? strtoupper($color) if (preg_match('/^#[0-9A-Fa-f]{6}$/', $color) === 1) {
: strtoupper($fallback); return strtoupper($color);
}
if (preg_match('/^[0-9]+$/', $color) === 1) {
$legacyColor = (int) $color;
if ($legacyColor >= 0 && $legacyColor <= 0xFFFFFF) {
return sprintf('#%06X', $legacyColor);
}
}
return strtoupper($fallback);
} }
private static function date(string $date): DateTimeImmutable private static function date(string $date): DateTimeImmutable
+76 -17
View File
@@ -63,19 +63,41 @@
html, html,
body { body {
min-width: 0; min-width: 0;
height: 100%;
min-height: 100%; min-height: 100%;
margin: 0; margin: 0;
color: var(--content-color, inherit);
background-color: var(--card-color, transparent);
} }
#energy-app { #energy-app {
--card-color: #121216;
--content-color: #f5f5f7;
position: relative;
isolation: isolate;
color-scheme: dark;
width: 100%; width: 100%;
min-height: 100%; min-height: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 14px; padding: 58px 14px 14px;
overflow: auto; overflow: auto;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: inherit; color: var(--content-color, inherit);
background: transparent; background-color: var(--card-color, transparent);
}
/* Keep the original Energy Pie colors behind the scrollable controls. */
#energy-app::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background:
radial-gradient(520px 420px at 18% 18%, rgba(99, 179, 255, 0.42), transparent 60%),
radial-gradient(560px 460px at 82% 28%, rgba(168, 85, 247, 0.38), transparent 62%),
radial-gradient(620px 520px at 55% 85%, rgba(99, 179, 255, 0.26), transparent 64%);
filter: blur(18px);
pointer-events: none;
} }
.toolbar, .toolbar,
@@ -104,11 +126,16 @@
border: 1px solid rgba(127, 127, 127, 0.45); border: 1px solid rgba(127, 127, 127, 0.45);
border-radius: 6px; border-radius: 6px;
padding: 7px 10px; padding: 7px 10px;
color: inherit; color: var(--content-color, inherit);
background: rgba(127, 127, 127, 0.08); background-color: var(--card-color, transparent);
font: inherit; font: inherit;
} }
select option {
color: var(--content-color, CanvasText);
background-color: var(--card-color, Canvas);
}
button { button {
cursor: pointer; cursor: pointer;
font-weight: 650; font-weight: 650;
@@ -221,6 +248,11 @@
dominant-baseline: middle; dominant-baseline: middle;
} }
.donut-label.unavailable {
font-size: 14px;
font-weight: 600;
}
.error { .error {
min-height: 18px; min-height: 18px;
margin-top: 8px; margin-top: 8px;
@@ -234,7 +266,7 @@
@media (max-width: 560px) { @media (max-width: 560px) {
#energy-app { #energy-app {
padding: 10px; padding: 58px 10px 10px;
} }
.toolbar, .toolbar,
@@ -277,6 +309,23 @@
const selfConsumptionChart = document.getElementById('self-consumption-chart'); const selfConsumptionChart = document.getElementById('self-consumption-chart');
const autarkyChart = document.getElementById('autarky-chart'); const autarkyChart = document.getElementById('autarky-chart');
function syncColorScheme() {
const cardColor = getComputedStyle(document.documentElement)
.getPropertyValue('--card-color')
.trim();
const match = cardColor.match(/^#?([0-9a-f]{6})$/i);
if (!match) {
return;
}
const value = Number.parseInt(match[1], 16);
const red = (value >> 16) & 255;
const green = (value >> 8) & 255;
const blue = value & 255;
const luminance = ((0.2126 * red) + (0.7152 * green) + (0.0722 * blue)) / 255;
document.documentElement.style.colorScheme = luminance < 0.5 ? 'dark' : 'light';
}
function send(action, value) { function send(action, value) {
if (typeof requestAction !== 'function') { if (typeof requestAction !== 'function') {
return; return;
@@ -342,6 +391,8 @@
} }
function donut(target, percent, color) { function donut(target, percent, color) {
const available = percent !== null && percent !== undefined
&& Number.isFinite(Number(percent));
const value = Math.max(0, Math.min(100, asNumber(percent))); const value = Math.max(0, Math.min(100, asNumber(percent)));
const radius = 58; const radius = 58;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
@@ -349,15 +400,17 @@
target.innerHTML = [ target.innerHTML = [
'<svg viewBox="0 0 176 176" role="img" aria-label="' '<svg viewBox="0 0 176 176" role="img" aria-label="'
+ value.toFixed(1) + ' Prozent">', + (available ? value.toFixed(1) + ' Prozent' : 'Nicht bestimmbar') + '">',
'<circle class="donut-track" cx="88" cy="88" r="' + radius '<circle class="donut-track" cx="88" cy="88" r="' + radius
+ '" fill="none" stroke-width="18"></circle>', + '" fill="none" stroke-width="18"></circle>',
'<circle class="donut-value" cx="88" cy="88" r="' + radius available ? '<circle class="donut-value" cx="88" cy="88" r="' + radius
+ '" fill="none" stroke="' + color + '" stroke-width="18"' + '" fill="none" stroke="' + color + '" stroke-width="18"'
+ ' stroke-linecap="round" transform="rotate(-90 88 88)"' + ' stroke-linecap="round" transform="rotate(-90 88 88)"'
+ ' stroke-dasharray="' + filled + ' ' + circumference + '"></circle>', + ' stroke-dasharray="' + filled + ' ' + circumference + '"></circle>' : '',
'<text class="donut-label" x="88" y="91">' available ? '<text class="donut-label" x="88" y="91">'
+ value.toFixed(1) + '%</text>', + value.toFixed(1) + '%</text>'
: '<text class="donut-label unavailable" x="88" y="80">'
+ '<tspan x="88">Nicht</tspan><tspan x="88" dy="20">bestimmbar</tspan></text>',
'</svg>' '</svg>'
].join(''); ].join('');
} }
@@ -372,6 +425,7 @@
return; return;
} }
syncColorScheme();
range.value = data.range || 'day'; range.value = data.range || 'day';
date.value = data.date || ''; date.value = data.date || '';
customStart.value = data.customStart || ''; customStart.value = data.customStart || '';
@@ -396,19 +450,24 @@
const colors = data.colors || {}; const colors = data.colors || {};
donut( donut(
selfConsumptionChart, selfConsumptionChart,
values.Eigenverbrauchsquote, data.sharesAvailable === false ? null : values.Eigenverbrauchsquote,
colors.selfConsumption || '#2F80ED' colors.selfConsumption || '#2F80ED'
); );
donut( donut(
autarkyChart, autarkyChart,
values.Autarkiegrad, data.sharesAvailable === false ? null : values.Autarkiegrad,
colors.autarky || '#22A06B' colors.autarky || '#22A06B'
); );
message.hidden = data.hasData !== false; const hints = [];
message.textContent = data.hasData === false if (data.hasData === false) {
? (data.noDataHint || 'Keine Archivwerte verfügbar.') hints.push(data.noDataHint || 'Keine Archivwerte verfügbar.');
: ''; }
if (data.sharesAvailable === false) {
hints.push(data.attributionHint || 'Eigenverbrauch und Autarkie sind nicht eindeutig bestimmbar.');
}
message.hidden = hints.length === 0;
message.textContent = hints.join(' ');
} }
window.handleMessage = function (data) { window.handleMessage = function (data) {
+12 -3
View File
@@ -25,6 +25,7 @@ class Energiediagramm extends IPSModule
$this->RegisterPropertyInteger('NetzbezugZaehlerID', 0); $this->RegisterPropertyInteger('NetzbezugZaehlerID', 0);
$this->RegisterPropertyInteger('VerbrauchZaehlerID', 0); $this->RegisterPropertyInteger('VerbrauchZaehlerID', 0);
$this->RegisterPropertyFloat('Zaehlerfaktor', 1.0); $this->RegisterPropertyFloat('Zaehlerfaktor', 1.0);
$this->RegisterPropertyBoolean('EnergieanteileBerechenbar', true);
$this->RegisterPropertyString('Standardzeitraum', 'day'); $this->RegisterPropertyString('Standardzeitraum', 'day');
$this->RegisterPropertyInteger('Aktualisierungsintervall', 60); $this->RegisterPropertyInteger('Aktualisierungsintervall', 60);
$this->RegisterPropertyString('FarbeEigenverbrauch', self::COLOR_SELF_CONSUMPTION); $this->RegisterPropertyString('FarbeEigenverbrauch', self::COLOR_SELF_CONSUMPTION);
@@ -37,7 +38,10 @@ class Energiediagramm extends IPSModule
$this->RegisterAttributeString(self::ATTR_CUSTOM_START, $today); $this->RegisterAttributeString(self::ATTR_CUSTOM_START, $today);
$this->RegisterAttributeString(self::ATTR_CUSTOM_END, $today); $this->RegisterAttributeString(self::ATTR_CUSTOM_END, $today);
$this->SetVisualizationType(1); $visualizationType = version_compare(IPS_GetKernelVersion(), '9.0', '>=')
? 2
: 1;
$this->SetVisualizationType($visualizationType);
$this->RegisterTimer( $this->RegisterTimer(
'Refresh', 'Refresh',
0, 0,
@@ -250,6 +254,7 @@ class Energiediagramm extends IPSModule
$customStart, $customStart,
$customEnd $customEnd
); );
$sharesAvailable = $this->ReadPropertyBoolean('EnergieanteileBerechenbar');
$payload = [ $payload = [
'range' => $range, 'range' => $range,
@@ -260,6 +265,9 @@ class Energiediagramm extends IPSModule
'tEnd' => $end, 'tEnd' => $end,
'hasData' => false, 'hasData' => false,
'noDataHint' => 'Energiezähler konfigurieren.', 'noDataHint' => 'Energiezähler konfigurieren.',
'sharesAvailable' => $sharesAvailable,
'attributionHint' => $sharesAvailable ? ''
: 'Eigenverbrauch und Autarkie sind bei unterschiedlichen Messgrenzen nicht eindeutig bestimmbar. Die Energiesummen bleiben separat sichtbar.',
'colors' => [ 'colors' => [
'selfConsumption' => EnergyChartCalculator::normalizeColor( 'selfConsumption' => EnergyChartCalculator::normalizeColor(
$this->ReadPropertyString('FarbeEigenverbrauch'), $this->ReadPropertyString('FarbeEigenverbrauch'),
@@ -270,7 +278,7 @@ class Energiediagramm extends IPSModule
self::COLOR_AUTARKY self::COLOR_AUTARKY
), ),
], ],
'values' => EnergyChartCalculator::metrics(0.0, 0.0, 0.0), 'values' => EnergyChartCalculator::metrics(0.0, 0.0, 0.0, null, $sharesAvailable),
]; ];
if (!$this->configurationIsValid()) { if (!$this->configurationIsValid()) {
@@ -326,7 +334,8 @@ class Energiediagramm extends IPSModule
$production['value'] * $factor, $production['value'] * $factor,
$feedIn['value'] * $factor, $feedIn['value'] * $factor,
$grid['value'] * $factor, $grid['value'] * $factor,
$consumption $consumption,
$sharesAvailable
); );
if (!$payload['hasData']) { if (!$payload['hasData']) {
+9 -3
View File
@@ -4,9 +4,11 @@ Unabhaengige Zusatzmodule fuer IP-Symcon. Dieses Repository ist nicht vom Enelix
## Status ## Status
Das Repository befindet sich im Aufbau. Verbrauchskostenreport, Virtuelle Version 0.1 Build 1 ist die erste Beta fuer kontrollierte Anlagen- und
Batterie, CC100 Hardware, Energiediagramm, VGT-Schnittstelle und Shelly Modul Feldtests. Verbrauchskostenreport, Virtuelle Batterie, CC100 Hardware,
sind als installierbare IP-Symcon-Module enthalten. Energiediagramm, VGT-Schnittstelle und Shelly Modul sind als installierbare
IP-Symcon-Module enthalten. Produktiver Einsatz setzt eine anlagenspezifische
Pruefung und Sicherung voraus.
## Module ## Module
@@ -23,6 +25,10 @@ Punkten stehen in der [Modulübersicht](docs/module/README.md).
Prognose, Lizenzierung und Störüberwachung sind keine Module dieses Prognose, Lizenzierung und Störüberwachung sind keine Module dieses
Repositories. Die Utils-Module bleiben auch untereinander unabhängig. Repositories. Die Utils-Module bleiben auch untereinander unabhängig.
## Release-Hinweise
Die Aenderungen der ersten Beta stehen im [Changelog](CHANGELOG.md).
## Voraussetzungen ## Voraussetzungen
- IP-Symcon ab Version 8.0 - IP-Symcon ab Version 8.0
+8
View File
@@ -19,6 +19,13 @@ Abmelden des WebHooks beim Löschen einer Instanz.
- erzeugt optional einen Schweizer QR-Zahlteil - erzeugt optional einen Schweizer QR-Zahlteil
- unterstützt den konfigurierbaren MWST-Ausweis - unterstützt den konfigurierbaren MWST-Ausweis
## Lizenzierung
Der Report benoetigt eine Grundlizenz. Zusaetzlich prueft das Modul getrennte
Kontingente fuer konfigurierte Stromzaehler und Nebenzaehler. Lizenzcodes werden
zur Laufzeit gegen `https://license.enelix.ch` geprueft; Zugangsdaten oder
Lizenzcodes werden nicht im Repository gespeichert.
## QR-Zahlteil ## QR-Zahlteil
Der QR-Zahlteil verwendet strukturierte Adressen (`S`) nach SIX SPS 2.3. Der QR-Zahlteil verwendet strukturierte Adressen (`S`) nach SIX SPS 2.3.
@@ -30,6 +37,7 @@ Benutzer- und Zahlungsempfängeradresse benötigen Name, PLZ, Ort und ISO-2-Land
| Property | Beschreibung | | Property | Beschreibung |
|---|---| |---|---|
| `Lizenzcode` | Laufzeitparameter fuer die Freigabe am Lizenzdienst |
| `Users` | Benutzerliste | | `Users` | Benutzerliste |
| `PowerMeters` | Stromzählerliste | | `PowerMeters` | Stromzählerliste |
| `WaterMeters` | Nebenkostenzähler | | `WaterMeters` | Nebenkostenzähler |
+36
View File
@@ -1,5 +1,41 @@
{ {
"elements": [ "elements": [
{
"type": "ExpansionPanel",
"caption": "Lizenzierung",
"expanded": true,
"items": [
{
"type": "ValidationTextBox",
"name": "Lizenzcode",
"caption": "Verbrauchskostenlizenzcode",
"placeholder": "ENX-XXXX-XXXX-XXXX-XXXX"
},
{
"type": "Label",
"name": "LizenzInstallationID",
"caption": "Installations-ID wird erzeugt"
},
{
"type": "Label",
"name": "LizenzInformation",
"caption": "Verbrauchskostenlizenzcode fehlt."
},
{
"type": "Button",
"name": "LizenzPruefen",
"caption": "Lizenz prüfen und binden",
"onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);"
},
{
"type": "Button",
"name": "LizenzVerwalten",
"caption": "Lizenz verwalten",
"link": true,
"onClick": "echo 'https://license.enelix.ch';"
}
]
},
{ {
"type": "Label", "type": "Label",
"caption": "🧾 Einstellungen Verbrauchskostenreport" "caption": "🧾 Einstellungen Verbrauchskostenreport"
+302
View File
@@ -36,6 +36,12 @@ class VerbrauchskostenreportPDF extends TCPDF
class Verbrauchskostenreport extends IPSModule class Verbrauchskostenreport extends IPSModule
{ {
private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate';
private const LIZENZ_RETRY_SEKUNDEN = 900;
private const KATALOG_GRUNDLIZENZ = 'cost_report';
private const KATALOG_STROMZAEHLER = 'cost_meter_power';
private const KATALOG_NEBENZAEHLER = 'cost_meter_aux';
/** @var array<int, array<string, array<string, float|string>>> */ /** @var array<int, array<string, array<string, float|string>>> */
private array $powerCostCache = []; private array $powerCostCache = [];
@@ -47,6 +53,7 @@ class Verbrauchskostenreport extends IPSModule
$this->RegisterPropertyString('PowerMeters', '[]'); $this->RegisterPropertyString('PowerMeters', '[]');
$this->RegisterPropertyString('WaterMeters', '[]'); $this->RegisterPropertyString('WaterMeters', '[]');
$this->RegisterPropertyString('Tariffs', '[]'); $this->RegisterPropertyString('Tariffs', '[]');
$this->RegisterPropertyString('Lizenzcode', '');
$this->RegisterPropertyInteger('LogoMediaID', 0); $this->RegisterPropertyInteger('LogoMediaID', 0);
$this->RegisterPropertyString('PropertyText', 'Liegenschaft'); $this->RegisterPropertyString('PropertyText', 'Liegenschaft');
$this->RegisterPropertyString('FooterText', 'Belevo AG • 6122 Menznau • www.belevo.ch'); $this->RegisterPropertyString('FooterText', 'Belevo AG • 6122 Menznau • www.belevo.ch');
@@ -64,6 +71,15 @@ class Verbrauchskostenreport extends IPSModule
$this->RegisterPropertyString('CreditorCountry', 'CH'); $this->RegisterPropertyString('CreditorCountry', 'CH');
$this->RegisterPropertyString('BankIBAN', ''); $this->RegisterPropertyString('BankIBAN', '');
$this->RegisterAttributeString('LizenzInstallationID', '');
$this->RegisterAttributeString('LizenzLease', '{}');
$this->RegisterAttributeString('LizenzcodeHash', '');
$this->RegisterAttributeString('LizenzLetzterFehler', '');
$this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0);
if ($this->ReadAttributeString('LizenzInstallationID') === '') {
$this->WriteAttributeString('LizenzInstallationID', $this->ErzeugeUuidV4());
}
$this->RegisterTariffVariableProfiles(); $this->RegisterTariffVariableProfiles();
$this->RegisterVariableInteger('FromDate', 'Startdatum', '~UnixTimestamp', 1); $this->RegisterVariableInteger('FromDate', 'Startdatum', '~UnixTimestamp', 1);
@@ -106,6 +122,8 @@ class Verbrauchskostenreport extends IPSModule
$this->InitializeVisuTariffDefaults(); $this->InitializeVisuTariffDefaults();
$this->UpdateTariffOverview(); $this->UpdateTariffOverview();
$this->UpdateUserOverview(); $this->UpdateUserOverview();
$this->AktualisiereLizenz($this->ReadPropertyString('Lizenzcode'), false);
$this->AktualisiereLizenzForm();
} }
public function Destroy() public function Destroy()
@@ -630,6 +648,278 @@ form.onsubmit=function(){
return $this->SaveTariffTableFromPayload(json_encode($rows)); return $this->SaveTariffTableFromPayload(json_encode($rows));
} }
private function AktualisiereLizenz(string $lizenzcode, bool $erzwingen): bool
{
$code = $this->NormalisiereLizenzcode($lizenzcode);
if (!$this->IstLizenzcodeGueltig($code)) {
$this->WriteAttributeString(
'LizenzLetzterFehler',
$code === '' ? 'Verbrauchskostenlizenzcode fehlt.' : 'Verbrauchskostenlizenzcode ist ungültig.'
);
return false;
}
$installationID = $this->ReadAttributeString('LizenzInstallationID');
if ($installationID === '') {
$installationID = $this->ErzeugeUuidV4();
$this->WriteAttributeString('LizenzInstallationID', $installationID);
}
$codeHash = hash('sha256', $code);
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
$lease = $this->LeseLizenzLease();
$lokalGueltig = false;
if ($gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash)) {
try {
$this->PruefeLizenzLease($lease, $installationID);
$lokalGueltig = true;
} catch (Throwable $fehler) {
$lokalGueltig = false;
}
}
$erneuernAb = strtotime((string)($lease['refreshAfter'] ?? '')) ?: 0;
if (!$erzwingen && $lokalGueltig && time() < $erneuernAb) {
return true;
}
if (!$erzwingen && time() < $this->ReadAttributeInteger('LizenzNaechsterVersuch')) {
return $lokalGueltig;
}
$this->WriteAttributeInteger(
'LizenzNaechsterVersuch',
time() + self::LIZENZ_RETRY_SEKUNDEN
);
try {
$neueLease = $this->RufeLizenzserverAuf($code, $installationID);
$this->PruefeLizenzLease($neueLease, $installationID);
$this->WriteAttributeString(
'LizenzLease',
json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
);
$this->WriteAttributeString('LizenzcodeHash', $codeHash);
$this->WriteAttributeString('LizenzLetzterFehler', '');
$this->WriteAttributeInteger(
'LizenzNaechsterVersuch',
strtotime((string)$neueLease['refreshAfter']) ?: time() + 86400
);
return true;
} catch (Throwable $fehler) {
$this->WriteAttributeString(
'LizenzLetzterFehler',
substr($fehler->getMessage(), 0, 500)
);
return $lokalGueltig;
}
}
private function AktualisiereLizenzForm(?string $lizenzcode = null): void
{
$this->UpdateFormField(
'LizenzInstallationID',
'caption',
'Installations-ID: ' . $this->ReadAttributeString('LizenzInstallationID')
);
$this->UpdateFormField(
'LizenzInformation',
'caption',
$this->LizenzStatustext($lizenzcode)
);
}
private function LizenzStatustext(?string $lizenzcode = null): string
{
$code = $this->NormalisiereLizenzcode(
$lizenzcode ?? $this->ReadPropertyString('Lizenzcode')
);
if ($code === '') {
return 'Verbrauchskostenlizenzcode fehlt.';
}
if (!$this->IstLizenzcodeGueltig($code)) {
return 'Verbrauchskostenlizenzcode ist ungültig.';
}
$hash = $this->ReadAttributeString('LizenzcodeHash');
$fehler = $this->ReadAttributeString('LizenzLetzterFehler');
if ($hash === '' || !hash_equals($hash, hash('sha256', $code))) {
return $fehler !== '' ? 'Nicht freigegeben: ' . $fehler : 'Lizenz noch nicht bestätigt.';
}
try {
$lease = $this->LeseLizenzLease();
$this->PruefeLizenzLease(
$lease,
$this->ReadAttributeString('LizenzInstallationID')
);
$status = sprintf(
'Aktiv: %d Stromzähler und %d Wärme-/Wasserzähler lizenziert.',
$this->LizenzMenge($lease, self::KATALOG_STROMZAEHLER),
$this->LizenzMenge($lease, self::KATALOG_NEBENZAEHLER)
);
if ($fehler !== '') {
$status .= ' Online-Prüfung fehlgeschlagen; lokale Freigabe ist noch gültig. Fehler: '
. $fehler;
}
return $status;
} catch (Throwable $ungueltig) {
return 'Nicht freigegeben: ' . $ungueltig->getMessage();
}
}
private function PruefeLizenzLease(array $lease, string $installationID): void
{
if (($lease['status'] ?? null) !== 'active') {
throw new RuntimeException('Die Verbrauchskostenlizenz ist nicht aktiv.');
}
if (($lease['installationId'] ?? null) !== $installationID) {
throw new RuntimeException('Die Verbrauchskostenlizenz gehört zu einer anderen Installation.');
}
$offlineBis = strtotime((string)($lease['offlineUntil'] ?? ''));
if ($offlineBis === false || time() >= $offlineBis) {
throw new RuntimeException('Die Offline-Freigabe ist abgelaufen.');
}
if ($this->LizenzMenge($lease, self::KATALOG_GRUNDLIZENZ) < 1) {
throw new RuntimeException('Die Grundlizenz für den Verbrauchskostenreport fehlt.');
}
$strom = count($this->LeseJsonListe('PowerMeters'));
$neben = count($this->LeseJsonListe('WaterMeters'));
$stromLizenziert = $this->LizenzMenge($lease, self::KATALOG_STROMZAEHLER);
$nebenLizenziert = $this->LizenzMenge($lease, self::KATALOG_NEBENZAEHLER);
if ($strom > $stromLizenziert) {
throw new RuntimeException(sprintf(
'Stromzähler: %d konfiguriert, aber nur %d lizenziert.',
$strom,
$stromLizenziert
));
}
if ($neben > $nebenLizenziert) {
throw new RuntimeException(sprintf(
'Wärme-/Wasserzähler: %d konfiguriert, aber nur %d lizenziert.',
$neben,
$nebenLizenziert
));
}
}
private function LizenzMenge(array $lease, string $kennung): int
{
$menge = 0;
foreach (($lease['entitlements'] ?? []) as $berechtigung) {
if (!is_array($berechtigung) || ($berechtigung['status'] ?? null) !== 'active') {
continue;
}
$schluessel = $berechtigung['catalog_key'] ?? $berechtigung['sku'] ?? null;
$anzahl = $berechtigung['quantity'] ?? 0;
if ($schluessel === $kennung && is_int($anzahl) && $anzahl > 0) {
$menge += $anzahl;
}
}
return $menge;
}
private function LeseJsonListe(string $property): array
{
try {
$werte = json_decode(
$this->ReadPropertyString($property),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($werte) ? array_values($werte) : [];
} catch (Throwable $fehler) {
return [];
}
}
private function LeseLizenzLease(): array
{
try {
$lease = json_decode(
$this->ReadAttributeString('LizenzLease'),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($lease) ? $lease : [];
} catch (Throwable $fehler) {
return [];
}
}
private function RufeLizenzserverAuf(string $lizenzcode, string $installationID): array
{
$curl = curl_init(self::LIZENZ_ENDPOINT);
if ($curl === false) {
throw new RuntimeException('Die HTTPS-Anfrage konnte nicht vorbereitet werden.');
}
$nutzlast = json_encode([
'code' => $lizenzcode,
'installationId' => $installationID,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
try {
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $nutzlast,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json'],
CURLOPT_USERAGENT => 'Enelix-Verbrauchskostenreport/0.1',
]);
$antwort = curl_exec($curl);
if ($antwort === false) {
throw new RuntimeException('Lizenzserver nicht erreichbar: ' . curl_error($curl));
}
if (strlen($antwort) > 65536) {
throw new RuntimeException('Die Antwort des Lizenzservers ist zu gross.');
}
$httpStatus = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
} finally {
curl_close($curl);
}
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new RuntimeException('Der Lizenzserver lieferte kein JSON-Objekt.');
}
if ($httpStatus !== 200) {
throw new RuntimeException(
'Lizenzserver HTTP ' . $httpStatus . ': '
. (is_string($daten['error'] ?? null) ? $daten['error'] : 'Unbekannter Fehler')
);
}
return $daten;
}
private function NormalisiereLizenzcode(string $code): string
{
return strtoupper((string)preg_replace('/[^A-Z0-9]/i', '', $code));
}
private function IstLizenzcodeGueltig(string $code): bool
{
return preg_match('/^ENX[A-HJ-NP-Z2-9]{16}$/', $code) === 1;
}
private function ErzeugeUuidV4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
$hex = bin2hex($bytes);
return sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12)
);
}
private function ReadUsers() private function ReadUsers()
{ {
$users = json_decode($this->ReadPropertyString('Users'), true); $users = json_decode($this->ReadPropertyString('Users'), true);
@@ -1348,6 +1638,14 @@ refreshPickers();
public function RequestAction($Ident, $Value) public function RequestAction($Ident, $Value)
{ {
switch ($Ident) { switch ($Ident) {
case 'FormLizenzPruefen':
if (!is_string($Value)) {
throw new InvalidArgumentException('Der Lizenzcode muss als Text übergeben werden.');
}
$this->AktualisiereLizenz($Value, true);
$this->AktualisiereLizenzForm($Value);
break;
case 'FromDate': case 'FromDate':
case 'ToDate': case 'ToDate':
case 'VisuTariffType': case 'VisuTariffType':
@@ -1458,6 +1756,10 @@ refreshPickers();
public function GenerateReport() public function GenerateReport()
{ {
if (!$this->AktualisiereLizenz($this->ReadPropertyString('Lizenzcode'), false)) {
throw new RuntimeException($this->LizenzStatustext());
}
$from = GetValue($this->GetIDForIdent('FromDate')); $from = GetValue($this->GetIDForIdent('FromDate'));
$to = GetValue($this->GetIDForIdent('ToDate')); $to = GetValue($this->GetIDForIdent('ToDate'));
+1
View File
@@ -2,6 +2,7 @@
"name": "belevo/enelix-utils", "name": "belevo/enelix-utils",
"description": "Unabhaengige Zusatzmodule fuer IP-Symcon", "description": "Unabhaengige Zusatzmodule fuer IP-Symcon",
"type": "library", "type": "library",
"license": "proprietary",
"require": { "require": {
"php": ">=8.0" "php": ">=8.0"
}, },
@@ -7,6 +7,12 @@
Der Modulpräfix lautet `VKR`. Der Report wird über die Aktion `CreateReport` Der Modulpräfix lautet `VKR`. Der Report wird über die Aktion `CreateReport`
erstellt und im Medium `ReportPDF` bereitgestellt. erstellt und im Medium `ReportPDF` bereitgestellt.
## Lizenzierung
Die Freigabe besteht aus einer Grundlizenz und getrennten Kontingenten fuer
Stromzaehler und Nebenzaehler. Der konfigurierte Umfang darf die vom
Lizenzdienst bestaetigten Mengen nicht ueberschreiten.
## Variablen ## Variablen
| Technischer Ident / Anzeige | Typ / Zugriff | Beschreibung | | Technischer Ident / Anzeige | Typ / Zugriff | Beschreibung |
@@ -28,6 +34,7 @@ erstellt und im Medium `ReportPDF` bereitgestellt.
| Technischer Ident / Anzeige | Typ | Standard / Beschreibung | | Technischer Ident / Anzeige | Typ | Standard / Beschreibung |
| --- | --- | --- | | --- | --- | --- |
| `Lizenzcode` / Lizenzcode | String | leer; wird zur Laufzeit am Lizenzdienst geprueft. |
| `Users` / Benutzer | String/JSON | `[]`; Benutzerstruktur. | | `Users` / Benutzer | String/JSON | `[]`; Benutzerstruktur. |
| `PowerMeters` / Stromzähler | String/JSON | `[]`; Zuordnungen. | | `PowerMeters` / Stromzähler | String/JSON | `[]`; Zuordnungen. |
| `WaterMeters` / Wasserzähler | String/JSON | `[]`; Zuordnungen. | | `WaterMeters` / Wasserzähler | String/JSON | `[]`; Zuordnungen. |
+9 -9
View File
@@ -43,7 +43,7 @@ werden, wenn sie vor dem Lauf nicht existierten.
- `affected`: die durch geänderte Pfade ermittelten Module - `affected`: die durch geänderte Pfade ermittelten Module
Verfügbare Module: `CC100Hardware`, `Energiediagramm`, `ShellyModul`, Verfügbare Module: `CC100Hardware`, `Energiediagramm`, `ShellyModul`,
`Verbrauchskostenreport`. `VGTSchnittstelle`, `Verbrauchskostenreport` und `VirtuelleBatterie`.
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.
@@ -84,15 +84,15 @@ Zusätzlich schreibt der Runner die Zusammenfassung in das IP-Symcon-Log.
## CI-Regeln ## CI-Regeln
Bei jedem Push laufen sämtliche PHPUnit-Tests. Auf `develop` werden zusätzlich Bei jedem Push laufen sämtliche PHPUnit-Tests und die PHP-Syntaxprüfung. Die
die von den geänderten Pfaden betroffenen Symcon-Modultests ausgeführt. Bei Symcon-Modultests werden bis zur Verfügbarkeit eines geschützten Runners auf
gemeinsamen Framework- oder Vertragsänderungen läuft die vollständige Suite. der isolierten IP-Symcon-8.0-Instanz des Agent-Servers ausgeführt. Vor jeder
Für `beta` ist immer `all` vorgeschrieben. Übernahme nach `beta` ist ein erfolgreicher Lauf im Modus `all` verbindlich.
Der erzeugte JSON- und JUnit-Bericht gehört zum Freigabenachweis.
Die Symcon-Jobs benötigen einen geschützten Runner mit dem Label `symcon-8`, Eine spätere CI-Automatisierung benötigt einen geschützten Runner mit dem Label
lokalem Zugriff auf eine isolierte IP-Symcon-8.x-Instanz und den installierten `symcon-8`, lokalem Zugriff auf die isolierte IP-Symcon-Instanz und den exakt
Repository-Stand. Ohne diesen Runner ist die Richtlinie dokumentiert, aber zu prüfenden Repository-Stand.
nicht technisch durchsetzbar.
## Checkliste für neue Module ## Checkliste für neue Module
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"id": "{05DE4157-F0E8-4CA9-8B5E-D6AB9310775F}", "id": "{05DE4157-F0E8-4CA9-8B5E-D6AB9310775F}",
"name": "Enelix Utils", "name": "Enelix Utils",
"author": "Belevo AG", "author": "Enelix",
"url": "https://git.belevo.ch/ENELIX/Enelix-Utils", "url": "https://git.belevo.ch/ENELIX/Enelix-Utils",
"compatibility": { "compatibility": {
"version": "8.0" "version": "8.0"
}, },
"version": "0.1", "version": "0.1",
"build": 0, "build": 1,
"date": 0 "date": 1790662541
} }
+36 -2
View File
@@ -33,6 +33,7 @@ final class EnergiediagrammModulstrukturTest extends TestCase
'EinspeisungZaehlerID', 'EinspeisungZaehlerID',
'NetzbezugZaehlerID', 'NetzbezugZaehlerID',
'VerbrauchZaehlerID', 'VerbrauchZaehlerID',
'EnergieanteileBerechenbar',
'Standardzeitraum', 'Standardzeitraum',
'FarbeEigenverbrauch', 'FarbeEigenverbrauch',
'FarbeAutarkie', 'FarbeAutarkie',
@@ -49,11 +50,40 @@ final class EnergiediagrammModulstrukturTest extends TestCase
self::assertStringContainsString('__ENELIX_INITIAL_STATE__', $html); self::assertStringContainsString('__ENELIX_INITIAL_STATE__', $html);
self::assertStringContainsString('ResizeObserver', $html); self::assertStringContainsString('ResizeObserver', $html);
self::assertStringContainsString('grid-template-columns: minmax(0, 1fr)', $html); self::assertStringContainsString('grid-template-columns: minmax(0, 1fr)', $html);
self::assertStringContainsString('padding: 58px 14px 14px', $html);
self::assertStringContainsString('padding: 58px 10px 10px', $html);
self::assertStringContainsString('background-color: var(--card-color, transparent)', $html);
self::assertStringContainsString('color: var(--content-color, CanvasText)', $html);
self::assertStringContainsString('function syncColorScheme()', $html);
self::assertStringContainsString('SetCustomRange', $html); self::assertStringContainsString('SetCustomRange', $html);
self::assertStringNotContainsString("addEventListener('load'", $html); self::assertStringNotContainsString("addEventListener('load'", $html);
} }
public function testModuleEmbedsInitialPayloadForTileAndPopup(): void public function testOriginalEnergyPieBackgroundStaysReadableAndNonInteractive(): void
{
$html = file_get_contents(__DIR__ . '/../Energiediagramm/module.html');
self::assertNotFalse($html);
self::assertMatchesRegularExpression('/#energy-app\\s*\\{[^}]*--card-color: #121216;/s', $html);
self::assertMatchesRegularExpression('/#energy-app\\s*\\{[^}]*--content-color: #f5f5f7;/s', $html);
self::assertMatchesRegularExpression('/#energy-app\\s*\\{[^}]*color-scheme: dark;/s', $html);
self::assertMatchesRegularExpression('/#energy-app\\s*\\{[^}]*isolation: isolate;/s', $html);
self::assertSame(1, preg_match('/#energy-app::before\\s*\\{([^}]+)\\}/s', $html, $background));
foreach ([
'inset: 0',
'z-index: -1',
'radial-gradient(520px 420px at 18% 18%, rgba(99, 179, 255, 0.42), transparent 60%)',
'radial-gradient(560px 460px at 82% 28%, rgba(168, 85, 247, 0.38), transparent 62%)',
'radial-gradient(620px 520px at 55% 85%, rgba(99, 179, 255, 0.26), transparent 64%)',
'filter: blur(18px)',
'pointer-events: none',
] as $style) {
self::assertStringContainsString($style, $background[1]);
}
self::assertStringContainsString('overflow: auto', $html);
self::assertStringNotContainsString('touch-action: none', $html);
}
public function testModuleEmbedsInitialPayloadAndEnablesSupportedVisualization(): void
{ {
$module = file_get_contents(__DIR__ . '/../Energiediagramm/module.php'); $module = file_get_contents(__DIR__ . '/../Energiediagramm/module.php');
self::assertNotFalse($module); self::assertNotFalse($module);
@@ -61,7 +91,11 @@ final class EnergiediagrammModulstrukturTest extends TestCase
"str_replace('__ENELIX_INITIAL_STATE__'", "str_replace('__ENELIX_INITIAL_STATE__'",
$module $module
); );
self::assertStringContainsString('GetVisualizationPopup', $module); self::assertStringContainsString(
"version_compare(IPS_GetKernelVersion(), '9.0', '>=')",
$module
);
self::assertStringContainsString('SetVisualizationType($visualizationType)', $module);
self::assertStringContainsString('Standardzeitraum', $module); self::assertStringContainsString('Standardzeitraum', $module);
self::assertStringContainsString('CustomStart', $module); self::assertStringContainsString('CustomStart', $module);
self::assertStringContainsString('CustomEnd', $module); self::assertStringContainsString('CustomEnd', $module);
+37 -1
View File
@@ -110,9 +110,45 @@ final class EnergiediagrammTest extends TestCase
'#2F80ED', '#2F80ED',
EnergyChartCalculator::normalizeColor('#2f80ed', '#000000') EnergyChartCalculator::normalizeColor('#2f80ed', '#000000')
); );
self::assertSame(
'#00FF4C',
EnergyChartCalculator::normalizeColor('65356', '#000000')
);
self::assertSame(
'#000000',
EnergyChartCalculator::normalizeColor('0', '#FFFFFF')
);
self::assertSame( self::assertSame(
'#22A06B', '#22A06B',
EnergyChartCalculator::normalizeColor('transparent', '#22a06b') EnergyChartCalculator::normalizeColor('16777216', '#22a06b')
);
self::assertSame(
'#22A06B',
EnergyChartCalculator::normalizeColor('-1', '#22a06b')
); );
} }
public function testMixedMeasurementBoundariesKeepTotalsWithoutInventingShares(): void
{
$values = EnergyChartCalculator::metrics(10.0, 0.0, 10.0, 5.0, false);
self::assertSame(10.0, $values['Produktion']);
self::assertSame(0.0, $values['Einspeisung']);
self::assertSame(10.0, $values['Netzbezug']);
self::assertSame(5.0, $values['Hausverbrauch']);
self::assertNull($values['Eigenverbrauch']);
self::assertNull($values['Eigenverbrauchsquote']);
self::assertNull($values['Autarkiegrad']);
}
public function testUnavailableSharesRemainNullWithZeroEnergy(): void
{
$unavailable = EnergyChartCalculator::metrics(0.0, 0.0, 0.0, null, false);
self::assertNull($unavailable['Eigenverbrauchsquote']);
self::assertNull($unavailable['Autarkiegrad']);
$available = EnergyChartCalculator::metrics(0.0, 0.0, 0.0);
self::assertSame(0.0, $available['Eigenverbrauchsquote']);
self::assertSame(0.0, $available['Autarkiegrad']);
}
} }
+88
View File
@@ -0,0 +1,88 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
function visualization(initialState) {
const elements = new Map();
const element = id => {
if (!elements.has(id)) {
elements.set(id, {
innerHTML: '', textContent: '', value: '', hidden: false,
addEventListener() {}, classList: { toggle() {} }
});
}
return elements.get(id);
};
const window = { setTimeout() {} };
const document = {
getElementById: element,
documentElement: { style: {} },
addEventListener() {}
};
const html = fs.readFileSync(path.join(__dirname, '../Energiediagramm/module.html'), 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace('__ENELIX_INITIAL_STATE__', JSON.stringify(initialState));
vm.runInNewContext(script, {
window, document,
getComputedStyle: () => ({ getPropertyValue: () => '' })
});
return { element, window };
}
function payload(overrides = {}) {
return {
range: 'day', date: '2026-10-06', hasData: true,
values: {
Produktion: 10, Hausverbrauch: 5, Netzbezug: 10, Einspeisung: 0,
Eigenverbrauchsquote: null, Autarkiegrad: null
},
...overrides
};
}
test('unavailable shares show neutral rings while preserving energy totals', () => {
const ui = visualization(payload({ sharesAvailable: false, attributionHint: 'Different measurement boundaries.' }));
for (const id of ['self-consumption-chart', 'autarky-chart']) {
const html = ui.element(id).innerHTML;
assert.match(html, /Nicht bestimmbar/);
assert.doesNotMatch(html, /donut-value|0\.0%/);
}
assert.match(ui.element('summary').innerHTML, /Produktion/);
assert.match(ui.element('summary').innerHTML, /10\.00 kWh/);
assert.match(ui.element('summary').innerHTML, /5\.00 kWh/);
assert.equal(ui.element('message').hidden, false);
assert.equal(ui.element('message').textContent, 'Different measurement boundaries.');
});
test('unavailable flag cannot display stale numeric percentages', () => {
const state = payload({ sharesAvailable: false });
state.values.Eigenverbrauchsquote = 75;
state.values.Autarkiegrad = 50;
const ui = visualization(state);
assert.doesNotMatch(ui.element('self-consumption-chart').innerHTML, /75\.0%/);
assert.doesNotMatch(ui.element('autarky-chart').innerHTML, /50\.0%/);
});
test('legacy numeric percentages and later recovery remain compatible', () => {
const ui = visualization(payload({ sharesAvailable: false }));
const state = payload();
state.values.Eigenverbrauchsquote = 75;
state.values.Autarkiegrad = 81.25;
ui.window.handleMessage(JSON.stringify(state));
assert.match(ui.element('self-consumption-chart').innerHTML, /75\.0%/);
assert.match(ui.element('autarky-chart').innerHTML, /81\.3%/);
assert.equal(ui.element('message').hidden, true);
});
test('missing archive values do not hide the attribution limitation', () => {
const ui = visualization(payload({
hasData: false, noDataHint: 'No archive data.',
sharesAvailable: false, attributionHint: 'Different measurement boundaries.'
}));
assert.equal(ui.element('message').textContent,
'No archive data. Different measurement boundaries.');
});
+4
View File
@@ -15,7 +15,11 @@ final class LibraryMetadataTest extends TestCase
$daten = json_decode($inhalt, true, 512, JSON_THROW_ON_ERROR); $daten = json_decode($inhalt, true, 512, JSON_THROW_ON_ERROR);
self::assertSame('Enelix Utils', $daten['name']); self::assertSame('Enelix Utils', $daten['name']);
self::assertSame('Enelix', $daten['author']);
self::assertSame('8.0', $daten['compatibility']['version']); self::assertSame('8.0', $daten['compatibility']['version']);
self::assertSame('0.1', $daten['version']);
self::assertSame(1, $daten['build']);
self::assertGreaterThan(0, $daten['date']);
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']); self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']);
} }