diff --git a/Manager/form.json b/Manager/form.json index b9ad855..0037369 100644 --- a/Manager/form.json +++ b/Manager/form.json @@ -636,9 +636,21 @@ "caption": "Stoerueberwachung", "items": [ { - "type": "ValidationTextBox", - "name": "StoermeldeAnschluss", - "caption": "Konfiguration als JSON" + "type": "CheckBox", + "name": "StoerueberwachungAktiv", + "caption": "Stoerungen sicher an license.enelix.ch uebertragen" + }, + { + "type": "NumberSpinner", + "name": "StoerungsSendeintervall", + "caption": "Heartbeat-Intervall", + "minimum": 60, + "maximum": 3600, + "suffix": " Sekunden" + }, + { + "type": "Label", + "caption": "Die Freischaltung als jaehrlich erneuerbare Lizenz ist vorbereitet und wird spaeter aktiviert." } ] } diff --git a/Manager/module.php b/Manager/module.php index 1a77069..4369bde 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -8,13 +8,17 @@ require_once __DIR__ . '/../libs/ManagerRegler.php'; require_once __DIR__ . '/../libs/EinspeiseRegler.php'; require_once __DIR__ . '/../libs/Lizenzpruefung.php'; require_once __DIR__ . '/../libs/Anlagentopologie.php'; +require_once __DIR__ . '/../libs/StoerungsSnapshot.php'; +require_once __DIR__ . '/../libs/EnergieMessung.php'; use Belevo\EnelixEMS\Anlagentopologie; use Belevo\EnelixEMS\EinspeiseRegler; +use Belevo\EnelixEMS\EnergieMessung; use Belevo\EnelixEMS\Lizenzpruefung; use Belevo\EnelixEMS\ManagerRegler; use Belevo\EnelixEMS\ManagerSchnittstelle; use Belevo\EnelixEMS\Nachrichtenvertrag; +use Belevo\EnelixEMS\StoerungsSnapshot; class Manager extends IPSModule implements ManagerSchnittstelle { @@ -26,8 +30,26 @@ class Manager extends IPSModule implements ManagerSchnittstelle private const PROGNOSE_TOPOLOGIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology'; private const PROGNOSE_TELEMETRIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry'; private const PROGNOSE_FAHRPLAN_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/schedule'; + private const STOERUNGS_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/faults'; private const LIZENZ_RETRY_SEKUNDEN = 3600; + private const STOERUNGS_RETRY_MAX_SEKUNDEN = 900; private const VM_UPDATE = 10603; + private const ARCHIVE_MODULE_ID = '{43192F0B-135B-4CE7-A0A7-1475603F3060}'; + private const ENERGY_PIE_MODULE_ID = '{F73B8F8D-5E36-4B23-B379-4632A517A1D6}'; + private const LEISTUNGS_VARIABLEN = [ + 'PVLeistungArchiv', + 'HausverbrauchLeistungArchiv', + 'NetzleistungArchiv', + 'BatterieleistungArchiv', + ]; + private const ENERGIE_VARIABLEN = [ + 'PVEnergie', + 'Hausenergie', + 'NetzbezugEnergie', + 'EinspeisungEnergie', + 'BatterieLadenEnergie', + 'BatterieEntladenEnergie', + ]; private const VERBRAUCHER_LIZENZEN = [ '{B7C54AF4-AD7D-4FE4-B75D-203693906251}' => [ 'Katalog' => 'boiler_multi', @@ -117,9 +139,25 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterPropertyString('Lizenzcode', ''); // Verdeckte Altproperty fuer bestehende Instanzkonfigurationen. $this->RegisterPropertyString('LizenzAnschluss', '{}'); + // Verdeckte Altproperty fuer bestehende Instanzkonfigurationen. $this->RegisterPropertyString('StoermeldeAnschluss', '{}'); + $this->RegisterPropertyBoolean('StoerueberwachungAktiv', false); + $this->RegisterPropertyInteger('StoerungsSendeintervall', 300); $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); $this->RegisterPropertyBoolean('LoggingEin', false); + $this->RegisterPropertyBoolean('EnergieaufzeichnungAktiv', true); + $this->RegisterPropertyInteger('MessungPVLeistungVariableID', 0); + $this->RegisterPropertyInteger('MessungHausverbrauchLeistungVariableID', 0); + $this->RegisterPropertyInteger('MessungBatterieleistungVariableID', 0); + $this->RegisterPropertyFloat('MessungPVLeistungsfaktor', 1.0); + $this->RegisterPropertyFloat('MessungHausverbrauchLeistungsfaktor', 1.0); + $this->RegisterPropertyFloat('MessungBatterieleistungsfaktor', 1.0); + $this->RegisterPropertyBoolean('MesswerteAnzeigen', false); + $this->RegisterPropertyInteger('LeistungsaufzeichnungMinuten', 1); + $this->RegisterPropertyInteger('LeistungLoeschenMonate', 12); + $this->RegisterPropertyInteger('EnergieaufzeichnungMinuten', 5); + $this->RegisterPropertyInteger('EnergieVerdichtenMonate', 12); + $this->RegisterPropertyInteger('EnergieLoeschenMonate', 0); $this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10); $this->EnableAction('Aktiv'); @@ -138,6 +176,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0); $this->RegisterAttributeString('LizenzLetzterFehler', ''); $this->RegisterAttributeString('PrognoseInstallationsToken', ''); + $this->RegisterAttributeString('StoerungsSnapshot', '{}'); + $this->RegisterAttributeString('StoerungsGesendeterHash', ''); + $this->RegisterAttributeInteger('StoerungsLetzterErfolg', 0); + $this->RegisterAttributeInteger('StoerungsNaechsterVersuch', 0); + $this->RegisterAttributeInteger('StoerungsFehlversuche', 0); // Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren. $this->RegisterTimer('Regelzyklus', 0, ''); @@ -152,6 +195,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle 0, "IPS_RequestAction(\$_IPS['TARGET'], 'PrognoseSenden', true);" ); + $this->RegisterTimer( + 'StoerungenSenden', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'StoerungenSenden', true);" + ); } public function ApplyChanges(): void @@ -169,6 +217,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->SetTimerInterval('VorgabenErneuern', 0); $this->SetTimerInterval('KeepAlive', 0); $this->SetTimerInterval('PrognoseSenden', 0); + $this->SetTimerInterval('StoerungenSenden', 0); $this->protokolliere('Konfiguration', $fehler->getMessage()); return; } @@ -178,9 +227,15 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->SetTimerInterval('VorgabenErneuern', 0); $this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000); $this->SetTimerInterval('PrognoseSenden', 0); + $this->SetTimerInterval( + 'StoerungenSenden', + $this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 30000 : 0 + ); $this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen')); $this->aktualisiereAnschlussstatus(); - $geraetezugangFehlt = ($this->hatAnlagentopologie() || $this->prognoseTelemetrieKonfiguriert()) + $geraetezugangFehlt = ($this->hatAnlagentopologie() + || $this->prognoseTelemetrieKonfiguriert() + || $this->ReadPropertyBoolean('StoerueberwachungAktiv')) && $this->ReadAttributeString('PrognoseInstallationsToken') === ''; if (!$this->aktualisiereLizenzfreigabe( $this->ReadPropertyString('Lizenzcode'), @@ -207,6 +262,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle ); $this->sendePrognoseTelemetrie(); } + if ($this->ReadPropertyBoolean('StoerueberwachungAktiv')) { + $this->sendeStoerungsSnapshot(true); + } } public function GetConfigurationForm(): string @@ -347,6 +405,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->sendePrognoseTelemetrie(); return; + case 'StoerungenSenden': + $this->sendeStoerungsSnapshot(false); + return; + case 'FormLizenzPruefen': if (!is_string($wert)) { throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.'); @@ -1202,17 +1264,186 @@ class Manager extends IPSModule implements ManagerSchnittstelle */ private function aktualisiereStoerungen(array $verbraucher, array $stoerungen): void { + $meldungen = []; + foreach (array_values(array_unique($stoerungen)) as $meldung) { + $meldung = trim($meldung); + if ($meldung === '') { + continue; + } + $meldungen[] = [ + 'sourceType' => 'manager', + 'sourceId' => 'ips:' . $this->InstanceID, + 'sourceName' => IPS_GetName($this->InstanceID), + 'code' => 'Manager.' . substr(hash('sha256', $meldung), 0, 16), + 'severity' => 'error', + 'message' => $meldung, + ]; + } + foreach ($verbraucher as $eintrag) { foreach ($eintrag['Daten']['Zustand'] as $zustand) { - if ($zustand['Art'] === 'Stoerung' && $zustand['Wert'] === true) { - $stoerungen[] = 'Instanz ' . $eintrag['InstanzID'] . ': ' - . ($zustand['Text'] ?? $zustand['Kennung']); + if ($zustand['Art'] !== 'Stoerung' || $zustand['Wert'] !== true) { + continue; } + $instanzID = $eintrag['InstanzID']; + $text = trim((string) ($zustand['Text'] ?? $zustand['Kennung'])); + $meldungen[] = [ + 'sourceType' => 'consumer', + 'sourceId' => 'ips:' . $instanzID, + 'sourceName' => IPS_InstanceExists($instanzID) + ? IPS_GetName($instanzID) + : 'Instanz ' . $instanzID, + 'code' => (string) $zustand['Kennung'], + 'severity' => 'error', + 'message' => $text !== '' ? $text : (string) $zustand['Kennung'], + ]; } } - $stoerungen = array_values(array_unique($stoerungen)); - $this->setzeDiagnosewert('Sammelstoerung', $stoerungen !== []); - $this->setzeDiagnosewert('Stoertext', implode("\n", $stoerungen)); + + $snapshot = StoerungsSnapshot::erstellen( + $this->initialisiereLizenzInstallationID(), + $this->InstanceID, + $this->stoerungsRolle(), + (bool) $this->GetValue('Aktiv'), + $meldungen + ); + $this->WriteAttributeString( + 'StoerungsSnapshot', + json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) + ); + + $texte = array_map( + static fn (array $meldung): string => $meldung['sourceName'] . ': ' . $meldung['message'], + $snapshot['faults'] + ); + $this->setzeDiagnosewert('Sammelstoerung', $texte !== []); + $this->setzeDiagnosewert('Stoertext', implode("\n", $texte)); + $this->sendeStoerungsSnapshot(false); + } + + private function stoerungsRolle(): string + { + return [ + 0 => 'standalone', + 1 => 'main', + 2 => 'submanager', + ][$this->ReadPropertyInteger('Rolle')] ?? 'standalone'; + } + + private function sendeStoerungsSnapshot(bool $erzwingen): void + { + if (!$this->ReadPropertyBoolean('StoerueberwachungAktiv')) { + $this->setzeDiagnosewert('Stoerueberwachungsstatus', 'NichtVerwendet'); + return; + } + + try { + $snapshot = $this->leseJsonAttribut('StoerungsSnapshot'); + if (($snapshot['version'] ?? null) !== StoerungsSnapshot::VERSION) { + // Erst ein real erfasster Vollsnapshot darf bestehende Portalstoerungen aufloesen. + $this->setzeDiagnosewert('Stoerueberwachungsstatus', 'Wartet'); + return; + } + $hash = StoerungsSnapshot::zustandHash($snapshot); + $jetzt = time(); + if (!$erzwingen && $jetzt < $this->ReadAttributeInteger('StoerungsNaechsterVersuch')) { + return; + } + if (!$erzwingen + && hash_equals($this->ReadAttributeString('StoerungsGesendeterHash'), $hash) + && ($jetzt - $this->ReadAttributeInteger('StoerungsLetzterErfolg')) + < $this->ReadPropertyInteger('StoerungsSendeintervall') + ) { + return; + } + + $token = trim($this->ReadAttributeString('PrognoseInstallationsToken')); + if ($token === '') { + throw new RuntimeException('Der Geraetezugang fuer die Stoerueberwachung fehlt.'); + } + $snapshot['capturedAt'] = gmdate('Y-m-d\TH:i:s\Z', $jetzt); + $this->uebertrageStoerungsSnapshot( + $this->ReadAttributeString('LizenzInstallationID'), + $snapshot, + $token + ); + $this->WriteAttributeString('StoerungsGesendeterHash', $hash); + $this->WriteAttributeInteger('StoerungsLetzterErfolg', $jetzt); + $this->WriteAttributeInteger('StoerungsNaechsterVersuch', 0); + $this->WriteAttributeInteger('StoerungsFehlversuche', 0); + $this->setzeDiagnosewert('Stoerueberwachungsstatus', 'Verbunden'); + } catch (Throwable $fehler) { + $versuche = $this->ReadAttributeInteger('StoerungsFehlversuche') + 1; + $wartezeit = min( + self::STOERUNGS_RETRY_MAX_SEKUNDEN, + 30 * (2 ** min($versuche - 1, 5)) + ); + $meldung = substr($fehler->getMessage(), 0, 500); + $this->WriteAttributeInteger('StoerungsFehlversuche', $versuche); + $this->WriteAttributeInteger('StoerungsNaechsterVersuch', time() + $wartezeit); + $this->setzeDiagnosewert('Stoerueberwachungsstatus', 'Fehler: ' . $meldung); + $this->protokolliere('Stoerueberwachung', $meldung); + } + } + + /** @param array $nutzlast */ + private function uebertrageStoerungsSnapshot( + string $installationID, + array $nutzlast, + string $token + ): void { + $url = sprintf(self::STOERUNGS_ENDPOINT, rawurlencode($installationID)); + $json = json_encode($nutzlast, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Die Stoerungsuebertragung konnte nicht vorbereitet werden.'); + } + + try { + curl_setopt_array($curl, [ + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_POSTFIELDS => $json, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 10, + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json', + ], + CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.3', + ]); + $antwort = curl_exec($curl); + if ($antwort === false) { + throw new RuntimeException( + 'Stoerungsdienst nicht erreichbar: ' . curl_error($curl) + ); + } + if (strlen($antwort) > 65536) { + throw new RuntimeException('Die Antwort der Stoerungsuebertragung ist zu gross.'); + } + $httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE); + } finally { + curl_close($curl); + } + + if ($httpStatus === 401 || $httpStatus === 403) { + $this->WriteAttributeString('PrognoseInstallationsToken', ''); + } + if ($httpStatus !== 200) { + $meldung = 'Unbekannter Fehler'; + try { + $daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR); + if (is_array($daten) && is_string($daten['error'] ?? null)) { + $meldung = $daten['error']; + } + } catch (Throwable $fehler) { + // Der HTTP-Status bleibt die fuehrende Fehlerinformation. + } + throw new RuntimeException( + 'Stoerungsuebertragung HTTP ' . $httpStatus . ': ' . $meldung + ); + } } private function registriereNetzleistungsmeldung(): void @@ -1635,7 +1866,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle $nutzlast = json_encode([ 'code' => $lizenzcode, 'installationId' => $installationID, - 'issueDeviceToken' => $this->hatAnlagentopologie() + 'issueDeviceToken' => ($this->hatAnlagentopologie() + || $this->prognoseTelemetrieKonfiguriert() + || $this->ReadPropertyBoolean('StoerueberwachungAktiv')) && $this->ReadAttributeString('PrognoseInstallationsToken') === '', ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); $curl = curl_init(self::LIZENZ_ENDPOINT); @@ -1708,12 +1941,15 @@ class Manager extends IPSModule implements ManagerSchnittstelle foreach ([ 'PrognoseAnschluss' => 'Prognosestatus', 'SDLAnschluss' => 'SDLStatus', - 'StoermeldeAnschluss' => 'Stoerueberwachungsstatus', ] as $property => $variable) { $konfiguration = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); $aktiv = is_array($konfiguration) && ($konfiguration['Aktiv'] ?? false) === true; $this->setzeDiagnosewert($variable, $aktiv ? 'Fehler' : 'NichtVerwendet'); } + $this->setzeDiagnosewert( + 'Stoerueberwachungsstatus', + $this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 'Wartet' : 'NichtVerwendet' + ); } private function pruefeKonfiguration(): void @@ -1727,6 +1963,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle if ($intervall < 60 || $intervall > 3600) { throw new InvalidArgumentException('PrognoseSendeintervall muss zwischen 60 und 3600 Sekunden liegen.'); } + $stoerungsIntervall = $this->ReadPropertyInteger('StoerungsSendeintervall'); + if ($stoerungsIntervall < 60 || $stoerungsIntervall > 3600) { + throw new InvalidArgumentException('StoerungsSendeintervall muss zwischen 60 und 3600 Sekunden liegen.'); + } $telemetrieIDs = [ $this->ReadPropertyInteger('PrognosePVVariableID'), $this->ReadPropertyInteger('PrognoseHausverbrauchVariableID'), @@ -1771,7 +2011,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung'); $this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')); $this->leseAnlagentopologie(); - foreach (['PrognoseAnschluss', 'SDLAnschluss', 'StoermeldeAnschluss'] as $property) { + foreach (['PrognoseAnschluss', 'SDLAnschluss'] as $property) { $wert = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); if (!is_array($wert)) { throw new InvalidArgumentException($property . ' muss ein JSON-Objekt sein.'); diff --git a/docs/Obere-Anschluesse.md b/docs/Obere-Anschluesse.md index 80072d6..4e9cdd1 100644 --- a/docs/Obere-Anschluesse.md +++ b/docs/Obere-Anschluesse.md @@ -1,34 +1,60 @@ # Obere Anschlüsse des Managers -> Status: Lizenzierung implementiert; die übrigen Anbieterformate sind noch -> nicht festgelegt. +> Status: Lizenzierung, Prognose und Stoerueberwachung sind an +> `license.enelix.ch` angebunden. SDL/VGT bleibt als optionaler Anschluss +> vorbereitet. -Prognose, SDL/VGT und Stoerueberwachung sind optional. Die Lizenzierung ist fuer -den Betrieb des Managers erforderlich. Sie werden im Manager konfiguriert und erzeugen -keine Abhängigkeit zwischen Enelix EMS und Enelix Utils. +Die Anschluesse werden ausschliesslich im Manager konfiguriert. Sie erzeugen +keine Abhaengigkeit zwischen Enelix EMS und Enelix Utils. | Anschluss | Aufgabe | Gegenstelle | | --- | --- | --- | -| SDL/VGT | Zeitlich gültige Leistungsaufträge und Rückmeldungen | Optional das unabhängige Utils-Modul VGT-Schnittstelle | -| Prognose / Forecast | Vorhersage für Produktion und bei Bedarf Verbrauch | Externer Anbieter; kein Repo-Modul | +| SDL/VGT | Zeitlich gueltige Leistungsauftraege und Rueckmeldungen | Optional das unabhaengige Utils-Modul VGT-Schnittstelle | +| Prognose / Forecast | Topologie, Telemetrie und Netzfahrplan | `license.enelix.ch/api/v1/installations/{id}/prognosis/*` | | Lizenzierung | Pruefung freigeschalteter Manager-Funktionen | `POST https://license.enelix.ch/api/v1/licenses/activate` | -| Störüberwachung | Weitergabe von Zuständen und Störungen | Externer Empfänger; kein Repo-Modul | +| Stoerueberwachung | Vollsnapshot aktiver Manager- und Geraetestoerungen | `PUT https://license.enelix.ch/api/v1/installations/{id}/faults` | -## Gemeinsame Grundsätze +## Stoerungsdatenfluss -- Die Auswahl der Gegenstelle erfolgt ausschliesslich im Manager. -- Anbieterformate werden an diesem Anschluss übersetzt und gelangen nicht in +1. Jedes Geraetemodul meldet seinen Zustand ueber den bestehenden + Verbraucher-Nachrichtenvertrag an den Manager. +2. Der Manager sammelt aktive Eintraege mit `Art = Stoerung`, fuegt eigene + Regelungsstoerungen hinzu und normalisiert sie. +3. Der Manager speichert lokal einen deterministisch sortierten Vollsnapshot. +4. Bei Zustandsaenderung oder spaetestens im konfigurierten Heartbeat-Intervall + sendet er den Snapshot mit dem vorhandenen Geraete-Bearer-Token. +5. Ein leerer Snapshot loest zuvor aktive Stoerungen im Portal auf. + +Jede Meldung besteht aus `sourceType`, `sourceId`, `sourceName`, `code`, +`severity` und `message`. Die stabile Kombination aus `sourceId` und +`code` identifiziert ein Ereignis ueber mehrere Snapshots hinweg. + +Fehler der externen Uebertragung blockieren die lokale EMS-Regelung nicht. Der +Manager wiederholt mit exponentiellem Abstand zwischen 30 und 900 Sekunden. +Nach HTTP 401 oder 403 verwirft er den Geraetezugang und fordert ihn bei der +naechsten Lizenzaktivierung neu an. + +## Lizenzvorbereitung + +Die Stoerueberwachung ist technisch als separat schaltbare Manager-Funktion +gekapselt. Das Portal kann spaeter eine Berechtigung mit dem Katalogschluessel +`fault_monitoring`, einer Laufzeit von einem Jahr und einem erneuerten +`validUntil` ausliefern. In der ersten Ausbaustufe wird diese zusaetzliche +Jahresberechtigung noch nicht erzwungen; Voraussetzung bleibt eine gueltige +Managerlizenz samt Geraetezugang. + +## Gemeinsame Grundsaetze + +- Anbieterformate werden am Manageranschluss uebersetzt und gelangen nicht in die Verbraucher-Schnittstelle. -- Jeder Anschluss meldet im Manager `NichtVerwendet`, `Verbunden` oder `Fehler`. -- Fehlende optionale Anschlüsse dürfen die lokale EMS-Grundfunktion nicht +- Jeder Anschluss meldet `NichtVerwendet`, `Wartet`, `Verbunden` oder + `Fehler`. +- Fehlende optionale Anschluesse duerfen die lokale EMS-Grundfunktion nicht blockieren. -- Eine fehlende oder abgelaufene Lizenz blockiert die Managerregelung. Eine - bestaetigte Entwicklungs-Lease bleibt bei Verbindungsunterbruch bis - `offlineUntil` gueltig. - +- Zugangstoken werden weder als Property noch in Diagnosevariablen oder Logs + ausgegeben. ## Noch festzulegen -- Anbieter und Datenformat für Prognose und Störüberwachung -- Aufbau, Gültigkeitszeitraum und Rückmeldung eines SDL-Auftrags -- Authentisierung der noch optionalen Anschlüsse -- Ablösung der Entwicklungs-Lease durch den signierten produktiven Lizenzvertrag +- Aufbau, Gueltigkeitszeitraum und Rueckmeldung eines SDL-Auftrags +- Produkt- und Zahlungsmodell der jaehrlichen Stoerueberwachungslizenz +- Verhalten nach Ablauf einer spaeter aktivierten Stoerueberwachungslizenz diff --git a/libs/StoerungsSnapshot.php b/libs/StoerungsSnapshot.php new file mode 100644 index 0000000..ca059fd --- /dev/null +++ b/libs/StoerungsSnapshot.php @@ -0,0 +1,131 @@ + 0, + 'error' => 1, + 'warning' => 2, + 'info' => 3, + ]; + + /** + * @param list> $meldungen + * @return array + */ + public static function erstellen( + string $installationID, + int $managerID, + string $rolle, + bool $managerAktiv, + array $meldungen, + ?int $zeitpunkt = null + ): array { + if (preg_match( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', + $installationID + ) !== 1) { + throw new InvalidArgumentException('Die Installations-ID muss eine UUIDv4 sein.'); + } + if ($managerID <= 0) { + throw new InvalidArgumentException('Die Manager-ID muss positiv sein.'); + } + if (!in_array($rolle, ['standalone', 'main', 'submanager'], true)) { + throw new InvalidArgumentException('Die Managerrolle ist ungueltig.'); + } + + $eindeutig = []; + foreach ($meldungen as $meldung) { + $normalisiert = self::normalisiereMeldung($meldung); + $schluessel = $normalisiert['sourceId'] . "\0" . $normalisiert['code']; + $eindeutig[$schluessel] = $normalisiert; + } + $meldungen = array_values($eindeutig); + usort($meldungen, static function (array $links, array $rechts): int { + $schweregrad = self::SCHWEREGRADE[$links['severity']] + <=> self::SCHWEREGRADE[$rechts['severity']]; + if ($schweregrad !== 0) { + return $schweregrad; + } + + return [$links['sourceName'], $links['code']] + <=> [$rechts['sourceName'], $rechts['code']]; + }); + // Das Portal akzeptiert hoechstens 100 Meldungen; die hoechste Prioritaet bleibt erhalten. + $meldungen = array_slice($meldungen, 0, 100); + + return [ + 'version' => self::VERSION, + 'installationId' => strtolower($installationID), + 'capturedAt' => gmdate('Y-m-d\TH:i:s\Z', $zeitpunkt ?? time()), + 'manager' => [ + 'instanceId' => $managerID, + 'role' => $rolle, + 'active' => $managerAktiv, + ], + 'faults' => $meldungen, + ]; + } + + /** @param array $snapshot */ + public static function zustandHash(array $snapshot): string + { + unset($snapshot['capturedAt']); + + return hash('sha256', json_encode( + $snapshot, + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES + )); + } + + /** + * @param array $meldung + * @return array + */ + private static function normalisiereMeldung(array $meldung): array + { + $felder = ['sourceType', 'sourceId', 'sourceName', 'code', 'severity', 'message']; + if (array_diff($felder, array_keys($meldung)) !== [] + || array_diff(array_keys($meldung), $felder) !== [] + ) { + throw new InvalidArgumentException('Eine Stoerungsmeldung hat ungueltige Felder.'); + } + + $normalisiert = []; + foreach ($felder as $feld) { + if (!is_string($meldung[$feld])) { + throw new InvalidArgumentException('Stoerungsfelder muessen Texte sein.'); + } + $normalisiert[$feld] = trim((string) preg_replace('/\s+/', ' ', $meldung[$feld])); + } + if ($normalisiert['sourceType'] === '' + || $normalisiert['sourceId'] === '' + || $normalisiert['sourceName'] === '' + || $normalisiert['code'] === '' + || $normalisiert['message'] === '' + ) { + throw new InvalidArgumentException('Stoerungsfelder duerfen nicht leer sein.'); + } + if (!array_key_exists($normalisiert['severity'], self::SCHWEREGRADE)) { + throw new InvalidArgumentException('Der Stoerungsschweregrad ist ungueltig.'); + } + if (strlen($normalisiert['sourceType']) > 40 + || strlen($normalisiert['sourceId']) > 120 + || strlen($normalisiert['sourceName']) > 160 + || strlen($normalisiert['code']) > 120 + || strlen($normalisiert['message']) > 500 + ) { + throw new InvalidArgumentException('Mindestens ein Stoerungsfeld ist zu lang.'); + } + + return $normalisiert; + } +} diff --git a/tests/ManagerModulstrukturTest.php b/tests/ManagerModulstrukturTest.php index e2e7cc0..b41e0ef 100644 --- a/tests/ManagerModulstrukturTest.php +++ b/tests/ManagerModulstrukturTest.php @@ -70,6 +70,8 @@ final class ManagerModulstrukturTest extends TestCase 'PrognoseHausverbrauchVariableID', 'PrognoseSOCVariableID', 'PrognoseSendeintervall', + 'StoerueberwachungAktiv', + 'StoerungsSendeintervall', ] as $property) { self::assertStringContainsString($property, $json); } @@ -201,7 +203,7 @@ final class ManagerModulstrukturTest extends TestCase self::assertStringContainsString("'issueDeviceToken' =>", $inhalt); self::assertStringContainsString("unset(\$neueLease['deviceToken'])", $inhalt); self::assertStringContainsString( - "\$geraetezugangFehlt = (\$this->hatAnlagentopologie() || \$this->prognoseTelemetrieKonfiguriert())", + "|| \$this->ReadPropertyBoolean('StoerueberwachungAktiv'))", $inhalt ); self::assertStringContainsString( @@ -237,6 +239,34 @@ final class ManagerModulstrukturTest extends TestCase ); } + public function testManagerUebertraegtStoerungsvollsnapshotsMitRetry(): void + { + $inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); + + self::assertFileExists(__DIR__ . '/../libs/StoerungsSnapshot.php'); + self::assertStringContainsString( + 'https://license.enelix.ch/api/v1/installations/%s/faults', + $inhalt + ); + foreach ([ + "RegisterPropertyBoolean('StoerueberwachungAktiv', false)", + "RegisterPropertyInteger('StoerungsSendeintervall', 300)", + "RegisterAttributeString('StoerungsSnapshot', '{}')", + "RegisterAttributeString('StoerungsGesendeterHash', '')", + "RegisterAttributeInteger('StoerungsNaechsterVersuch', 0)", + "'StoerungenSenden',", + 'StoerungsSnapshot::erstellen(', + 'StoerungsSnapshot::zustandHash(', + "CURLOPT_CUSTOMREQUEST => 'PUT'", + ] as $erwartet) { + self::assertStringContainsString($erwartet, $inhalt); + } + self::assertStringNotContainsString( + '"name":"StoermeldeAnschluss"', + str_replace(' ', '', (string) file_get_contents(__DIR__ . '/../Manager/form.json')) + ); + } + public function testManagerRegeltDieAnlagenweiteEinspeisegrenzeUndDenNetzfahrplan(): void { $inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); diff --git a/tests/StoerungsSnapshotTest.php b/tests/StoerungsSnapshotTest.php new file mode 100644 index 0000000..2976749 --- /dev/null +++ b/tests/StoerungsSnapshotTest.php @@ -0,0 +1,131 @@ + 'consumer', + 'sourceId' => 'ips:8', + 'sourceName' => ' Boiler ', + 'code' => 'Temp.High', + 'severity' => 'warning', + 'message' => " Temperatur hoch ", + ], + [ + 'sourceType' => 'manager', + 'sourceId' => 'ips:42', + 'sourceName' => 'Manager', + 'code' => 'Grid.Missing', + 'severity' => 'critical', + 'message' => 'Netzmessung fehlt', + ], + [ + 'sourceType' => 'consumer', + 'sourceId' => 'ips:8', + 'sourceName' => 'Boiler', + 'code' => 'Temp.High', + 'severity' => 'warning', + 'message' => 'Temperatur sehr hoch', + ], + ], + 1_700_000_000 + ); + + self::assertSame('1.0', $snapshot['version']); + self::assertSame('2023-11-14T22:13:20Z', $snapshot['capturedAt']); + self::assertSame( + ['Grid.Missing', 'Temp.High'], + array_column($snapshot['faults'], 'code') + ); + self::assertSame('Temperatur sehr hoch', $snapshot['faults'][1]['message']); + self::assertSame( + ['instanceId' => 42, 'role' => 'main', 'active' => true], + $snapshot['manager'] + ); + } + + public function testZustandHashIgnoriertDenErfassungszeitpunkt(): void + { + $a = StoerungsSnapshot::erstellen( + self::INSTALLATION_ID, + 42, + 'standalone', + false, + [], + 1_700_000_000 + ); + $b = StoerungsSnapshot::erstellen( + self::INSTALLATION_ID, + 42, + 'standalone', + false, + [], + 1_700_000_300 + ); + + self::assertSame(StoerungsSnapshot::zustandHash($a), StoerungsSnapshot::zustandHash($b)); + } + + public function testSnapshotBegrenztSichAufHundertPriorisierteMeldungen(): void + { + $meldungen = []; + for ($index = 1; $index <= 101; ++$index) { + $meldungen[] = [ + 'sourceType' => 'consumer', + 'sourceId' => 'ips:' . $index, + 'sourceName' => sprintf('Quelle %03d', $index), + 'code' => 'Fault.' . $index, + 'severity' => $index === 101 ? 'critical' : 'warning', + 'message' => 'Testmeldung ' . $index, + ]; + } + + $snapshot = StoerungsSnapshot::erstellen( + self::INSTALLATION_ID, + 42, + 'standalone', + true, + $meldungen + ); + + self::assertCount(100, $snapshot['faults']); + self::assertSame('Fault.101', $snapshot['faults'][0]['code']); + } + + public function testUngueltigerSchweregradWirdAbgelehnt(): void + { + $this->expectException(InvalidArgumentException::class); + + StoerungsSnapshot::erstellen( + self::INSTALLATION_ID, + 42, + 'standalone', + true, + [[ + 'sourceType' => 'consumer', + 'sourceId' => 'ips:8', + 'sourceName' => 'Boiler', + 'code' => 'Temp.High', + 'severity' => 'alarm', + 'message' => 'Temperatur hoch', + ]] + ); + } +}