diff --git a/Manager/form.json b/Manager/form.json index 12b302d..2a7a119 100644 --- a/Manager/form.json +++ b/Manager/form.json @@ -3,11 +3,29 @@ { "type": "ExpansionPanel", "caption": "Lizenzierung", + "expanded": true, "items": [ { "type": "ValidationTextBox", - "name": "LizenzAnschluss", - "caption": "Konfiguration als JSON" + "name": "Lizenzcode", + "caption": "Lizenzcode", + "placeholder": "ENX-XXXX-XXXX-XXXX-XXXX" + }, + { + "type": "Label", + "name": "LizenzInstallationID", + "caption": "Installations-ID wird erzeugt" + }, + { + "type": "Label", + "name": "LizenzInformation", + "caption": "Lizenzcode fehlt." + }, + { + "type": "Button", + "name": "LizenzPruefen", + "caption": "Lizenz pruefen und binden", + "onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);" } ] }, @@ -277,6 +295,7 @@ "status": [ {"code": 102, "icon": "active", "caption": "Aktiv"}, {"code": 201, "icon": "inactive", "caption": "Netzleistungsmessung fehlt oder ist veraltet"}, - {"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"} + {"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"}, + {"code": 203, "icon": "error", "caption": "Lizenz fehlt oder ist abgelaufen"} ] } diff --git a/Manager/module.php b/Manager/module.php index dffb9a9..0f4b685 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -5,7 +5,9 @@ declare(strict_types=1); require_once __DIR__ . '/../libs/ManagerSchnittstelle.php'; require_once __DIR__ . '/../libs/Nachrichtenvertrag.php'; require_once __DIR__ . '/../libs/ManagerRegler.php'; +require_once __DIR__ . '/../libs/Lizenzpruefung.php'; +use Belevo\EnelixEMS\Lizenzpruefung; use Belevo\EnelixEMS\ManagerRegler; use Belevo\EnelixEMS\ManagerSchnittstelle; use Belevo\EnelixEMS\Nachrichtenvertrag; @@ -15,6 +17,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle private const STATUS_AKTIV = 102; private const STATUS_MESSWERT_UNGUELTIG = 201; private const STATUS_KONFIGURATION_UNGUELTIG = 202; + private const STATUS_LIZENZ_UNGUELTIG = 203; + private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate'; + private const LIZENZ_RETRY_SEKUNDEN = 3600; private const VM_UPDATE = 10603; private const VERBRAUCHER_MODUL_IDS = [ '{B7C54AF4-AD7D-4FE4-B75D-203693906251}', @@ -61,6 +66,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterPropertyFloat('Umschaltdifferenz', 5.0); $this->RegisterPropertyString('PrognoseAnschluss', '{}'); $this->RegisterPropertyString('SDLAnschluss', '{}'); + $this->RegisterPropertyString('Lizenzcode', ''); + // Verdeckte Altproperty fuer bestehende Instanzkonfigurationen. $this->RegisterPropertyString('LizenzAnschluss', '{}'); $this->RegisterPropertyString('StoermeldeAnschluss', '{}'); $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); @@ -74,6 +81,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterAttributeString('VerbraucherCache', '{}'); $this->RegisterAttributeString('LetzteSollwerte', '{}'); $this->RegisterAttributeInteger('RegistrierteNetzleistungVariableID', 0); + $this->RegisterAttributeString('LizenzInstallationID', ''); + $this->RegisterAttributeString('LizenzLease', '{}'); + $this->RegisterAttributeString('LizenzcodeHash', ''); + $this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0); + $this->RegisterAttributeString('LizenzLetzterFehler', ''); + if ($this->ReadAttributeString('LizenzInstallationID') === '') { + $this->WriteAttributeString('LizenzInstallationID', $this->erzeugeUuidV4()); + } // Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren. $this->RegisterTimer('Regelzyklus', 0, ''); @@ -108,6 +123,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000); $this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen')); $this->aktualisiereAnschlussstatus(); + if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) { + $this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV); + $this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG); + return; + } $this->SetStatus(self::STATUS_AKTIV); } @@ -124,6 +144,18 @@ class Manager extends IPSModule implements ManagerSchnittstelle $monatsgrenzenSichtbar = $lastspitzenmodus === 2 && $this->GetBuffer('MonatsgrenzenSichtbar') === '1'; + $this->setzeFormularParameter( + $formular['elements'], + 'LizenzInstallationID', + 'caption', + 'Installations-ID: ' . $this->ReadAttributeString('LizenzInstallationID') + ); + $this->setzeFormularParameter( + $formular['elements'], + 'LizenzInformation', + 'caption', + $this->lizenzStatustext() + ); $this->setzeFormularParameter($formular['elements'], 'VerbraucherZuordnung', 'visible', !$automatisch); $this->setzeFormularParameter( $formular['elements'], @@ -199,6 +231,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->regeln((bool) $wert); return; + case 'FormLizenzPruefen': + if (!is_string($wert)) { + throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.'); + } + $this->aktualisiereLizenzfreigabe($wert, true); + $this->UpdateFormField('LizenzInformation', 'caption', $this->lizenzStatustext($wert)); + return; + case 'FormAutomatischeSuche': $automatisch = (bool) $wert; $this->UpdateFormField('VerbraucherZuordnung', 'visible', !$automatisch); @@ -298,6 +338,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle private function regeln(bool $erneuern): void { + if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) { + $this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV); + $this->setzeDiagnosewert('Verteilbudget', 0.0); + $this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG); + return; + } + [$netzleistung, $netzleistungGueltig] = $this->leseNetzleistung(); $this->SetValue('Netzleistung', $netzleistung); $this->setzeDiagnosewert('NetzleistungGueltig', $netzleistungGueltig); @@ -685,12 +732,207 @@ class Manager extends IPSModule implements ManagerSchnittstelle } } + private function aktualisiereLizenzfreigabe(string $lizenzcode, bool $erzwingen): bool + { + $normalisiert = Lizenzpruefung::normalisiereCode($lizenzcode); + if (!Lizenzpruefung::istLizenzcodeGueltig($normalisiert)) { + $fehler = $normalisiert === '' ? 'Lizenzcode fehlt.' : 'Lizenzcode ist ungueltig.'; + $this->WriteAttributeString('LizenzLetzterFehler', $fehler); + $this->setzeDiagnosewert('Lizenzstatus', $fehler); + + return false; + } + + $installationID = $this->ReadAttributeString('LizenzInstallationID'); + if ($installationID === '') { + $installationID = $this->erzeugeUuidV4(); + $this->WriteAttributeString('LizenzInstallationID', $installationID); + } + $codeHash = hash('sha256', $normalisiert); + $gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash'); + $lease = $this->leseLizenzLease(); + $codePasst = $gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash); + $gueltig = $codePasst && Lizenzpruefung::istLeaseGueltig( + $lease, + $installationID, + $this->ReadPropertyInteger('Lastspitzenmodus') + ); + $faellig = !$gueltig || Lizenzpruefung::erneuerungFaellig($lease); + $jetzt = time(); + + if (!$erzwingen + && (!$faellig || $jetzt < $this->ReadAttributeInteger('LizenzNaechsterVersuch')) + ) { + $this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode)); + + return $gueltig; + } + + $this->WriteAttributeInteger('LizenzNaechsterVersuch', $jetzt + self::LIZENZ_RETRY_SEKUNDEN); + try { + $neueLease = $this->rufeLizenzserverAuf($normalisiert, $installationID); + Lizenzpruefung::pruefeLease( + $neueLease, + $installationID, + $this->ReadPropertyInteger('Lastspitzenmodus') + ); + $this->WriteAttributeString( + 'LizenzLease', + json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) + ); + $this->WriteAttributeString('LizenzcodeHash', $codeHash); + $this->WriteAttributeInteger( + 'LizenzNaechsterVersuch', + Lizenzpruefung::erneuernAb($neueLease) + ); + $this->WriteAttributeString('LizenzLetzterFehler', ''); + $this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode)); + + return true; + } catch (Throwable $fehler) { + $meldung = substr($fehler->getMessage(), 0, 500); + $this->WriteAttributeString('LizenzLetzterFehler', $meldung); + $this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode)); + $this->protokolliere('Lizenzpruefung', $meldung); + + return $gueltig; + } + } + + /** @return array */ + 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 lizenzStatustext(?string $lizenzcode = null): string + { + $code = Lizenzpruefung::normalisiereCode( + $lizenzcode ?? $this->ReadPropertyString('Lizenzcode') + ); + if ($code === '') { + return 'Lizenzcode fehlt.'; + } + if (!Lizenzpruefung::istLizenzcodeGueltig($code)) { + return 'Lizenzcode ist ungueltig.'; + } + + $codeHash = hash('sha256', $code); + $gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash'); + $fehler = $this->ReadAttributeString('LizenzLetzterFehler'); + if ($gespeicherterHash === '' || !hash_equals($gespeicherterHash, $codeHash)) { + return $fehler !== '' ? 'Nicht freigegeben: ' . $fehler : 'Lizenz noch nicht bestaetigt.'; + } + + $lease = $this->leseLizenzLease(); + try { + Lizenzpruefung::pruefeLease( + $lease, + $this->ReadAttributeString('LizenzInstallationID'), + $this->ReadPropertyInteger('Lastspitzenmodus') + ); + $status = ($lease['development'] ?? false) === true + ? 'Aktiv (Entwicklung)' + : 'Aktiv'; + $status .= ' bis ' . date('d.m.Y H:i', Lizenzpruefung::offlineBis($lease)); + if ($fehler !== '') { + $status .= '; Erneuerung fehlgeschlagen: ' . $fehler; + } + + return $status; + } catch (Throwable $ungueltig) { + return 'Nicht freigegeben: ' . $ungueltig->getMessage(); + } + } + + /** @return array */ + private function rufeLizenzserverAuf(string $lizenzcode, string $installationID): array + { + $nutzlast = json_encode([ + 'code' => $lizenzcode, + 'installationId' => $installationID, + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $curl = curl_init(self::LIZENZ_ENDPOINT); + if ($curl === false) { + throw new RuntimeException('Die HTTPS-Anfrage konnte nicht vorbereitet werden.'); + } + + 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-EMS-Manager/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); + } + + try { + $daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR); + } catch (Throwable $fehler) { + throw new RuntimeException('Der Lizenzserver lieferte kein gueltiges JSON.', 0, $fehler); + } + if (!is_array($daten)) { + throw new RuntimeException('Der Lizenzserver lieferte kein JSON-Objekt.'); + } + if ($httpStatus !== 200) { + $meldung = is_string($daten['error'] ?? null) + ? $daten['error'] + : 'Unbekannter Fehler'; + throw new RuntimeException('Lizenzserver HTTP ' . $httpStatus . ': ' . $meldung); + } + + return $daten; + } + + 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 aktualisiereAnschlussstatus(): void { foreach ([ 'PrognoseAnschluss' => 'Prognosestatus', 'SDLAnschluss' => 'SDLStatus', - 'LizenzAnschluss' => 'Lizenzstatus', 'StoermeldeAnschluss' => 'Stoerueberwachungsstatus', ] as $property => $variable) { $konfiguration = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); @@ -714,7 +956,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->leseAktiveVerbraucherIDs('VerbraucherZuordnung'); $this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung'); $this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')); - foreach (['PrognoseAnschluss', 'SDLAnschluss', 'LizenzAnschluss', 'StoermeldeAnschluss'] as $property) { + foreach (['PrognoseAnschluss', 'SDLAnschluss', 'StoermeldeAnschluss'] 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 1cf0e17..80072d6 100644 --- a/docs/Obere-Anschluesse.md +++ b/docs/Obere-Anschluesse.md @@ -1,16 +1,17 @@ # Obere Anschlüsse des Managers -> Status: Diskussionsentwurf. Anbieterformate und konkrete Gegenstellen sind -> noch nicht festgelegt. +> Status: Lizenzierung implementiert; die übrigen Anbieterformate sind noch +> nicht festgelegt. -Diese Anschlüsse sind optional. Sie werden im Manager konfiguriert und erzeugen +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. | 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 | -| Lizenzierung | Prüfung freigeschalteter Funktionen | Externer Lizenzdienst; kein Repo-Modul | +| 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 | ## Gemeinsame Grundsätze @@ -21,10 +22,13 @@ keine Abhängigkeit zwischen Enelix EMS und Enelix Utils. - Jeder Anschluss meldet im Manager `NichtVerwendet`, `Verbunden` oder `Fehler`. - Fehlende optionale Anschlüsse dürfen die lokale EMS-Grundfunktion nicht blockieren. +- Eine fehlende oder abgelaufene Lizenz blockiert die Managerregelung. Eine + bestaetigte Entwicklungs-Lease bleibt bei Verbindungsunterbruch bis + `offlineUntil` gueltig. ## Noch festzulegen -- Anbieter und Datenformat für Prognose, Lizenzierung und Störüberwachung +- Anbieter und Datenformat für Prognose und Störüberwachung - Aufbau, Gültigkeitszeitraum und Rückmeldung eines SDL-Auftrags -- Authentisierung und Verhalten bei Verbindungsunterbruch -- Welche Lizenzfehler nur Funktionen sperren und welche den Manager stoppen +- Authentisierung der noch optionalen Anschlüsse +- Ablösung der Entwicklungs-Lease durch den signierten produktiven Lizenzvertrag diff --git a/docs/Offene-Punkte.md b/docs/Offene-Punkte.md index c1a1cb4..c871cfa 100644 --- a/docs/Offene-Punkte.md +++ b/docs/Offene-Punkte.md @@ -46,16 +46,17 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis. | ID | Status | Bereich | Kurzthema | | --- | --- | --- | --- | -| OP-001 | Offen | Lizenzierung | Betriebsort des Lizenzservers | +| OP-001 | Entschieden | Lizenzierung | Betriebsort des Lizenzservers | -## Offene Entscheidungen +## Entschiedene Punkte ### OP-001: Betriebsort des Lizenzservers -- **Entscheidungsfrage:** Wo soll der Enelix-Lizenzserver betrieben werden? -- **Verantwortlich:** Offen -- **Ziel-Sprint:** Offen -- **Ergebnis/Verweis:** Offen +- **Entscheidung:** Der Entwicklungs-Lizenzdienst wird unter + `https://license.enelix.ch` betrieben. +- **Manager-Endpunkt:** `POST /api/v1/licenses/activate` +- **Ergebnis/Verweis:** [Obere Anschluesse](Obere-Anschluesse.md) und + [Manager](module/Manager/README.md) ## Vorlage fuer neue Punkte diff --git a/docs/module/Manager/README.md b/docs/module/Manager/README.md index e02849e..73b6dfa 100644 --- a/docs/module/Manager/README.md +++ b/docs/module/Manager/README.md @@ -49,7 +49,7 @@ Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netz | `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. | | `PrognoseAnschluss` | String/JSON | Optionaler Forecast-Anschluss. | | `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. | -| `LizenzAnschluss` | String/JSON | Optionaler Lizenzanschluss. | +| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. | | `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. | | `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. | | `LoggingEin` | Boolean | `false`; laufende Meldungen im Debug-Fenster ausgeben. | @@ -59,8 +59,12 @@ Manager. Im Modus `Aus` bleibt Solarladen aktiv, nur die Peak-Begrenzung entfäl Die Monatsgrenzen werden in den Grundeinstellungen über eine Schaltfläche ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren. -Die vier optionalen oberen Anschlüsse besitzen jeweils einen eigenen Bereich; -die Lizenzierung steht an erster Stelle des Formulars. +Die Lizenzierung steht an erster Stelle des Formulars. Der Manager erzeugt dabei +einmalig eine UUIDv4 als stabile Installations-ID. Mit "Lizenz pruefen und binden" +wird der eingegebene Code ueber +`POST https://license.enelix.ch/api/v1/licenses/activate` an diese Installation +gebunden. Es werden weder Portal-Cookies noch ein CSRF-Token oder ein noch nicht +implementierter Lizenzendpunkt verwendet. ## Verteilalgorithmus @@ -94,6 +98,14 @@ wenn sich die beiden berechneten Korrekturen nicht ausreichend unterscheiden. als Sammelstörung ausgewiesen. - Bei fehlender oder veralteter Netzleistung bleibt die Regelung `Inaktiv` und sendet keine neuen Vorgaben. +- Ohne gueltige Manager-Berechtigung bleibt der Manager mit Status `203` gesperrt. +- Die Serverantwort wird lokal gespeichert und ab `refreshAfter` erneuert. Bei + einem Verbindungsfehler gilt eine zuvor bestaetigte Entwicklungsfreigabe bis + `offlineUntil`; danach wird die Regelung wieder gesperrt. +- `manager_standard` erlaubt die Grundregelung. Ein aktiver Lastspitzenmodus + benoetigt `manager_peak`. +- Der Lizenzcode wird nie geloggt. Lokal wird fuer den Lease-Abgleich nur sein + SHA-256-Wert gespeichert. - Die oberen Anschlüsse bleiben optional. Solange kein konkreter Adapter implementiert ist, meldet ein aktivierter Anschluss den Status `Fehler`, ohne die lokale EMS-Regelung zu blockieren. @@ -107,5 +119,6 @@ wenn sich die beiden berechneten Korrekturen nicht ausreichend unterscheiden. ## Offene Punkte - Monatliche Batteriereserve aus dem alten Peakshaving-Modul übernehmen? -- Anbieterformate für Prognose, Lizenzierung und Störüberwachung festlegen. +- Anbieterformate für Prognose und Störüberwachung festlegen. +- Produktive signierte Lizenz-Leases nach Abschluss des Entwicklungsvertrags integrieren. - Verhalten und Messabgrenzung bei Untermanagern im Anlagentest bestätigen. diff --git a/libs/Lizenzpruefung.php b/libs/Lizenzpruefung.php new file mode 100644 index 0000000..5098886 --- /dev/null +++ b/libs/Lizenzpruefung.php @@ -0,0 +1,147 @@ + $lease + */ + public static function pruefeLease( + array $lease, + string $installationID, + int $lastspitzenmodus, + ?int $zeitpunkt = null + ): void { + if (($lease['status'] ?? null) !== 'active') { + throw new InvalidArgumentException('Die Lizenz ist nicht aktiv.'); + } + if (($lease['installationId'] ?? null) !== $installationID) { + throw new InvalidArgumentException('Die Lizenz gehoert zu einer anderen Installation.'); + } + if (isset($lease['development']) && !is_bool($lease['development'])) { + throw new InvalidArgumentException('Der Entwicklungsstatus der Lizenz ist ungueltig.'); + } + + $ausgestelltAm = self::zeitstempel($lease, 'issuedAt'); + $erneuernAb = self::zeitstempel($lease, 'refreshAfter'); + $offlineBis = self::zeitstempel($lease, 'offlineUntil'); + if ($ausgestelltAm > $erneuernAb || $erneuernAb > $offlineBis) { + throw new InvalidArgumentException('Die Lizenzzeitpunkte sind widerspruechlich.'); + } + if (($zeitpunkt ?? time()) >= $offlineBis) { + throw new InvalidArgumentException('Die Offline-Freigabe ist abgelaufen.'); + } + + $berechtigungen = $lease['entitlements'] ?? null; + if (!is_array($berechtigungen)) { + throw new InvalidArgumentException('Die Lizenz enthaelt keine Berechtigungen.'); + } + $standard = false; + $peak = false; + foreach ($berechtigungen as $berechtigung) { + if (!is_array($berechtigung) + || ($berechtigung['status'] ?? null) !== 'active' + || !is_int($berechtigung['quantity'] ?? null) + || $berechtigung['quantity'] < 1 + ) { + continue; + } + $kennung = $berechtigung['catalog_key'] ?? $berechtigung['sku'] ?? null; + $standard = $standard || $kennung === self::MANAGER_STANDARD; + $peak = $peak || $kennung === self::MANAGER_PEAK; + } + + if (!$standard && !$peak) { + throw new InvalidArgumentException('Die Manager-Berechtigung fehlt.'); + } + if ($lastspitzenmodus !== 0 && !$peak) { + throw new InvalidArgumentException('Peak Shaving ist nicht lizenziert.'); + } + } + + /** + * @param array $lease + */ + public static function istLeaseGueltig( + array $lease, + string $installationID, + int $lastspitzenmodus, + ?int $zeitpunkt = null + ): bool { + try { + self::pruefeLease($lease, $installationID, $lastspitzenmodus, $zeitpunkt); + + return true; + } catch (Throwable $fehler) { + return false; + } + } + + /** + * @param array $lease + */ + public static function erneuerungFaellig(array $lease, ?int $zeitpunkt = null): bool + { + try { + return ($zeitpunkt ?? time()) >= self::zeitstempel($lease, 'refreshAfter'); + } catch (Throwable $fehler) { + return true; + } + } + + /** + * @param array $lease + */ + public static function offlineBis(array $lease): int + { + return self::zeitstempel($lease, 'offlineUntil'); + } + + /** + * @param array $lease + */ + public static function erneuernAb(array $lease): int + { + return self::zeitstempel($lease, 'refreshAfter'); + } + + /** + * @param array $lease + */ + private static function zeitstempel(array $lease, string $feld): int + { + $wert = $lease[$feld] ?? null; + if (!is_string($wert) || trim($wert) === '') { + throw new InvalidArgumentException($feld . ' fehlt in der Lizenz.'); + } + + try { + return (new DateTimeImmutable($wert))->getTimestamp(); + } catch (Throwable $fehler) { + throw new InvalidArgumentException($feld . ' ist kein gueltiger ISO-8601-Zeitpunkt.', 0, $fehler); + } + } +} diff --git a/tests/LizenzpruefungTest.php b/tests/LizenzpruefungTest.php new file mode 100644 index 0000000..56857d6 --- /dev/null +++ b/tests/LizenzpruefungTest.php @@ -0,0 +1,143 @@ +lease('manager_standard'), + self::INSTALLATION_ID, + 0, + $this->zeit('2026-09-19T12:00:00Z') + ); + + self::assertTrue(true); + } + + public function testPeakLizenzGibtLastspitzenmodusFrei(): void + { + Lizenzpruefung::pruefeLease( + $this->lease('manager_peak'), + self::INSTALLATION_ID, + 2, + $this->zeit('2026-09-19T12:00:00Z') + ); + + self::assertTrue(true); + } + + public function testStandardLizenzSperrtLastspitzenmodus(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Peak Shaving ist nicht lizenziert.'); + + Lizenzpruefung::pruefeLease( + $this->lease('manager_standard'), + self::INSTALLATION_ID, + 1, + $this->zeit('2026-09-19T12:00:00Z') + ); + } + + public function testAbgelaufeneOfflineFreigabeWirdAbgelehnt(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Offline-Freigabe ist abgelaufen.'); + + Lizenzpruefung::pruefeLease( + $this->lease('manager_peak'), + self::INSTALLATION_ID, + 0, + $this->zeit('2026-10-03T12:00:00Z') + ); + } + + public function testAndereInstallationWirdAbgelehnt(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('anderen Installation'); + + Lizenzpruefung::pruefeLease( + $this->lease('manager_standard'), + '223e4567-e89b-42d3-a456-426614174000', + 0, + $this->zeit('2026-09-19T12:00:00Z') + ); + } + + public function testErneuerungWirdAbRefreshAfterFaellig(): void + { + $lease = $this->lease('manager_standard'); + + self::assertFalse(Lizenzpruefung::erneuerungFaellig( + $lease, + $this->zeit('2026-09-20T11:59:59Z') + )); + self::assertTrue(Lizenzpruefung::erneuerungFaellig( + $lease, + $this->zeit('2026-09-20T12:00:00Z') + )); + } + + public function testLeaseOhneManagerBerechtigungWirdAbgelehnt(): void + { + $lease = $this->lease('consumer_single'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Manager-Berechtigung fehlt.'); + Lizenzpruefung::pruefeLease( + $lease, + self::INSTALLATION_ID, + 0, + $this->zeit('2026-09-19T12:00:00Z') + ); + } + + public function testLizenzcodeWirdWieAufDemServerNormalisiert(): void + { + self::assertSame( + 'ENX23456789ABCDEFGH', + Lizenzpruefung::normalisiereCode('enx-2345-6789-abcd-efgh') + ); + self::assertTrue(Lizenzpruefung::istLizenzcodeGueltig('ENX-2345-6789-ABCD-EFGH')); + self::assertFalse(Lizenzpruefung::istLizenzcodeGueltig('ENX-OOOO-OOOO-OOOO-OOOO')); + } + + /** + * @return array + */ + private function lease(string $sku): array + { + return [ + 'status' => 'active', + 'development' => true, + 'installationId' => self::INSTALLATION_ID, + 'orderId' => '323e4567-e89b-42d3-a456-426614174000', + 'entitlements' => [[ + 'sku' => $sku, + 'catalog_key' => $sku, + 'quantity' => 1, + 'status' => 'active', + ]], + 'issuedAt' => '2026-09-19T12:00:00.000Z', + 'refreshAfter' => '2026-09-20T12:00:00.000Z', + 'offlineUntil' => '2026-10-03T12:00:00.000Z', + ]; + } + + private function zeit(string $wert): int + { + return (new DateTimeImmutable($wert))->getTimestamp(); + } +} diff --git a/tests/ManagerModulstrukturTest.php b/tests/ManagerModulstrukturTest.php index 2cdb8f3..f323241 100644 --- a/tests/ManagerModulstrukturTest.php +++ b/tests/ManagerModulstrukturTest.php @@ -50,12 +50,16 @@ final class ManagerModulstrukturTest extends TestCase 'Umschaltdifferenz', 'DiagnosevariablenAnzeigen', 'LoggingEin', + 'Lizenzcode', + 'LizenzPruefen', + 'LizenzInstallationID', + 'LizenzInformation', 'VerbraucherAktualisieren', ] as $property) { self::assertStringContainsString($property, $json); } foreach (['Regelintervall', 'Vorgabeintervall', 'UmschaltschwelleUeberschuss', - 'UmschaltschwelleLastspitze', 'Optionale obere Anschluesse'] as $entfernt + 'UmschaltschwelleLastspitze', 'Optionale obere Anschluesse', 'LizenzAnschluss'] as $entfernt ) { self::assertStringNotContainsString($entfernt, $json); } @@ -89,6 +93,27 @@ final class ManagerModulstrukturTest extends TestCase self::assertStringContainsString('SendDebug(', $inhalt); } + public function testManagerBindetDieLizenzierungFailClosedAn(): void + { + $inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); + + self::assertFileExists(__DIR__ . '/../libs/Lizenzpruefung.php'); + self::assertStringContainsString( + "RegisterPropertyString('Lizenzcode', '')", + $inhalt + ); + self::assertStringContainsString( + "https://license.enelix.ch/api/v1/licenses/activate", + $inhalt + ); + self::assertStringContainsString("RegisterAttributeString('LizenzInstallationID'", $inhalt); + self::assertStringContainsString("RegisterAttributeString('LizenzLease'", $inhalt); + self::assertStringContainsString("RegisterAttributeString('LizenzcodeHash'", $inhalt); + self::assertStringContainsString('STATUS_LIZENZ_UNGUELTIG', $inhalt); + self::assertStringContainsString('aktualisiereLizenzfreigabe(', $inhalt); + self::assertStringNotContainsString("'publicKey' =>", $inhalt); + } + public function testManagerVerwendetDieNeuenZeitdefaults(): void { $inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); diff --git a/tests/Symcon/manifest.php b/tests/Symcon/manifest.php index 5b7a6a6..1e9a16d 100644 --- a/tests/Symcon/manifest.php +++ b/tests/Symcon/manifest.php @@ -7,6 +7,7 @@ return [ 'file' => __DIR__ . '/modules/Manager.php', 'paths' => [ 'Manager/*', + 'libs/Lizenzpruefung.php', 'libs/ManagerRegler.php', 'libs/ManagerSchnittstelle.php', 'libs/Nachrichtenvertrag.php', diff --git a/tests/Symcon/modules/Manager.php b/tests/Symcon/modules/Manager.php index 6f9f8bd..1ba0ef8 100644 --- a/tests/Symcon/modules/Manager.php +++ b/tests/Symcon/modules/Manager.php @@ -48,11 +48,13 @@ return static function (TestContext $test): void { ['InstanzID' => $einStufigId, 'Aktiv' => true], ['InstanzID' => $warmwasserId, 'Aktiv' => true], ]; + $lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE')); $managerId = $test->createInstance( '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}', 'Manager', [ 'NetzleistungVariableID' => $netzleistungId, + 'Lizenzcode' => $lizenzcode, 'AutomatischeSuche' => false, 'VerbraucherZuordnung' => json_encode($zuordnung, JSON_THROW_ON_ERROR), 'KeepAlive' => 30, @@ -130,6 +132,22 @@ return static function (TestContext $test): void { return null; }; + if ($lizenzcode === '') { + $test->runCase('Manager bleibt ohne Lizenz sicher gesperrt', static function ( + TestContext $test + ) use ($managerId): void { + IPS_RequestAction($managerId, 'Aktiv', true); + IPS_RequestAction($managerId, 'Regeln', true); + $test->assertInstanceStatus($managerId, 203); + $test->assertSame( + 'Inaktiv', + GetValue($test->objectByIdent('Betriebsart', $managerId)) + ); + }); + + return; + } + $test->runCase('Manager und alle vorhandenen Verbrauchertypen sind verknüpft', static function ( TestContext $test ) use ($managerId, $einStufigId, $warmwasserId, $melden): void { diff --git a/tests/SymconTestContractTest.php b/tests/SymconTestContractTest.php index be8ffaa..2f69c51 100644 --- a/tests/SymconTestContractTest.php +++ b/tests/SymconTestContractTest.php @@ -63,6 +63,14 @@ final class SymconTestContractTest extends TestCase ); } + public function testAffectedModuleResolverSelectsManagerForLicenseChecks(): void + { + self::assertSame( + ['Manager'], + $this->resolveAffectedModules(['libs/Lizenzpruefung.php']) + ); + } + public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void { self::assertSame(