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",
"items": [
{
"type": "ValidationTextBox",
"name": "StoermeldeAnschluss",
"caption": "Konfiguration als JSON"
"type": "CheckBox",
"name": "StoerueberwachungAktiv",
"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/Lizenzpruefung.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\EinspeiseRegler;
use Belevo\EnelixEMS\EnergieMessung;
use Belevo\EnelixEMS\Lizenzpruefung;
use Belevo\EnelixEMS\ManagerRegler;
use Belevo\EnelixEMS\ManagerSchnittstelle;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\StoerungsSnapshot;
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_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 STOERUNGS_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/faults';
private const LIZENZ_RETRY_SEKUNDEN = 3600;
private const STOERUNGS_RETRY_MAX_SEKUNDEN = 900;
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 = [
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}' => [
'Katalog' => 'boiler_multi',
@@ -117,9 +139,25 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterPropertyString('Lizenzcode', '');
// Verdeckte Altproperty fuer bestehende Instanzkonfigurationen.
$this->RegisterPropertyString('LizenzAnschluss', '{}');
// Verdeckte Altproperty fuer bestehende Instanzkonfigurationen.
$this->RegisterPropertyString('StoermeldeAnschluss', '{}');
$this->RegisterPropertyBoolean('StoerueberwachungAktiv', false);
$this->RegisterPropertyInteger('StoerungsSendeintervall', 300);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', 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->EnableAction('Aktiv');
@@ -138,6 +176,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0);
$this->RegisterAttributeString('LizenzLetzterFehler', '');
$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.
$this->RegisterTimer('Regelzyklus', 0, '');
@@ -152,6 +195,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'PrognoseSenden', true);"
);
$this->RegisterTimer(
'StoerungenSenden',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'StoerungenSenden', true);"
);
}
public function ApplyChanges(): void
@@ -169,6 +217,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->SetTimerInterval('VorgabenErneuern', 0);
$this->SetTimerInterval('KeepAlive', 0);
$this->SetTimerInterval('PrognoseSenden', 0);
$this->SetTimerInterval('StoerungenSenden', 0);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
@@ -178,9 +227,15 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->SetTimerInterval('VorgabenErneuern', 0);
$this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000);
$this->SetTimerInterval('PrognoseSenden', 0);
$this->SetTimerInterval(
'StoerungenSenden',
$this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 30000 : 0
);
$this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen'));
$this->aktualisiereAnschlussstatus();
$geraetezugangFehlt = ($this->hatAnlagentopologie() || $this->prognoseTelemetrieKonfiguriert())
$geraetezugangFehlt = ($this->hatAnlagentopologie()
|| $this->prognoseTelemetrieKonfiguriert()
|| $this->ReadPropertyBoolean('StoerueberwachungAktiv'))
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '';
if (!$this->aktualisiereLizenzfreigabe(
$this->ReadPropertyString('Lizenzcode'),
@@ -207,6 +262,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
);
$this->sendePrognoseTelemetrie();
}
if ($this->ReadPropertyBoolean('StoerueberwachungAktiv')) {
$this->sendeStoerungsSnapshot(true);
}
}
public function GetConfigurationForm(): string
@@ -347,6 +405,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->sendePrognoseTelemetrie();
return;
case 'StoerungenSenden':
$this->sendeStoerungsSnapshot(false);
return;
case 'FormLizenzPruefen':
if (!is_string($wert)) {
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
{
$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 ($eintrag['Daten']['Zustand'] as $zustand) {
if ($zustand['Art'] === 'Stoerung' && $zustand['Wert'] === true) {
$stoerungen[] = 'Instanz ' . $eintrag['InstanzID'] . ': '
. ($zustand['Text'] ?? $zustand['Kennung']);
if ($zustand['Art'] !== 'Stoerung' || $zustand['Wert'] !== true) {
continue;
}
$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 !== []);
$this->setzeDiagnosewert('Stoertext', implode("\n", $stoerungen));
$snapshot = StoerungsSnapshot::erstellen(
$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
@@ -1635,7 +1866,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$nutzlast = json_encode([
'code' => $lizenzcode,
'installationId' => $installationID,
'issueDeviceToken' => $this->hatAnlagentopologie()
'issueDeviceToken' => ($this->hatAnlagentopologie()
|| $this->prognoseTelemetrieKonfiguriert()
|| $this->ReadPropertyBoolean('StoerueberwachungAktiv'))
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '',
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
$curl = curl_init(self::LIZENZ_ENDPOINT);
@@ -1708,12 +1941,15 @@ class Manager extends IPSModule implements ManagerSchnittstelle
foreach ([
'PrognoseAnschluss' => 'Prognosestatus',
'SDLAnschluss' => 'SDLStatus',
'StoermeldeAnschluss' => 'Stoerueberwachungsstatus',
] as $property => $variable) {
$konfiguration = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
$aktiv = is_array($konfiguration) && ($konfiguration['Aktiv'] ?? false) === true;
$this->setzeDiagnosewert($variable, $aktiv ? 'Fehler' : 'NichtVerwendet');
}
$this->setzeDiagnosewert(
'Stoerueberwachungsstatus',
$this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 'Wartet' : 'NichtVerwendet'
);
}
private function pruefeKonfiguration(): void
@@ -1727,6 +1963,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
if ($intervall < 60 || $intervall > 3600) {
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 = [
$this->ReadPropertyInteger('PrognosePVVariableID'),
$this->ReadPropertyInteger('PrognoseHausverbrauchVariableID'),
@@ -1771,7 +2011,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen'));
$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);
if (!is_array($wert)) {
throw new InvalidArgumentException($property . ' muss ein JSON-Objekt sein.');