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",
"caption": "Lizenzierung",
"expanded": true,
"items": [
{
"type": "ValidationTextBox",
"name": "LizenzAnschluss",
"caption": "Konfiguration als JSON"
"name": "Lizenzcode",
"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": [
{"code": 102, "icon": "active", "caption": "Aktiv"},
{"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/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/ManagerRegler.php';
require_once __DIR__ . '/../libs/Lizenzpruefung.php';
use Belevo\EnelixEMS\Lizenzpruefung;
use Belevo\EnelixEMS\ManagerRegler;
use Belevo\EnelixEMS\ManagerSchnittstelle;
use Belevo\EnelixEMS\Nachrichtenvertrag;
@@ -15,6 +17,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
private const STATUS_AKTIV = 102;
private const STATUS_MESSWERT_UNGUELTIG = 201;
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 VERBRAUCHER_MODUL_IDS = [
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
@@ -61,6 +66,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterPropertyFloat('Umschaltdifferenz', 5.0);
$this->RegisterPropertyString('PrognoseAnschluss', '{}');
$this->RegisterPropertyString('SDLAnschluss', '{}');
$this->RegisterPropertyString('Lizenzcode', '');
// Verdeckte Altproperty fuer bestehende Instanzkonfigurationen.
$this->RegisterPropertyString('LizenzAnschluss', '{}');
$this->RegisterPropertyString('StoermeldeAnschluss', '{}');
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
@@ -74,6 +81,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterAttributeString('VerbraucherCache', '{}');
$this->RegisterAttributeString('LetzteSollwerte', '{}');
$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.
$this->RegisterTimer('Regelzyklus', 0, '');
@@ -108,6 +123,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000);
$this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen'));
$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);
}
@@ -124,6 +144,18 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$monatsgrenzenSichtbar = $lastspitzenmodus === 2
&& $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'],
@@ -199,6 +231,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->regeln((bool) $wert);
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':
$automatisch = (bool) $wert;
$this->UpdateFormField('VerbraucherZuordnung', 'visible', !$automatisch);
@@ -298,6 +338,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle
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();
$this->SetValue('Netzleistung', $netzleistung);
$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
{
foreach ([
'PrognoseAnschluss' => 'Prognosestatus',
'SDLAnschluss' => 'SDLStatus',
'LizenzAnschluss' => 'Lizenzstatus',
'StoermeldeAnschluss' => 'Stoerueberwachungsstatus',
] as $property => $variable) {
$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('AutomatischeVerbraucherZuordnung');
$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);
if (!is_array($wert)) {
throw new InvalidArgumentException($property . ' muss ein JSON-Objekt sein.');
+11 -7
View File
@@ -1,16 +1,17 @@
# Obere Anschlüsse des Managers
> Status: Diskussionsentwurf. Anbieterformate und konkrete Gegenstellen sind
> noch nicht festgelegt.
> Status: Lizenzierung implementiert; die übrigen Anbieterformate sind noch
> 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.
| Anschluss | Aufgabe | Gegenstelle |
| --- | --- | --- |
| 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 |
| 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 |
## 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`.
- Fehlende optionale Anschlüsse dürfen die lokale EMS-Grundfunktion nicht
blockieren.
- Eine fehlende oder abgelaufene Lizenz blockiert die Managerregelung. Eine
bestaetigte Entwicklungs-Lease bleibt bei Verbindungsunterbruch bis
`offlineUntil` gueltig.
## 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
- Authentisierung und Verhalten bei Verbindungsunterbruch
- Welche Lizenzfehler nur Funktionen sperren und welche den Manager stoppen
- Authentisierung der noch optionalen Anschlüsse
- 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 |
| --- | --- | --- | --- |
| OP-001 | Offen | Lizenzierung | Betriebsort des Lizenzservers |
| OP-001 | Entschieden | Lizenzierung | Betriebsort des Lizenzservers |
## Offene Entscheidungen
## Entschiedene Punkte
### OP-001: Betriebsort des Lizenzservers
- **Entscheidungsfrage:** Wo soll der Enelix-Lizenzserver betrieben werden?
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
- **Entscheidung:** Der Entwicklungs-Lizenzdienst wird unter
`https://license.enelix.ch` betrieben.
- **Manager-Endpunkt:** `POST /api/v1/licenses/activate`
- **Ergebnis/Verweis:** [Obere Anschluesse](Obere-Anschluesse.md) und
[Manager](module/Manager/README.md)
## 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. |
| `PrognoseAnschluss` | String/JSON | Optionaler Forecast-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. |
| `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. |
| `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
ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut
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.
Die Lizenzierung steht an erster Stelle des Formulars. Der Manager erzeugt dabei
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
@@ -94,6 +98,14 @@ wenn sich die beiden berechneten Korrekturen nicht ausreichend unterscheiden.
als Sammelstörung ausgewiesen.
- Bei fehlender oder veralteter Netzleistung bleibt die Regelung `Inaktiv` und
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
implementiert ist, meldet ein aktivierter Anschluss den Status `Fehler`, ohne
die lokale EMS-Regelung zu blockieren.
@@ -107,5 +119,6 @@ wenn sich die beiden berechneten Korrekturen nicht ausreichend unterscheiden.
## Offene Punkte
- 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.
+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',
'DiagnosevariablenAnzeigen',
'LoggingEin',
'Lizenzcode',
'LizenzPruefen',
'LizenzInstallationID',
'LizenzInformation',
'VerbraucherAktualisieren',
] as $property) {
self::assertStringContainsString($property, $json);
}
foreach (['Regelintervall', 'Vorgabeintervall', 'UmschaltschwelleUeberschuss',
'UmschaltschwelleLastspitze', 'Optionale obere Anschluesse'] as $entfernt
'UmschaltschwelleLastspitze', 'Optionale obere Anschluesse', 'LizenzAnschluss'] as $entfernt
) {
self::assertStringNotContainsString($entfernt, $json);
}
@@ -89,6 +93,27 @@ final class ManagerModulstrukturTest extends TestCase
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
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
+1
View File
@@ -7,6 +7,7 @@ return [
'file' => __DIR__ . '/modules/Manager.php',
'paths' => [
'Manager/*',
'libs/Lizenzpruefung.php',
'libs/ManagerRegler.php',
'libs/ManagerSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
+18
View File
@@ -48,11 +48,13 @@ return static function (TestContext $test): void {
['InstanzID' => $einStufigId, 'Aktiv' => true],
['InstanzID' => $warmwasserId, 'Aktiv' => true],
];
$lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE'));
$managerId = $test->createInstance(
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
'Manager',
[
'NetzleistungVariableID' => $netzleistungId,
'Lizenzcode' => $lizenzcode,
'AutomatischeSuche' => false,
'VerbraucherZuordnung' => json_encode($zuordnung, JSON_THROW_ON_ERROR),
'KeepAlive' => 30,
@@ -130,6 +132,22 @@ return static function (TestContext $test): void {
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 (
TestContext $test
) 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
{
self::assertSame(