This commit is contained in:
+15
-3
@@ -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
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user