feat: Anlagentopologie mit Prognoseportal synchronisieren
Tests / test (push) Successful in 45s

This commit is contained in:
dh
2026-09-23 04:36:59 +00:00
parent dbd728dd03
commit 1e6481330a
3 changed files with 160 additions and 2 deletions
+119
View File
@@ -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<string, mixed> $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<int, float> */
private function dekodiereMonatsgrenzen(string $json): array
{
+7 -2
View File
@@ -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.
+34
View File
@@ -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');