diff --git a/Manager/module.php b/Manager/module.php index 073481b..8d9dc7e 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -21,6 +21,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle 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 PROGNOSE_TOPOLOGIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology'; private const LIZENZ_RETRY_SEKUNDEN = 3600; private const VM_UPDATE = 10603; private const VERBRAUCHER_LIZENZEN = [ @@ -119,6 +120,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0); $this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0); $this->RegisterAttributeString('LizenzLetzterFehler', ''); + $this->RegisterAttributeString('PrognoseInstallationsToken', ''); // Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren. $this->RegisterTimer('Regelzyklus', 0, ''); @@ -168,6 +170,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle return; } $this->SetStatus(self::STATUS_AKTIV); + $this->synchronisiereAnlagentopologie(); } public function GetConfigurationForm(): string @@ -1078,6 +1081,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle $installationID = $this->initialisiereLizenzInstallationID(); $codeHash = hash('sha256', $normalisiert); $gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash'); + if ($gespeicherterHash !== '' && !hash_equals($gespeicherterHash, $codeHash)) { + $this->WriteAttributeString('PrognoseInstallationsToken', ''); + } $lease = $this->leseLizenzLease(); $codePasst = $gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash); $lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus'); @@ -1093,7 +1099,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle ); $berechtigungenVeraltet = $basisGueltig && !$this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus); + $geraetezugangFehlt = $this->hatAnlagentopologie() + && $this->ReadAttributeString('PrognoseInstallationsToken') === ''; $routineFaellig = (!$gueltig && !$berechtigungenVeraltet) + || $geraetezugangFehlt || Lizenzpruefung::erneuerungFaellig($lease); $jetzt = time(); @@ -1135,11 +1144,22 @@ class Manager extends IPSModule implements ManagerSchnittstelle } try { $neueLease = $this->rufeLizenzserverAuf($normalisiert, $installationID); + $geraeteToken = $neueLease['deviceToken'] ?? null; + unset($neueLease['deviceToken']); + if ($geraetezugangFehlt + && (!is_string($geraeteToken) + || preg_match('/^[A-Za-z0-9_-]{43,128}$/', $geraeteToken) !== 1) + ) { + throw new RuntimeException('Der Lizenzserver lieferte keinen gueltigen Geraetezugang.'); + } Lizenzpruefung::pruefeLease( $neueLease, $installationID, $this->ReadPropertyInteger('Lastspitzenmodus') ); + if ($geraetezugangFehlt) { + $this->WriteAttributeString('PrognoseInstallationsToken', $geraeteToken); + } $this->WriteAttributeString( 'LizenzLease', json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) @@ -1334,6 +1354,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle $nutzlast = json_encode([ 'code' => $lizenzcode, 'installationId' => $installationID, + 'issueDeviceToken' => $this->hatAnlagentopologie() + && $this->ReadAttributeString('PrognoseInstallationsToken') === '', ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); $curl = curl_init(self::LIZENZ_ENDPOINT); if ($curl === false) { @@ -1448,6 +1470,103 @@ class Manager extends IPSModule implements ManagerSchnittstelle ); } + private function hatAnlagentopologie(): bool + { + $topologie = $this->leseAnlagentopologie(); + + return $topologie['inverters'] !== [] + || $topologie['pvArrays'] !== [] + || $topologie['batteries'] !== []; + } + + private function synchronisiereAnlagentopologie(): void + { + try { + $topologie = $this->leseAnlagentopologie(); + if ($topologie['inverters'] === [] + && $topologie['pvArrays'] === [] + && $topologie['batteries'] === [] + ) { + $this->setzeDiagnosewert('Prognosestatus', 'NichtVerwendet'); + return; + } + + $token = trim($this->ReadAttributeString('PrognoseInstallationsToken')); + if ($token === '') { + throw new RuntimeException('Der Geraetezugang fuer die Prognose fehlt.'); + } + $this->sendeAnlagentopologie($topologie, $token); + $this->setzeDiagnosewert('Prognosestatus', 'Verbunden'); + } catch (Throwable $fehler) { + $meldung = substr($fehler->getMessage(), 0, 500); + $this->setzeDiagnosewert('Prognosestatus', 'Fehler: ' . $meldung); + $this->protokolliere('Prognosesynchronisation', $meldung); + } + } + + /** @param array $topologie */ + private function sendeAnlagentopologie(array $topologie, string $token): void + { + $url = sprintf( + self::PROGNOSE_TOPOLOGIE_ENDPOINT, + rawurlencode($this->ReadAttributeString('LizenzInstallationID')) + ); + $nutzlast = json_encode( + $topologie, + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES + ); + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Die Prognosesynchronisation konnte nicht vorbereitet werden.'); + } + + try { + curl_setopt_array($curl, [ + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_POSTFIELDS => $nutzlast, + 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.2', + ]); + $antwort = curl_exec($curl); + if ($antwort === false) { + throw new RuntimeException( + 'Prognosedienst nicht erreichbar: ' . curl_error($curl) + ); + } + if (strlen($antwort) > 65536) { + throw new RuntimeException('Die Antwort der Prognosesynchronisation 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( + 'Prognosesynchronisation HTTP ' . $httpStatus . ': ' . $meldung + ); + } + } + /** @return array */ private function dekodiereMonatsgrenzen(string $json): array { diff --git a/docs/module/Manager/README.md b/docs/module/Manager/README.md index 6bdf664..8e026dd 100644 --- a/docs/module/Manager/README.md +++ b/docs/module/Manager/README.md @@ -79,8 +79,13 @@ beanspruchen. Der JSON-Export enthaelt die Vertragsversion `1.0`, die Lizenz-Installations-ID, die drei Komponentenlisten, gemeinsame AC-Grenzen und aggregierte Summen fuer die schrittweise Prognoseanbindung. Tarif- und Variantenparameter bleiben im -Prognoseportal. Der automatische Upload benoetigt spaeter einen separaten, -widerrufbaren Installationszugang; der Lizenzcode wird dafuer nicht verwendet. +Prognoseportal. Eine nichtleere Topologie wird beim Speichern automatisch ueber +einen separaten, widerrufbaren Installationszugang synchronisiert. Der +Lizenzserver liefert diesen Zugang nur auf ausdrueckliche Geraeteanforderung; +der Manager entfernt ihn vor dem Speichern aus der Lease und haelt ihn in einem +internen Attribut. Der Lizenzcode wird nicht als API-Token verwendet. Ein +Synchronisationsfehler erscheint im `Prognosestatus`, blockiert die lokale +EMS-Regelung aber nicht. Die Entscheidung und ihre Alternativen sind in [ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert. diff --git a/tests/ManagerModulstrukturTest.php b/tests/ManagerModulstrukturTest.php index 7b1e6ba..f649c2a 100644 --- a/tests/ManagerModulstrukturTest.php +++ b/tests/ManagerModulstrukturTest.php @@ -174,6 +174,40 @@ final class ManagerModulstrukturTest extends TestCase self::assertStringNotContainsString("'publicKey' =>", $inhalt); } + public function testManagerSynchronisiertDieTopologieMitSeparatemGeraetezugang(): void + { + $inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); + + self::assertStringContainsString( + 'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology', + $inhalt + ); + self::assertStringContainsString( + "RegisterAttributeString('PrognoseInstallationsToken', '')", + $inhalt + ); + self::assertStringContainsString("'issueDeviceToken' =>", $inhalt); + self::assertStringContainsString("unset(\$neueLease['deviceToken'])", $inhalt); + self::assertStringContainsString('synchronisiereAnlagentopologie()', $inhalt); + self::assertStringContainsString("'Authorization: Bearer ' . \$token", $inhalt); + self::assertStringNotContainsString( + "protokolliere('Prognosesynchronisation', \$token)", + $inhalt + ); + + $leasePruefung = strpos($inhalt, 'Lizenzpruefung::pruefeLease('); + $tokenSpeicherung = strpos( + $inhalt, + "WriteAttributeString('PrognoseInstallationsToken', \$geraeteToken)" + ); + self::assertNotFalse($leasePruefung); + self::assertNotFalse($tokenSpeicherung); + self::assertTrue( + $leasePruefung < $tokenSpeicherung, + 'Der Gerätezugang darf erst nach validierter Lizenzantwort gespeichert werden.' + ); + } + public function testManagerVerwendetDieNeuenZeitdefaults(): void { $inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');