Manager-Lizenzierung integrieren
Tests / test (push) Successful in 1m7s

This commit is contained in:
dh
2026-09-20 14:21:11 +00:00
parent cf1ae5e348
commit 7b6d1b2f07
11 changed files with 644 additions and 23 deletions
+22 -3
View File
@@ -3,11 +3,29 @@
{ {
"type": "ExpansionPanel", "type": "ExpansionPanel",
"caption": "Lizenzierung", "caption": "Lizenzierung",
"expanded": true,
"items": [ "items": [
{ {
"type": "ValidationTextBox", "type": "ValidationTextBox",
"name": "LizenzAnschluss", "name": "Lizenzcode",
"caption": "Konfiguration als JSON" "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": [ "status": [
{"code": 102, "icon": "active", "caption": "Aktiv"}, {"code": 102, "icon": "active", "caption": "Aktiv"},
{"code": 201, "icon": "inactive", "caption": "Netzleistungsmessung fehlt oder ist veraltet"}, {"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"}
] ]
} }
+244 -2
View File
@@ -5,7 +5,9 @@ declare(strict_types=1);
require_once __DIR__ . '/../libs/ManagerSchnittstelle.php'; require_once __DIR__ . '/../libs/ManagerSchnittstelle.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php'; require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/ManagerRegler.php'; require_once __DIR__ . '/../libs/ManagerRegler.php';
require_once __DIR__ . '/../libs/Lizenzpruefung.php';
use Belevo\EnelixEMS\Lizenzpruefung;
use Belevo\EnelixEMS\ManagerRegler; use Belevo\EnelixEMS\ManagerRegler;
use Belevo\EnelixEMS\ManagerSchnittstelle; use Belevo\EnelixEMS\ManagerSchnittstelle;
use Belevo\EnelixEMS\Nachrichtenvertrag; use Belevo\EnelixEMS\Nachrichtenvertrag;
@@ -15,6 +17,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
private const STATUS_AKTIV = 102; private const STATUS_AKTIV = 102;
private const STATUS_MESSWERT_UNGUELTIG = 201; private const STATUS_MESSWERT_UNGUELTIG = 201;
private const STATUS_KONFIGURATION_UNGUELTIG = 202; 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 VM_UPDATE = 10603;
private const VERBRAUCHER_MODUL_IDS = [ private const VERBRAUCHER_MODUL_IDS = [
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}', '{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
@@ -61,6 +66,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterPropertyFloat('Umschaltdifferenz', 5.0); $this->RegisterPropertyFloat('Umschaltdifferenz', 5.0);
$this->RegisterPropertyString('PrognoseAnschluss', '{}'); $this->RegisterPropertyString('PrognoseAnschluss', '{}');
$this->RegisterPropertyString('SDLAnschluss', '{}'); $this->RegisterPropertyString('SDLAnschluss', '{}');
$this->RegisterPropertyString('Lizenzcode', '');
// Verdeckte Altproperty fuer bestehende Instanzkonfigurationen.
$this->RegisterPropertyString('LizenzAnschluss', '{}'); $this->RegisterPropertyString('LizenzAnschluss', '{}');
$this->RegisterPropertyString('StoermeldeAnschluss', '{}'); $this->RegisterPropertyString('StoermeldeAnschluss', '{}');
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
@@ -74,6 +81,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterAttributeString('VerbraucherCache', '{}'); $this->RegisterAttributeString('VerbraucherCache', '{}');
$this->RegisterAttributeString('LetzteSollwerte', '{}'); $this->RegisterAttributeString('LetzteSollwerte', '{}');
$this->RegisterAttributeInteger('RegistrierteNetzleistungVariableID', 0); $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. // Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren.
$this->RegisterTimer('Regelzyklus', 0, ''); $this->RegisterTimer('Regelzyklus', 0, '');
@@ -108,6 +123,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000); $this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000);
$this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen')); $this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen'));
$this->aktualisiereAnschlussstatus(); $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); $this->SetStatus(self::STATUS_AKTIV);
} }
@@ -124,6 +144,18 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$monatsgrenzenSichtbar = $lastspitzenmodus === 2 $monatsgrenzenSichtbar = $lastspitzenmodus === 2
&& $this->GetBuffer('MonatsgrenzenSichtbar') === '1'; && $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'], 'VerbraucherZuordnung', 'visible', !$automatisch);
$this->setzeFormularParameter( $this->setzeFormularParameter(
$formular['elements'], $formular['elements'],
@@ -199,6 +231,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->regeln((bool) $wert); $this->regeln((bool) $wert);
return; 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': case 'FormAutomatischeSuche':
$automatisch = (bool) $wert; $automatisch = (bool) $wert;
$this->UpdateFormField('VerbraucherZuordnung', 'visible', !$automatisch); $this->UpdateFormField('VerbraucherZuordnung', 'visible', !$automatisch);
@@ -298,6 +338,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle
private function regeln(bool $erneuern): void 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(); [$netzleistung, $netzleistungGueltig] = $this->leseNetzleistung();
$this->SetValue('Netzleistung', $netzleistung); $this->SetValue('Netzleistung', $netzleistung);
$this->setzeDiagnosewert('NetzleistungGueltig', $netzleistungGueltig); $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<string, mixed> */
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<string, mixed> */
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 private function aktualisiereAnschlussstatus(): void
{ {
foreach ([ foreach ([
'PrognoseAnschluss' => 'Prognosestatus', 'PrognoseAnschluss' => 'Prognosestatus',
'SDLAnschluss' => 'SDLStatus', 'SDLAnschluss' => 'SDLStatus',
'LizenzAnschluss' => 'Lizenzstatus',
'StoermeldeAnschluss' => 'Stoerueberwachungsstatus', 'StoermeldeAnschluss' => 'Stoerueberwachungsstatus',
] as $property => $variable) { ] as $property => $variable) {
$konfiguration = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); $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('VerbraucherZuordnung');
$this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung'); $this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')); $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); $wert = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($wert)) { if (!is_array($wert)) {
throw new InvalidArgumentException($property . ' muss ein JSON-Objekt sein.'); throw new InvalidArgumentException($property . ' muss ein JSON-Objekt sein.');
+11 -7
View File
@@ -1,16 +1,17 @@
# Obere Anschlüsse des Managers # Obere Anschlüsse des Managers
> Status: Diskussionsentwurf. Anbieterformate und konkrete Gegenstellen sind > Status: Lizenzierung implementiert; die übrigen Anbieterformate sind noch
> noch nicht festgelegt. > 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. keine Abhängigkeit zwischen Enelix EMS und Enelix Utils.
| Anschluss | Aufgabe | Gegenstelle | | Anschluss | Aufgabe | Gegenstelle |
| --- | --- | --- | | --- | --- | --- |
| SDL/VGT | Zeitlich gültige Leistungsaufträge und Rückmeldungen | Optional das unabhängige Utils-Modul VGT-Schnittstelle | | 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 | | 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 | | Störüberwachung | Weitergabe von Zuständen und Störungen | Externer Empfänger; kein Repo-Modul |
## Gemeinsame Grundsätze ## 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`. - Jeder Anschluss meldet im Manager `NichtVerwendet`, `Verbunden` oder `Fehler`.
- Fehlende optionale Anschlüsse dürfen die lokale EMS-Grundfunktion nicht - Fehlende optionale Anschlüsse dürfen die lokale EMS-Grundfunktion nicht
blockieren. blockieren.
- Eine fehlende oder abgelaufene Lizenz blockiert die Managerregelung. Eine
bestaetigte Entwicklungs-Lease bleibt bei Verbindungsunterbruch bis
`offlineUntil` gueltig.
## Noch festzulegen ## 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 - Aufbau, Gültigkeitszeitraum und Rückmeldung eines SDL-Auftrags
- Authentisierung und Verhalten bei Verbindungsunterbruch - Authentisierung der noch optionalen Anschlüsse
- Welche Lizenzfehler nur Funktionen sperren und welche den Manager stoppen - Ablösung der Entwicklungs-Lease durch den signierten produktiven Lizenzvertrag
+7 -6
View File
@@ -46,16 +46,17 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
| ID | Status | Bereich | Kurzthema | | 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 ### OP-001: Betriebsort des Lizenzservers
- **Entscheidungsfrage:** Wo soll der Enelix-Lizenzserver betrieben werden? - **Entscheidung:** Der Entwicklungs-Lizenzdienst wird unter
- **Verantwortlich:** Offen `https://license.enelix.ch` betrieben.
- **Ziel-Sprint:** Offen - **Manager-Endpunkt:** `POST /api/v1/licenses/activate`
- **Ergebnis/Verweis:** Offen - **Ergebnis/Verweis:** [Obere Anschluesse](Obere-Anschluesse.md) und
[Manager](module/Manager/README.md)
## Vorlage fuer neue Punkte ## Vorlage fuer neue Punkte
+17 -4
View File
@@ -49,7 +49,7 @@ Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netz
| `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. | | `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. |
| `PrognoseAnschluss` | String/JSON | Optionaler Forecast-Anschluss. | | `PrognoseAnschluss` | String/JSON | Optionaler Forecast-Anschluss. |
| `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-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. | | `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. |
| `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. | | `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. |
| `LoggingEin` | Boolean | `false`; laufende Meldungen im Debug-Fenster ausgeben. | | `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 Die Monatsgrenzen werden in den Grundeinstellungen über eine Schaltfläche
ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut
ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren. 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. Der Manager erzeugt dabei
die Lizenzierung steht an erster Stelle des Formulars. 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 ## Verteilalgorithmus
@@ -94,6 +98,14 @@ wenn sich die beiden berechneten Korrekturen nicht ausreichend unterscheiden.
als Sammelstörung ausgewiesen. als Sammelstörung ausgewiesen.
- Bei fehlender oder veralteter Netzleistung bleibt die Regelung `Inaktiv` und - Bei fehlender oder veralteter Netzleistung bleibt die Regelung `Inaktiv` und
sendet keine neuen Vorgaben. 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 - Die oberen Anschlüsse bleiben optional. Solange kein konkreter Adapter
implementiert ist, meldet ein aktivierter Anschluss den Status `Fehler`, ohne implementiert ist, meldet ein aktivierter Anschluss den Status `Fehler`, ohne
die lokale EMS-Regelung zu blockieren. die lokale EMS-Regelung zu blockieren.
@@ -107,5 +119,6 @@ wenn sich die beiden berechneten Korrekturen nicht ausreichend unterscheiden.
## Offene Punkte ## Offene Punkte
- Monatliche Batteriereserve aus dem alten Peakshaving-Modul übernehmen? - 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. - Verhalten und Messabgrenzung bei Untermanagern im Anlagentest bestätigen.
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use DateTimeImmutable;
use InvalidArgumentException;
use Throwable;
final class Lizenzpruefung
{
public const MANAGER_STANDARD = 'manager_standard';
public const MANAGER_PEAK = 'manager_peak';
public static function normalisiereCode(string $code): string
{
return strtoupper((string) preg_replace('/[^A-Z0-9]/i', '', $code));
}
public static function istLizenzcodeGueltig(string $code): bool
{
return preg_match(
'/^ENX[A-HJ-NP-Z2-9]{16}$/',
self::normalisiereCode($code)
) === 1;
}
/**
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $lease
*/
public static function offlineBis(array $lease): int
{
return self::zeitstempel($lease, 'offlineUntil');
}
/**
* @param array<string, mixed> $lease
*/
public static function erneuernAb(array $lease): int
{
return self::zeitstempel($lease, 'refreshAfter');
}
/**
* @param array<string, mixed> $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);
}
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\Lizenzpruefung;
use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class LizenzpruefungTest extends TestCase
{
private const INSTALLATION_ID = '123e4567-e89b-42d3-a456-426614174000';
public function testStandardLizenzGibtGrundregelungFrei(): void
{
Lizenzpruefung::pruefeLease(
$this->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<string, mixed>
*/
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();
}
}
+26 -1
View File
@@ -50,12 +50,16 @@ final class ManagerModulstrukturTest extends TestCase
'Umschaltdifferenz', 'Umschaltdifferenz',
'DiagnosevariablenAnzeigen', 'DiagnosevariablenAnzeigen',
'LoggingEin', 'LoggingEin',
'Lizenzcode',
'LizenzPruefen',
'LizenzInstallationID',
'LizenzInformation',
'VerbraucherAktualisieren', 'VerbraucherAktualisieren',
] as $property) { ] as $property) {
self::assertStringContainsString($property, $json); self::assertStringContainsString($property, $json);
} }
foreach (['Regelintervall', 'Vorgabeintervall', 'UmschaltschwelleUeberschuss', foreach (['Regelintervall', 'Vorgabeintervall', 'UmschaltschwelleUeberschuss',
'UmschaltschwelleLastspitze', 'Optionale obere Anschluesse'] as $entfernt 'UmschaltschwelleLastspitze', 'Optionale obere Anschluesse', 'LizenzAnschluss'] as $entfernt
) { ) {
self::assertStringNotContainsString($entfernt, $json); self::assertStringNotContainsString($entfernt, $json);
} }
@@ -89,6 +93,27 @@ final class ManagerModulstrukturTest extends TestCase
self::assertStringContainsString('SendDebug(', $inhalt); 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 public function testManagerVerwendetDieNeuenZeitdefaults(): void
{ {
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); $inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
+1
View File
@@ -7,6 +7,7 @@ return [
'file' => __DIR__ . '/modules/Manager.php', 'file' => __DIR__ . '/modules/Manager.php',
'paths' => [ 'paths' => [
'Manager/*', 'Manager/*',
'libs/Lizenzpruefung.php',
'libs/ManagerRegler.php', 'libs/ManagerRegler.php',
'libs/ManagerSchnittstelle.php', 'libs/ManagerSchnittstelle.php',
'libs/Nachrichtenvertrag.php', 'libs/Nachrichtenvertrag.php',
+18
View File
@@ -48,11 +48,13 @@ return static function (TestContext $test): void {
['InstanzID' => $einStufigId, 'Aktiv' => true], ['InstanzID' => $einStufigId, 'Aktiv' => true],
['InstanzID' => $warmwasserId, 'Aktiv' => true], ['InstanzID' => $warmwasserId, 'Aktiv' => true],
]; ];
$lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE'));
$managerId = $test->createInstance( $managerId = $test->createInstance(
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}', '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
'Manager', 'Manager',
[ [
'NetzleistungVariableID' => $netzleistungId, 'NetzleistungVariableID' => $netzleistungId,
'Lizenzcode' => $lizenzcode,
'AutomatischeSuche' => false, 'AutomatischeSuche' => false,
'VerbraucherZuordnung' => json_encode($zuordnung, JSON_THROW_ON_ERROR), 'VerbraucherZuordnung' => json_encode($zuordnung, JSON_THROW_ON_ERROR),
'KeepAlive' => 30, 'KeepAlive' => 30,
@@ -130,6 +132,22 @@ return static function (TestContext $test): void {
return null; 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 ( $test->runCase('Manager und alle vorhandenen Verbrauchertypen sind verknüpft', static function (
TestContext $test TestContext $test
) use ($managerId, $einStufigId, $warmwasserId, $melden): void { ) use ($managerId, $einStufigId, $warmwasserId, $melden): void {
+8
View File
@@ -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 public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
{ {
self::assertSame( self::assertSame(