feat(manager): Störungsüberwachung ans Portal anbinden
Tests / test (push) Successful in 47s

This commit is contained in:
dh
2026-09-27 15:41:15 +00:00
parent f47c908798
commit 2d24ce0900
6 changed files with 605 additions and 35 deletions
+15 -3
View File
@@ -636,9 +636,21 @@
"caption": "Stoerueberwachung", "caption": "Stoerueberwachung",
"items": [ "items": [
{ {
"type": "ValidationTextBox", "type": "CheckBox",
"name": "StoermeldeAnschluss", "name": "StoerueberwachungAktiv",
"caption": "Konfiguration als JSON" "caption": "Stoerungen sicher an license.enelix.ch uebertragen"
},
{
"type": "NumberSpinner",
"name": "StoerungsSendeintervall",
"caption": "Heartbeat-Intervall",
"minimum": 60,
"maximum": 3600,
"suffix": " Sekunden"
},
{
"type": "Label",
"caption": "Die Freischaltung als jaehrlich erneuerbare Lizenz ist vorbereitet und wird spaeter aktiviert."
} }
] ]
} }
+250 -10
View File
@@ -8,13 +8,17 @@ require_once __DIR__ . '/../libs/ManagerRegler.php';
require_once __DIR__ . '/../libs/EinspeiseRegler.php'; require_once __DIR__ . '/../libs/EinspeiseRegler.php';
require_once __DIR__ . '/../libs/Lizenzpruefung.php'; require_once __DIR__ . '/../libs/Lizenzpruefung.php';
require_once __DIR__ . '/../libs/Anlagentopologie.php'; require_once __DIR__ . '/../libs/Anlagentopologie.php';
require_once __DIR__ . '/../libs/StoerungsSnapshot.php';
require_once __DIR__ . '/../libs/EnergieMessung.php';
use Belevo\EnelixEMS\Anlagentopologie; use Belevo\EnelixEMS\Anlagentopologie;
use Belevo\EnelixEMS\EinspeiseRegler; use Belevo\EnelixEMS\EinspeiseRegler;
use Belevo\EnelixEMS\EnergieMessung;
use Belevo\EnelixEMS\Lizenzpruefung; 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;
use Belevo\EnelixEMS\StoerungsSnapshot;
class Manager extends IPSModule implements ManagerSchnittstelle class Manager extends IPSModule implements ManagerSchnittstelle
{ {
@@ -26,8 +30,26 @@ class Manager extends IPSModule implements ManagerSchnittstelle
private const PROGNOSE_TOPOLOGIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology'; private const PROGNOSE_TOPOLOGIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology';
private const PROGNOSE_TELEMETRIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry'; private const PROGNOSE_TELEMETRIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry';
private const PROGNOSE_FAHRPLAN_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/schedule'; private const PROGNOSE_FAHRPLAN_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/schedule';
private const STOERUNGS_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/faults';
private const LIZENZ_RETRY_SEKUNDEN = 3600; private const LIZENZ_RETRY_SEKUNDEN = 3600;
private const STOERUNGS_RETRY_MAX_SEKUNDEN = 900;
private const VM_UPDATE = 10603; private const VM_UPDATE = 10603;
private const ARCHIVE_MODULE_ID = '{43192F0B-135B-4CE7-A0A7-1475603F3060}';
private const ENERGY_PIE_MODULE_ID = '{F73B8F8D-5E36-4B23-B379-4632A517A1D6}';
private const LEISTUNGS_VARIABLEN = [
'PVLeistungArchiv',
'HausverbrauchLeistungArchiv',
'NetzleistungArchiv',
'BatterieleistungArchiv',
];
private const ENERGIE_VARIABLEN = [
'PVEnergie',
'Hausenergie',
'NetzbezugEnergie',
'EinspeisungEnergie',
'BatterieLadenEnergie',
'BatterieEntladenEnergie',
];
private const VERBRAUCHER_LIZENZEN = [ private const VERBRAUCHER_LIZENZEN = [
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}' => [ '{B7C54AF4-AD7D-4FE4-B75D-203693906251}' => [
'Katalog' => 'boiler_multi', 'Katalog' => 'boiler_multi',
@@ -117,9 +139,25 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterPropertyString('Lizenzcode', ''); $this->RegisterPropertyString('Lizenzcode', '');
// Verdeckte Altproperty fuer bestehende Instanzkonfigurationen. // Verdeckte Altproperty fuer bestehende Instanzkonfigurationen.
$this->RegisterPropertyString('LizenzAnschluss', '{}'); $this->RegisterPropertyString('LizenzAnschluss', '{}');
// Verdeckte Altproperty fuer bestehende Instanzkonfigurationen.
$this->RegisterPropertyString('StoermeldeAnschluss', '{}'); $this->RegisterPropertyString('StoermeldeAnschluss', '{}');
$this->RegisterPropertyBoolean('StoerueberwachungAktiv', false);
$this->RegisterPropertyInteger('StoerungsSendeintervall', 300);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
$this->RegisterPropertyBoolean('LoggingEin', false); $this->RegisterPropertyBoolean('LoggingEin', false);
$this->RegisterPropertyBoolean('EnergieaufzeichnungAktiv', true);
$this->RegisterPropertyInteger('MessungPVLeistungVariableID', 0);
$this->RegisterPropertyInteger('MessungHausverbrauchLeistungVariableID', 0);
$this->RegisterPropertyInteger('MessungBatterieleistungVariableID', 0);
$this->RegisterPropertyFloat('MessungPVLeistungsfaktor', 1.0);
$this->RegisterPropertyFloat('MessungHausverbrauchLeistungsfaktor', 1.0);
$this->RegisterPropertyFloat('MessungBatterieleistungsfaktor', 1.0);
$this->RegisterPropertyBoolean('MesswerteAnzeigen', false);
$this->RegisterPropertyInteger('LeistungsaufzeichnungMinuten', 1);
$this->RegisterPropertyInteger('LeistungLoeschenMonate', 12);
$this->RegisterPropertyInteger('EnergieaufzeichnungMinuten', 5);
$this->RegisterPropertyInteger('EnergieVerdichtenMonate', 12);
$this->RegisterPropertyInteger('EnergieLoeschenMonate', 0);
$this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10); $this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10);
$this->EnableAction('Aktiv'); $this->EnableAction('Aktiv');
@@ -138,6 +176,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0); $this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0);
$this->RegisterAttributeString('LizenzLetzterFehler', ''); $this->RegisterAttributeString('LizenzLetzterFehler', '');
$this->RegisterAttributeString('PrognoseInstallationsToken', ''); $this->RegisterAttributeString('PrognoseInstallationsToken', '');
$this->RegisterAttributeString('StoerungsSnapshot', '{}');
$this->RegisterAttributeString('StoerungsGesendeterHash', '');
$this->RegisterAttributeInteger('StoerungsLetzterErfolg', 0);
$this->RegisterAttributeInteger('StoerungsNaechsterVersuch', 0);
$this->RegisterAttributeInteger('StoerungsFehlversuche', 0);
// 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, '');
@@ -152,6 +195,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
0, 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'PrognoseSenden', true);" "IPS_RequestAction(\$_IPS['TARGET'], 'PrognoseSenden', true);"
); );
$this->RegisterTimer(
'StoerungenSenden',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'StoerungenSenden', true);"
);
} }
public function ApplyChanges(): void public function ApplyChanges(): void
@@ -169,6 +217,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->SetTimerInterval('VorgabenErneuern', 0); $this->SetTimerInterval('VorgabenErneuern', 0);
$this->SetTimerInterval('KeepAlive', 0); $this->SetTimerInterval('KeepAlive', 0);
$this->SetTimerInterval('PrognoseSenden', 0); $this->SetTimerInterval('PrognoseSenden', 0);
$this->SetTimerInterval('StoerungenSenden', 0);
$this->protokolliere('Konfiguration', $fehler->getMessage()); $this->protokolliere('Konfiguration', $fehler->getMessage());
return; return;
} }
@@ -178,9 +227,15 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->SetTimerInterval('VorgabenErneuern', 0); $this->SetTimerInterval('VorgabenErneuern', 0);
$this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000); $this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000);
$this->SetTimerInterval('PrognoseSenden', 0); $this->SetTimerInterval('PrognoseSenden', 0);
$this->SetTimerInterval(
'StoerungenSenden',
$this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 30000 : 0
);
$this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen')); $this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen'));
$this->aktualisiereAnschlussstatus(); $this->aktualisiereAnschlussstatus();
$geraetezugangFehlt = ($this->hatAnlagentopologie() || $this->prognoseTelemetrieKonfiguriert()) $geraetezugangFehlt = ($this->hatAnlagentopologie()
|| $this->prognoseTelemetrieKonfiguriert()
|| $this->ReadPropertyBoolean('StoerueberwachungAktiv'))
&& $this->ReadAttributeString('PrognoseInstallationsToken') === ''; && $this->ReadAttributeString('PrognoseInstallationsToken') === '';
if (!$this->aktualisiereLizenzfreigabe( if (!$this->aktualisiereLizenzfreigabe(
$this->ReadPropertyString('Lizenzcode'), $this->ReadPropertyString('Lizenzcode'),
@@ -207,6 +262,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
); );
$this->sendePrognoseTelemetrie(); $this->sendePrognoseTelemetrie();
} }
if ($this->ReadPropertyBoolean('StoerueberwachungAktiv')) {
$this->sendeStoerungsSnapshot(true);
}
} }
public function GetConfigurationForm(): string public function GetConfigurationForm(): string
@@ -347,6 +405,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->sendePrognoseTelemetrie(); $this->sendePrognoseTelemetrie();
return; return;
case 'StoerungenSenden':
$this->sendeStoerungsSnapshot(false);
return;
case 'FormLizenzPruefen': case 'FormLizenzPruefen':
if (!is_string($wert)) { if (!is_string($wert)) {
throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.'); throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.');
@@ -1202,17 +1264,186 @@ class Manager extends IPSModule implements ManagerSchnittstelle
*/ */
private function aktualisiereStoerungen(array $verbraucher, array $stoerungen): void private function aktualisiereStoerungen(array $verbraucher, array $stoerungen): void
{ {
$meldungen = [];
foreach (array_values(array_unique($stoerungen)) as $meldung) {
$meldung = trim($meldung);
if ($meldung === '') {
continue;
}
$meldungen[] = [
'sourceType' => 'manager',
'sourceId' => 'ips:' . $this->InstanceID,
'sourceName' => IPS_GetName($this->InstanceID),
'code' => 'Manager.' . substr(hash('sha256', $meldung), 0, 16),
'severity' => 'error',
'message' => $meldung,
];
}
foreach ($verbraucher as $eintrag) { foreach ($verbraucher as $eintrag) {
foreach ($eintrag['Daten']['Zustand'] as $zustand) { foreach ($eintrag['Daten']['Zustand'] as $zustand) {
if ($zustand['Art'] === 'Stoerung' && $zustand['Wert'] === true) { if ($zustand['Art'] !== 'Stoerung' || $zustand['Wert'] !== true) {
$stoerungen[] = 'Instanz ' . $eintrag['InstanzID'] . ': ' continue;
. ($zustand['Text'] ?? $zustand['Kennung']);
} }
$instanzID = $eintrag['InstanzID'];
$text = trim((string) ($zustand['Text'] ?? $zustand['Kennung']));
$meldungen[] = [
'sourceType' => 'consumer',
'sourceId' => 'ips:' . $instanzID,
'sourceName' => IPS_InstanceExists($instanzID)
? IPS_GetName($instanzID)
: 'Instanz ' . $instanzID,
'code' => (string) $zustand['Kennung'],
'severity' => 'error',
'message' => $text !== '' ? $text : (string) $zustand['Kennung'],
];
} }
} }
$stoerungen = array_values(array_unique($stoerungen));
$this->setzeDiagnosewert('Sammelstoerung', $stoerungen !== []); $snapshot = StoerungsSnapshot::erstellen(
$this->setzeDiagnosewert('Stoertext', implode("\n", $stoerungen)); $this->initialisiereLizenzInstallationID(),
$this->InstanceID,
$this->stoerungsRolle(),
(bool) $this->GetValue('Aktiv'),
$meldungen
);
$this->WriteAttributeString(
'StoerungsSnapshot',
json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
);
$texte = array_map(
static fn (array $meldung): string => $meldung['sourceName'] . ': ' . $meldung['message'],
$snapshot['faults']
);
$this->setzeDiagnosewert('Sammelstoerung', $texte !== []);
$this->setzeDiagnosewert('Stoertext', implode("\n", $texte));
$this->sendeStoerungsSnapshot(false);
}
private function stoerungsRolle(): string
{
return [
0 => 'standalone',
1 => 'main',
2 => 'submanager',
][$this->ReadPropertyInteger('Rolle')] ?? 'standalone';
}
private function sendeStoerungsSnapshot(bool $erzwingen): void
{
if (!$this->ReadPropertyBoolean('StoerueberwachungAktiv')) {
$this->setzeDiagnosewert('Stoerueberwachungsstatus', 'NichtVerwendet');
return;
}
try {
$snapshot = $this->leseJsonAttribut('StoerungsSnapshot');
if (($snapshot['version'] ?? null) !== StoerungsSnapshot::VERSION) {
// Erst ein real erfasster Vollsnapshot darf bestehende Portalstoerungen aufloesen.
$this->setzeDiagnosewert('Stoerueberwachungsstatus', 'Wartet');
return;
}
$hash = StoerungsSnapshot::zustandHash($snapshot);
$jetzt = time();
if (!$erzwingen && $jetzt < $this->ReadAttributeInteger('StoerungsNaechsterVersuch')) {
return;
}
if (!$erzwingen
&& hash_equals($this->ReadAttributeString('StoerungsGesendeterHash'), $hash)
&& ($jetzt - $this->ReadAttributeInteger('StoerungsLetzterErfolg'))
< $this->ReadPropertyInteger('StoerungsSendeintervall')
) {
return;
}
$token = trim($this->ReadAttributeString('PrognoseInstallationsToken'));
if ($token === '') {
throw new RuntimeException('Der Geraetezugang fuer die Stoerueberwachung fehlt.');
}
$snapshot['capturedAt'] = gmdate('Y-m-d\TH:i:s\Z', $jetzt);
$this->uebertrageStoerungsSnapshot(
$this->ReadAttributeString('LizenzInstallationID'),
$snapshot,
$token
);
$this->WriteAttributeString('StoerungsGesendeterHash', $hash);
$this->WriteAttributeInteger('StoerungsLetzterErfolg', $jetzt);
$this->WriteAttributeInteger('StoerungsNaechsterVersuch', 0);
$this->WriteAttributeInteger('StoerungsFehlversuche', 0);
$this->setzeDiagnosewert('Stoerueberwachungsstatus', 'Verbunden');
} catch (Throwable $fehler) {
$versuche = $this->ReadAttributeInteger('StoerungsFehlversuche') + 1;
$wartezeit = min(
self::STOERUNGS_RETRY_MAX_SEKUNDEN,
30 * (2 ** min($versuche - 1, 5))
);
$meldung = substr($fehler->getMessage(), 0, 500);
$this->WriteAttributeInteger('StoerungsFehlversuche', $versuche);
$this->WriteAttributeInteger('StoerungsNaechsterVersuch', time() + $wartezeit);
$this->setzeDiagnosewert('Stoerueberwachungsstatus', 'Fehler: ' . $meldung);
$this->protokolliere('Stoerueberwachung', $meldung);
}
}
/** @param array<string, mixed> $nutzlast */
private function uebertrageStoerungsSnapshot(
string $installationID,
array $nutzlast,
string $token
): void {
$url = sprintf(self::STOERUNGS_ENDPOINT, rawurlencode($installationID));
$json = json_encode($nutzlast, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Die Stoerungsuebertragung konnte nicht vorbereitet werden.');
}
try {
curl_setopt_array($curl, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $json,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
],
CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.3',
]);
$antwort = curl_exec($curl);
if ($antwort === false) {
throw new RuntimeException(
'Stoerungsdienst nicht erreichbar: ' . curl_error($curl)
);
}
if (strlen($antwort) > 65536) {
throw new RuntimeException('Die Antwort der Stoerungsuebertragung ist zu gross.');
}
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
} finally {
curl_close($curl);
}
if ($httpStatus === 401 || $httpStatus === 403) {
$this->WriteAttributeString('PrognoseInstallationsToken', '');
}
if ($httpStatus !== 200) {
$meldung = 'Unbekannter Fehler';
try {
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (is_array($daten) && is_string($daten['error'] ?? null)) {
$meldung = $daten['error'];
}
} catch (Throwable $fehler) {
// Der HTTP-Status bleibt die fuehrende Fehlerinformation.
}
throw new RuntimeException(
'Stoerungsuebertragung HTTP ' . $httpStatus . ': ' . $meldung
);
}
} }
private function registriereNetzleistungsmeldung(): void private function registriereNetzleistungsmeldung(): void
@@ -1635,7 +1866,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$nutzlast = json_encode([ $nutzlast = json_encode([
'code' => $lizenzcode, 'code' => $lizenzcode,
'installationId' => $installationID, 'installationId' => $installationID,
'issueDeviceToken' => $this->hatAnlagentopologie() 'issueDeviceToken' => ($this->hatAnlagentopologie()
|| $this->prognoseTelemetrieKonfiguriert()
|| $this->ReadPropertyBoolean('StoerueberwachungAktiv'))
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '', && $this->ReadAttributeString('PrognoseInstallationsToken') === '',
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
$curl = curl_init(self::LIZENZ_ENDPOINT); $curl = curl_init(self::LIZENZ_ENDPOINT);
@@ -1708,12 +1941,15 @@ class Manager extends IPSModule implements ManagerSchnittstelle
foreach ([ foreach ([
'PrognoseAnschluss' => 'Prognosestatus', 'PrognoseAnschluss' => 'Prognosestatus',
'SDLAnschluss' => 'SDLStatus', 'SDLAnschluss' => 'SDLStatus',
'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);
$aktiv = is_array($konfiguration) && ($konfiguration['Aktiv'] ?? false) === true; $aktiv = is_array($konfiguration) && ($konfiguration['Aktiv'] ?? false) === true;
$this->setzeDiagnosewert($variable, $aktiv ? 'Fehler' : 'NichtVerwendet'); $this->setzeDiagnosewert($variable, $aktiv ? 'Fehler' : 'NichtVerwendet');
} }
$this->setzeDiagnosewert(
'Stoerueberwachungsstatus',
$this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 'Wartet' : 'NichtVerwendet'
);
} }
private function pruefeKonfiguration(): void private function pruefeKonfiguration(): void
@@ -1727,6 +1963,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
if ($intervall < 60 || $intervall > 3600) { if ($intervall < 60 || $intervall > 3600) {
throw new InvalidArgumentException('PrognoseSendeintervall muss zwischen 60 und 3600 Sekunden liegen.'); throw new InvalidArgumentException('PrognoseSendeintervall muss zwischen 60 und 3600 Sekunden liegen.');
} }
$stoerungsIntervall = $this->ReadPropertyInteger('StoerungsSendeintervall');
if ($stoerungsIntervall < 60 || $stoerungsIntervall > 3600) {
throw new InvalidArgumentException('StoerungsSendeintervall muss zwischen 60 und 3600 Sekunden liegen.');
}
$telemetrieIDs = [ $telemetrieIDs = [
$this->ReadPropertyInteger('PrognosePVVariableID'), $this->ReadPropertyInteger('PrognosePVVariableID'),
$this->ReadPropertyInteger('PrognoseHausverbrauchVariableID'), $this->ReadPropertyInteger('PrognoseHausverbrauchVariableID'),
@@ -1771,7 +2011,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung'); $this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')); $this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen'));
$this->leseAnlagentopologie(); $this->leseAnlagentopologie();
foreach (['PrognoseAnschluss', 'SDLAnschluss', 'StoermeldeAnschluss'] as $property) { foreach (['PrognoseAnschluss', 'SDLAnschluss'] 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.');
+47 -21
View File
@@ -1,34 +1,60 @@
# Obere Anschlüsse des Managers # Obere Anschlüsse des Managers
> Status: Lizenzierung implementiert; die übrigen Anbieterformate sind noch > Status: Lizenzierung, Prognose und Stoerueberwachung sind an
> nicht festgelegt. > `license.enelix.ch` angebunden. SDL/VGT bleibt als optionaler Anschluss
> vorbereitet.
Prognose, SDL/VGT und Stoerueberwachung sind optional. Die Lizenzierung ist fuer Die Anschluesse werden ausschliesslich im Manager konfiguriert. Sie erzeugen
den Betrieb des Managers erforderlich. Sie werden im Manager konfiguriert und erzeugen keine Abhaengigkeit 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 gueltige Leistungsauftraege und Rueckmeldungen | Optional das unabhaengige Utils-Modul VGT-Schnittstelle |
| Prognose / Forecast | Vorhersage für Produktion und bei Bedarf Verbrauch | Externer Anbieter; kein Repo-Modul | | Prognose / Forecast | Topologie, Telemetrie und Netzfahrplan | `license.enelix.ch/api/v1/installations/{id}/prognosis/*` |
| Lizenzierung | Pruefung freigeschalteter Manager-Funktionen | `POST https://license.enelix.ch/api/v1/licenses/activate` | | 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 | | Stoerueberwachung | Vollsnapshot aktiver Manager- und Geraetestoerungen | `PUT https://license.enelix.ch/api/v1/installations/{id}/faults` |
## Gemeinsame Grundsätze ## Stoerungsdatenfluss
- Die Auswahl der Gegenstelle erfolgt ausschliesslich im Manager. 1. Jedes Geraetemodul meldet seinen Zustand ueber den bestehenden
- Anbieterformate werden an diesem Anschluss übersetzt und gelangen nicht in Verbraucher-Nachrichtenvertrag an den Manager.
2. Der Manager sammelt aktive Eintraege mit `Art = Stoerung`, fuegt eigene
Regelungsstoerungen hinzu und normalisiert sie.
3. Der Manager speichert lokal einen deterministisch sortierten Vollsnapshot.
4. Bei Zustandsaenderung oder spaetestens im konfigurierten Heartbeat-Intervall
sendet er den Snapshot mit dem vorhandenen Geraete-Bearer-Token.
5. Ein leerer Snapshot loest zuvor aktive Stoerungen im Portal auf.
Jede Meldung besteht aus `sourceType`, `sourceId`, `sourceName`, `code`,
`severity` und `message`. Die stabile Kombination aus `sourceId` und
`code` identifiziert ein Ereignis ueber mehrere Snapshots hinweg.
Fehler der externen Uebertragung blockieren die lokale EMS-Regelung nicht. Der
Manager wiederholt mit exponentiellem Abstand zwischen 30 und 900 Sekunden.
Nach HTTP 401 oder 403 verwirft er den Geraetezugang und fordert ihn bei der
naechsten Lizenzaktivierung neu an.
## Lizenzvorbereitung
Die Stoerueberwachung ist technisch als separat schaltbare Manager-Funktion
gekapselt. Das Portal kann spaeter eine Berechtigung mit dem Katalogschluessel
`fault_monitoring`, einer Laufzeit von einem Jahr und einem erneuerten
`validUntil` ausliefern. In der ersten Ausbaustufe wird diese zusaetzliche
Jahresberechtigung noch nicht erzwungen; Voraussetzung bleibt eine gueltige
Managerlizenz samt Geraetezugang.
## Gemeinsame Grundsaetze
- Anbieterformate werden am Manageranschluss uebersetzt und gelangen nicht in
die Verbraucher-Schnittstelle. die Verbraucher-Schnittstelle.
- Jeder Anschluss meldet im Manager `NichtVerwendet`, `Verbunden` oder `Fehler`. - Jeder Anschluss meldet `NichtVerwendet`, `Wartet`, `Verbunden` oder
- Fehlende optionale Anschlüsse dürfen die lokale EMS-Grundfunktion nicht `Fehler`.
- Fehlende optionale Anschluesse duerfen die lokale EMS-Grundfunktion nicht
blockieren. blockieren.
- Eine fehlende oder abgelaufene Lizenz blockiert die Managerregelung. Eine - Zugangstoken werden weder als Property noch in Diagnosevariablen oder Logs
bestaetigte Entwicklungs-Lease bleibt bei Verbindungsunterbruch bis ausgegeben.
`offlineUntil` gueltig.
## Noch festzulegen ## Noch festzulegen
- Anbieter und Datenformat für Prognose und Störüberwachung - Aufbau, Gueltigkeitszeitraum und Rueckmeldung eines SDL-Auftrags
- Aufbau, Gültigkeitszeitraum und Rückmeldung eines SDL-Auftrags - Produkt- und Zahlungsmodell der jaehrlichen Stoerueberwachungslizenz
- Authentisierung der noch optionalen Anschlüsse - Verhalten nach Ablauf einer spaeter aktivierten Stoerueberwachungslizenz
- Ablösung der Entwicklungs-Lease durch den signierten produktiven Lizenzvertrag
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class StoerungsSnapshot
{
public const VERSION = '1.0';
private const SCHWEREGRADE = [
'critical' => 0,
'error' => 1,
'warning' => 2,
'info' => 3,
];
/**
* @param list<array<string, mixed>> $meldungen
* @return array<string, mixed>
*/
public static function erstellen(
string $installationID,
int $managerID,
string $rolle,
bool $managerAktiv,
array $meldungen,
?int $zeitpunkt = null
): array {
if (preg_match(
'/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
$installationID
) !== 1) {
throw new InvalidArgumentException('Die Installations-ID muss eine UUIDv4 sein.');
}
if ($managerID <= 0) {
throw new InvalidArgumentException('Die Manager-ID muss positiv sein.');
}
if (!in_array($rolle, ['standalone', 'main', 'submanager'], true)) {
throw new InvalidArgumentException('Die Managerrolle ist ungueltig.');
}
$eindeutig = [];
foreach ($meldungen as $meldung) {
$normalisiert = self::normalisiereMeldung($meldung);
$schluessel = $normalisiert['sourceId'] . "\0" . $normalisiert['code'];
$eindeutig[$schluessel] = $normalisiert;
}
$meldungen = array_values($eindeutig);
usort($meldungen, static function (array $links, array $rechts): int {
$schweregrad = self::SCHWEREGRADE[$links['severity']]
<=> self::SCHWEREGRADE[$rechts['severity']];
if ($schweregrad !== 0) {
return $schweregrad;
}
return [$links['sourceName'], $links['code']]
<=> [$rechts['sourceName'], $rechts['code']];
});
// Das Portal akzeptiert hoechstens 100 Meldungen; die hoechste Prioritaet bleibt erhalten.
$meldungen = array_slice($meldungen, 0, 100);
return [
'version' => self::VERSION,
'installationId' => strtolower($installationID),
'capturedAt' => gmdate('Y-m-d\TH:i:s\Z', $zeitpunkt ?? time()),
'manager' => [
'instanceId' => $managerID,
'role' => $rolle,
'active' => $managerAktiv,
],
'faults' => $meldungen,
];
}
/** @param array<string, mixed> $snapshot */
public static function zustandHash(array $snapshot): string
{
unset($snapshot['capturedAt']);
return hash('sha256', json_encode(
$snapshot,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
));
}
/**
* @param array<string, mixed> $meldung
* @return array<string, string>
*/
private static function normalisiereMeldung(array $meldung): array
{
$felder = ['sourceType', 'sourceId', 'sourceName', 'code', 'severity', 'message'];
if (array_diff($felder, array_keys($meldung)) !== []
|| array_diff(array_keys($meldung), $felder) !== []
) {
throw new InvalidArgumentException('Eine Stoerungsmeldung hat ungueltige Felder.');
}
$normalisiert = [];
foreach ($felder as $feld) {
if (!is_string($meldung[$feld])) {
throw new InvalidArgumentException('Stoerungsfelder muessen Texte sein.');
}
$normalisiert[$feld] = trim((string) preg_replace('/\s+/', ' ', $meldung[$feld]));
}
if ($normalisiert['sourceType'] === ''
|| $normalisiert['sourceId'] === ''
|| $normalisiert['sourceName'] === ''
|| $normalisiert['code'] === ''
|| $normalisiert['message'] === ''
) {
throw new InvalidArgumentException('Stoerungsfelder duerfen nicht leer sein.');
}
if (!array_key_exists($normalisiert['severity'], self::SCHWEREGRADE)) {
throw new InvalidArgumentException('Der Stoerungsschweregrad ist ungueltig.');
}
if (strlen($normalisiert['sourceType']) > 40
|| strlen($normalisiert['sourceId']) > 120
|| strlen($normalisiert['sourceName']) > 160
|| strlen($normalisiert['code']) > 120
|| strlen($normalisiert['message']) > 500
) {
throw new InvalidArgumentException('Mindestens ein Stoerungsfeld ist zu lang.');
}
return $normalisiert;
}
}
+31 -1
View File
@@ -70,6 +70,8 @@ final class ManagerModulstrukturTest extends TestCase
'PrognoseHausverbrauchVariableID', 'PrognoseHausverbrauchVariableID',
'PrognoseSOCVariableID', 'PrognoseSOCVariableID',
'PrognoseSendeintervall', 'PrognoseSendeintervall',
'StoerueberwachungAktiv',
'StoerungsSendeintervall',
] as $property) { ] as $property) {
self::assertStringContainsString($property, $json); self::assertStringContainsString($property, $json);
} }
@@ -201,7 +203,7 @@ final class ManagerModulstrukturTest extends TestCase
self::assertStringContainsString("'issueDeviceToken' =>", $inhalt); self::assertStringContainsString("'issueDeviceToken' =>", $inhalt);
self::assertStringContainsString("unset(\$neueLease['deviceToken'])", $inhalt); self::assertStringContainsString("unset(\$neueLease['deviceToken'])", $inhalt);
self::assertStringContainsString( self::assertStringContainsString(
"\$geraetezugangFehlt = (\$this->hatAnlagentopologie() || \$this->prognoseTelemetrieKonfiguriert())", "|| \$this->ReadPropertyBoolean('StoerueberwachungAktiv'))",
$inhalt $inhalt
); );
self::assertStringContainsString( self::assertStringContainsString(
@@ -237,6 +239,34 @@ final class ManagerModulstrukturTest extends TestCase
); );
} }
public function testManagerUebertraegtStoerungsvollsnapshotsMitRetry(): void
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertFileExists(__DIR__ . '/../libs/StoerungsSnapshot.php');
self::assertStringContainsString(
'https://license.enelix.ch/api/v1/installations/%s/faults',
$inhalt
);
foreach ([
"RegisterPropertyBoolean('StoerueberwachungAktiv', false)",
"RegisterPropertyInteger('StoerungsSendeintervall', 300)",
"RegisterAttributeString('StoerungsSnapshot', '{}')",
"RegisterAttributeString('StoerungsGesendeterHash', '')",
"RegisterAttributeInteger('StoerungsNaechsterVersuch', 0)",
"'StoerungenSenden',",
'StoerungsSnapshot::erstellen(',
'StoerungsSnapshot::zustandHash(',
"CURLOPT_CUSTOMREQUEST => 'PUT'",
] as $erwartet) {
self::assertStringContainsString($erwartet, $inhalt);
}
self::assertStringNotContainsString(
'"name":"StoermeldeAnschluss"',
str_replace(' ', '', (string) file_get_contents(__DIR__ . '/../Manager/form.json'))
);
}
public function testManagerRegeltDieAnlagenweiteEinspeisegrenzeUndDenNetzfahrplan(): void public function testManagerRegeltDieAnlagenweiteEinspeisegrenzeUndDenNetzfahrplan(): void
{ {
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); $inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\StoerungsSnapshot;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class StoerungsSnapshotTest extends TestCase
{
private const INSTALLATION_ID = '123e4567-e89b-42d3-a456-426614174000';
public function testSnapshotNormalisiertDedupliziertUndSortiertMeldungen(): void
{
$snapshot = StoerungsSnapshot::erstellen(
self::INSTALLATION_ID,
42,
'main',
true,
[
[
'sourceType' => 'consumer',
'sourceId' => 'ips:8',
'sourceName' => ' Boiler ',
'code' => 'Temp.High',
'severity' => 'warning',
'message' => " Temperatur hoch ",
],
[
'sourceType' => 'manager',
'sourceId' => 'ips:42',
'sourceName' => 'Manager',
'code' => 'Grid.Missing',
'severity' => 'critical',
'message' => 'Netzmessung fehlt',
],
[
'sourceType' => 'consumer',
'sourceId' => 'ips:8',
'sourceName' => 'Boiler',
'code' => 'Temp.High',
'severity' => 'warning',
'message' => 'Temperatur sehr hoch',
],
],
1_700_000_000
);
self::assertSame('1.0', $snapshot['version']);
self::assertSame('2023-11-14T22:13:20Z', $snapshot['capturedAt']);
self::assertSame(
['Grid.Missing', 'Temp.High'],
array_column($snapshot['faults'], 'code')
);
self::assertSame('Temperatur sehr hoch', $snapshot['faults'][1]['message']);
self::assertSame(
['instanceId' => 42, 'role' => 'main', 'active' => true],
$snapshot['manager']
);
}
public function testZustandHashIgnoriertDenErfassungszeitpunkt(): void
{
$a = StoerungsSnapshot::erstellen(
self::INSTALLATION_ID,
42,
'standalone',
false,
[],
1_700_000_000
);
$b = StoerungsSnapshot::erstellen(
self::INSTALLATION_ID,
42,
'standalone',
false,
[],
1_700_000_300
);
self::assertSame(StoerungsSnapshot::zustandHash($a), StoerungsSnapshot::zustandHash($b));
}
public function testSnapshotBegrenztSichAufHundertPriorisierteMeldungen(): void
{
$meldungen = [];
for ($index = 1; $index <= 101; ++$index) {
$meldungen[] = [
'sourceType' => 'consumer',
'sourceId' => 'ips:' . $index,
'sourceName' => sprintf('Quelle %03d', $index),
'code' => 'Fault.' . $index,
'severity' => $index === 101 ? 'critical' : 'warning',
'message' => 'Testmeldung ' . $index,
];
}
$snapshot = StoerungsSnapshot::erstellen(
self::INSTALLATION_ID,
42,
'standalone',
true,
$meldungen
);
self::assertCount(100, $snapshot['faults']);
self::assertSame('Fault.101', $snapshot['faults'][0]['code']);
}
public function testUngueltigerSchweregradWirdAbgelehnt(): void
{
$this->expectException(InvalidArgumentException::class);
StoerungsSnapshot::erstellen(
self::INSTALLATION_ID,
42,
'standalone',
true,
[[
'sourceType' => 'consumer',
'sourceId' => 'ips:8',
'sourceName' => 'Boiler',
'code' => 'Temp.High',
'severity' => 'alarm',
'message' => 'Temperatur hoch',
]]
);
}
}