This commit is contained in:
@@ -21,6 +21,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
private const STATUS_KONFIGURATION_UNGUELTIG = 202;
|
private const STATUS_KONFIGURATION_UNGUELTIG = 202;
|
||||||
private const STATUS_LIZENZ_UNGUELTIG = 203;
|
private const STATUS_LIZENZ_UNGUELTIG = 203;
|
||||||
private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate';
|
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 LIZENZ_RETRY_SEKUNDEN = 3600;
|
||||||
private const VM_UPDATE = 10603;
|
private const VM_UPDATE = 10603;
|
||||||
private const VERBRAUCHER_LIZENZEN = [
|
private const VERBRAUCHER_LIZENZEN = [
|
||||||
@@ -119,6 +120,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
$this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0);
|
$this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0);
|
||||||
$this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0);
|
$this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0);
|
||||||
$this->RegisterAttributeString('LizenzLetzterFehler', '');
|
$this->RegisterAttributeString('LizenzLetzterFehler', '');
|
||||||
|
$this->RegisterAttributeString('PrognoseInstallationsToken', '');
|
||||||
|
|
||||||
// Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren.
|
// Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren.
|
||||||
$this->RegisterTimer('Regelzyklus', 0, '');
|
$this->RegisterTimer('Regelzyklus', 0, '');
|
||||||
@@ -168,6 +170,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$this->SetStatus(self::STATUS_AKTIV);
|
$this->SetStatus(self::STATUS_AKTIV);
|
||||||
|
$this->synchronisiereAnlagentopologie();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GetConfigurationForm(): string
|
public function GetConfigurationForm(): string
|
||||||
@@ -1078,6 +1081,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
$installationID = $this->initialisiereLizenzInstallationID();
|
$installationID = $this->initialisiereLizenzInstallationID();
|
||||||
$codeHash = hash('sha256', $normalisiert);
|
$codeHash = hash('sha256', $normalisiert);
|
||||||
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
|
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
|
||||||
|
if ($gespeicherterHash !== '' && !hash_equals($gespeicherterHash, $codeHash)) {
|
||||||
|
$this->WriteAttributeString('PrognoseInstallationsToken', '');
|
||||||
|
}
|
||||||
$lease = $this->leseLizenzLease();
|
$lease = $this->leseLizenzLease();
|
||||||
$codePasst = $gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash);
|
$codePasst = $gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash);
|
||||||
$lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus');
|
$lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus');
|
||||||
@@ -1093,7 +1099,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
);
|
);
|
||||||
$berechtigungenVeraltet = $basisGueltig
|
$berechtigungenVeraltet = $basisGueltig
|
||||||
&& !$this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus);
|
&& !$this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus);
|
||||||
|
$geraetezugangFehlt = $this->hatAnlagentopologie()
|
||||||
|
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '';
|
||||||
$routineFaellig = (!$gueltig && !$berechtigungenVeraltet)
|
$routineFaellig = (!$gueltig && !$berechtigungenVeraltet)
|
||||||
|
|| $geraetezugangFehlt
|
||||||
|| Lizenzpruefung::erneuerungFaellig($lease);
|
|| Lizenzpruefung::erneuerungFaellig($lease);
|
||||||
$jetzt = time();
|
$jetzt = time();
|
||||||
|
|
||||||
@@ -1135,11 +1144,22 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$neueLease = $this->rufeLizenzserverAuf($normalisiert, $installationID);
|
$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(
|
Lizenzpruefung::pruefeLease(
|
||||||
$neueLease,
|
$neueLease,
|
||||||
$installationID,
|
$installationID,
|
||||||
$this->ReadPropertyInteger('Lastspitzenmodus')
|
$this->ReadPropertyInteger('Lastspitzenmodus')
|
||||||
);
|
);
|
||||||
|
if ($geraetezugangFehlt) {
|
||||||
|
$this->WriteAttributeString('PrognoseInstallationsToken', $geraeteToken);
|
||||||
|
}
|
||||||
$this->WriteAttributeString(
|
$this->WriteAttributeString(
|
||||||
'LizenzLease',
|
'LizenzLease',
|
||||||
json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
|
json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
|
||||||
@@ -1334,6 +1354,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
$nutzlast = json_encode([
|
$nutzlast = json_encode([
|
||||||
'code' => $lizenzcode,
|
'code' => $lizenzcode,
|
||||||
'installationId' => $installationID,
|
'installationId' => $installationID,
|
||||||
|
'issueDeviceToken' => $this->hatAnlagentopologie()
|
||||||
|
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '',
|
||||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
||||||
$curl = curl_init(self::LIZENZ_ENDPOINT);
|
$curl = curl_init(self::LIZENZ_ENDPOINT);
|
||||||
if ($curl === false) {
|
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> */
|
/** @return array<int, float> */
|
||||||
private function dekodiereMonatsgrenzen(string $json): array
|
private function dekodiereMonatsgrenzen(string $json): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -79,8 +79,13 @@ beanspruchen.
|
|||||||
Der JSON-Export enthaelt die Vertragsversion `1.0`, die Lizenz-Installations-ID,
|
Der JSON-Export enthaelt die Vertragsversion `1.0`, die Lizenz-Installations-ID,
|
||||||
die drei Komponentenlisten, gemeinsame AC-Grenzen und aggregierte Summen fuer
|
die drei Komponentenlisten, gemeinsame AC-Grenzen und aggregierte Summen fuer
|
||||||
die schrittweise Prognoseanbindung. Tarif- und Variantenparameter bleiben im
|
die schrittweise Prognoseanbindung. Tarif- und Variantenparameter bleiben im
|
||||||
Prognoseportal. Der automatische Upload benoetigt spaeter einen separaten,
|
Prognoseportal. Eine nichtleere Topologie wird beim Speichern automatisch ueber
|
||||||
widerrufbaren Installationszugang; der Lizenzcode wird dafuer nicht verwendet.
|
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
|
Die Entscheidung und ihre Alternativen sind in
|
||||||
[ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert.
|
[ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert.
|
||||||
|
|||||||
@@ -174,6 +174,40 @@ final class ManagerModulstrukturTest extends TestCase
|
|||||||
self::assertStringNotContainsString("'publicKey' =>", $inhalt);
|
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
|
public function testManagerVerwendetDieNeuenZeitdefaults(): void
|
||||||
{
|
{
|
||||||
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||||
|
|||||||
Reference in New Issue
Block a user