This commit is contained in:
+22
-3
@@ -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
@@ -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.');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -7,6 +7,7 @@ return [
|
||||
'file' => __DIR__ . '/modules/Manager.php',
|
||||
'paths' => [
|
||||
'Manager/*',
|
||||
'libs/Lizenzpruefung.php',
|
||||
'libs/ManagerRegler.php',
|
||||
'libs/ManagerSchnittstelle.php',
|
||||
'libs/Nachrichtenvertrag.php',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user