This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user