From efa8fe698cef8feed95fc61290970496d949be25 Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Wed, 23 Sep 2026 06:40:17 +0000 Subject: [PATCH] feat: Prognosetelemetrie aus Demoanlage senden --- Manager/form.json | 24 ++++- Manager/module.php | 166 ++++++++++++++++++++++++++++- docs/module/Manager/README.md | 15 ++- examples/Demoanlage/README.md | 6 +- examples/Demoanlage/bootstrap.php | 4 + tests/DemoanlageBootstrapTest.php | 4 + tests/ManagerModulstrukturTest.php | 15 ++- 7 files changed, 227 insertions(+), 7 deletions(-) diff --git a/Manager/form.json b/Manager/form.json index b90cc99..90ef5ab 100644 --- a/Manager/form.json +++ b/Manager/form.json @@ -539,9 +539,27 @@ "caption": "Prognose / Forecast", "items": [ { - "type": "ValidationTextBox", - "name": "PrognoseAnschluss", - "caption": "Konfiguration als JSON" + "type": "SelectVariable", + "name": "PrognosePVVariableID", + "caption": "PV-Leistung" + }, + { + "type": "SelectVariable", + "name": "PrognoseHausverbrauchVariableID", + "caption": "Hausverbrauch" + }, + { + "type": "SelectVariable", + "name": "PrognoseSOCVariableID", + "caption": "Batterie-Ladezustand" + }, + { + "type": "NumberSpinner", + "name": "PrognoseSendeintervall", + "caption": "Sendeintervall", + "minimum": 60, + "maximum": 3600, + "suffix": " Sekunden" } ] }, diff --git a/Manager/module.php b/Manager/module.php index 7a0d1b6..e2587e2 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -22,6 +22,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle private const STATUS_LIZENZ_UNGUELTIG = 203; private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate'; 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 LIZENZ_RETRY_SEKUNDEN = 3600; private const VM_UPDATE = 10603; private const VERBRAUCHER_LIZENZEN = [ @@ -97,6 +98,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterPropertyString('AnlagenWechselrichter', '[]'); $this->RegisterPropertyString('AnlagenPVFlaechen', '[]'); $this->RegisterPropertyString('AnlagenBatterien', '[]'); + $this->RegisterPropertyInteger('PrognosePVVariableID', 0); + $this->RegisterPropertyInteger('PrognoseHausverbrauchVariableID', 0); + $this->RegisterPropertyInteger('PrognoseSOCVariableID', 0); + $this->RegisterPropertyInteger('PrognoseSendeintervall', 60); $this->RegisterPropertyString('PrognoseAnschluss', '{}'); $this->RegisterPropertyString('SDLAnschluss', '{}'); $this->RegisterPropertyString('Lizenzcode', ''); @@ -130,6 +135,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle 0, "IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);" ); + $this->RegisterTimer( + 'PrognoseSenden', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'PrognoseSenden', true);" + ); } public function ApplyChanges(): void @@ -146,6 +156,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->SetTimerInterval('Regelzyklus', 0); $this->SetTimerInterval('VorgabenErneuern', 0); $this->SetTimerInterval('KeepAlive', 0); + $this->SetTimerInterval('PrognoseSenden', 0); $this->protokolliere('Konfiguration', $fehler->getMessage()); return; } @@ -154,9 +165,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->SetTimerInterval('Regelzyklus', 0); $this->SetTimerInterval('VorgabenErneuern', 0); $this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000); + $this->SetTimerInterval('PrognoseSenden', 0); $this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen')); $this->aktualisiereAnschlussstatus(); - $geraetezugangFehlt = $this->hatAnlagentopologie() + $geraetezugangFehlt = ($this->hatAnlagentopologie() || $this->prognoseTelemetrieKonfiguriert()) && $this->ReadAttributeString('PrognoseInstallationsToken') === ''; if (!$this->aktualisiereLizenzfreigabe( $this->ReadPropertyString('Lizenzcode'), @@ -176,6 +188,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle } $this->SetStatus(self::STATUS_AKTIV); $this->synchronisiereAnlagentopologie(); + if ($this->prognoseTelemetrieKonfiguriert()) { + $this->SetTimerInterval( + 'PrognoseSenden', + $this->ReadPropertyInteger('PrognoseSendeintervall') * 1000 + ); + $this->sendePrognoseTelemetrie(); + } } public function GetConfigurationForm(): string @@ -295,6 +314,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->regeln((bool) $wert); return; + case 'PrognoseSenden': + $this->sendePrognoseTelemetrie(); + return; + case 'FormLizenzPruefen': if (!is_string($wert)) { throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.'); @@ -1447,6 +1470,29 @@ class Manager extends IPSModule implements ManagerSchnittstelle throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); } } + $intervall = $this->ReadPropertyInteger('PrognoseSendeintervall'); + if ($intervall < 60 || $intervall > 3600) { + throw new InvalidArgumentException('PrognoseSendeintervall muss zwischen 60 und 3600 Sekunden liegen.'); + } + $telemetrieIDs = [ + $this->ReadPropertyInteger('PrognosePVVariableID'), + $this->ReadPropertyInteger('PrognoseHausverbrauchVariableID'), + $this->ReadPropertyInteger('PrognoseSOCVariableID'), + ]; + $konfigurierteIDs = array_values(array_filter($telemetrieIDs, static fn (int $id): bool => $id > 0)); + if ($konfigurierteIDs !== [] && count($konfigurierteIDs) !== count($telemetrieIDs)) { + throw new InvalidArgumentException('Fuer die Prognose muessen PV, Hausverbrauch und SOC gemeinsam konfiguriert sein.'); + } + foreach ($konfigurierteIDs as $variableID) { + if (!IPS_VariableExists($variableID)) { + throw new InvalidArgumentException('Eine konfigurierte Prognosevariable existiert nicht.'); + } + } + if ($konfigurierteIDs !== [] + && !IPS_VariableExists($this->ReadPropertyInteger('NetzleistungVariableID')) + ) { + throw new InvalidArgumentException('Die Netzleistungsvariable fuer die Prognose existiert nicht.'); + } if ($this->ReadPropertyFloat('Umschaltdifferenz') < 0 || $this->ReadPropertyFloat('Umschaltdifferenz') > 100 ) { @@ -1484,6 +1530,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle || $topologie['batteries'] !== []; } + private function prognoseTelemetrieKonfiguriert(): bool + { + return $this->ReadPropertyInteger('PrognosePVVariableID') > 0 + && $this->ReadPropertyInteger('PrognoseHausverbrauchVariableID') > 0 + && $this->ReadPropertyInteger('PrognoseSOCVariableID') > 0; + } + private function synchronisiereAnlagentopologie(): void { try { @@ -1572,6 +1625,117 @@ class Manager extends IPSModule implements ManagerSchnittstelle } } + private function sendePrognoseTelemetrie(): void + { + if (!$this->prognoseTelemetrieKonfiguriert()) { + return; + } + + try { + $token = trim($this->ReadAttributeString('PrognoseInstallationsToken')); + if ($token === '') { + throw new RuntimeException('Der Geraetezugang fuer die Prognose fehlt.'); + } + + $werte = [ + 'PV' => GetValue($this->ReadPropertyInteger('PrognosePVVariableID')), + 'Hausverbrauch' => GetValue($this->ReadPropertyInteger('PrognoseHausverbrauchVariableID')), + 'Netzleistung' => GetValue($this->ReadPropertyInteger('NetzleistungVariableID')), + 'SOC' => GetValue($this->ReadPropertyInteger('PrognoseSOCVariableID')), + ]; + foreach ($werte as $name => $wert) { + if (!is_int($wert) && !is_float($wert)) { + throw new RuntimeException('Der Prognosewert ' . $name . ' ist keine Zahl.'); + } + $werte[$name] = (float) $wert; + if (!is_finite($werte[$name])) { + throw new RuntimeException('Der Prognosewert ' . $name . ' ist nicht endlich.'); + } + } + $werte['Netzleistung'] *= $this->ReadPropertyFloat('Netzleistungsfaktor'); + if ($werte['PV'] < 0.0 || $werte['Hausverbrauch'] < 0.0 + || $werte['SOC'] < 0.0 || $werte['SOC'] > 100.0 + ) { + throw new RuntimeException('Mindestens ein Prognosewert liegt ausserhalb des gueltigen Bereichs.'); + } + + $installationID = $this->ReadAttributeString('LizenzInstallationID'); + $nutzlast = [ + 'version' => '1.0', + 'installationId' => $installationID, + 'capturedAt' => gmdate('Y-m-d\TH:i:s\Z'), + 'values' => $werte, + ]; + $this->uebertragePrognoseTelemetrie($installationID, $nutzlast, $token); + $this->setzeDiagnosewert('Prognosestatus', 'Verbunden'); + } catch (Throwable $fehler) { + $meldung = substr($fehler->getMessage(), 0, 500); + $this->setzeDiagnosewert('Prognosestatus', 'Fehler: ' . $meldung); + $this->protokolliere('Prognosetelemetrie', $meldung); + } + } + + /** @param array $nutzlast */ + private function uebertragePrognoseTelemetrie( + string $installationID, + array $nutzlast, + string $token + ): void { + $url = sprintf(self::PROGNOSE_TELEMETRIE_ENDPOINT, rawurlencode($installationID)); + $json = json_encode( + $nutzlast, + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION + ); + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Die Prognosetelemetrie konnte nicht vorbereitet werden.'); + } + + try { + curl_setopt_array($curl, [ + CURLOPT_POST => true, + 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.2', + ]); + $antwort = curl_exec($curl); + if ($antwort === false) { + throw new RuntimeException('Prognosedienst nicht erreichbar: ' . curl_error($curl)); + } + if (strlen($antwort) > 65536) { + throw new RuntimeException('Die Antwort der Prognosetelemetrie 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( + 'Prognosetelemetrie HTTP ' . $httpStatus . ': ' . $meldung + ); + } + } + /** @return array */ private function dekodiereMonatsgrenzen(string $json): array { diff --git a/docs/module/Manager/README.md b/docs/module/Manager/README.md index 8e026dd..06d3d6b 100644 --- a/docs/module/Manager/README.md +++ b/docs/module/Manager/README.md @@ -50,7 +50,11 @@ Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netz | `AnlagenWechselrichter` | String/JSON | `[]`; Wechselrichter mit Typ und AC-Grenzen. | | `AnlagenPVFlaechen` | String/JSON | `[]`; PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichter-ID. | | `AnlagenBatterien` | String/JSON | `[]`; Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und Wechselrichter-ID. | -| `PrognoseAnschluss` | String/JSON | Optionaler Forecast-Anschluss. | +| `PrognosePVVariableID` | Integer | `0`; PV-Leistung in W fuer die Prognosetelemetrie. | +| `PrognoseHausverbrauchVariableID` | Integer | `0`; Hausverbrauch in W fuer die Prognosetelemetrie. | +| `PrognoseSOCVariableID` | Integer | `0`; Batterie-Ladezustand in Prozent. | +| `PrognoseSendeintervall` | Integer | `60` s; zulaessig sind 60 bis 3600 Sekunden. | +| `PrognoseAnschluss` | String/JSON | Verdeckte Altproperty fuer bestehende Konfigurationen. | | `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. | | `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. | | `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. | @@ -87,6 +91,15 @@ internen Attribut. Der Lizenzcode wird nicht als API-Token verwendet. Ein Synchronisationsfehler erscheint im `Prognosestatus`, blockiert die lokale EMS-Regelung aber nicht. +Sind PV-Leistung, Hausverbrauch und Batterie-SOC gemeinsam konfiguriert, sendet +der Manager diese Werte zusammen mit der faktorbereinigten Netzleistung und +einem UTC-Zeitstempel im eingestellten Intervall. Das Minimum von 60 Sekunden +passt zum Rate-Limit des Lizenzportals. Die Uebertragung nutzt denselben +widerrufbaren Installationszugang wie die Topologie; Lizenzcode und Geraet Token +werden weder als Telemetriefelder noch im Debug-Log ausgegeben. Bei HTTP 401 +oder 403 verwirft der Manager den Geraetezugang und fordert ihn bei der naechsten +Lizenzpruefung neu an. + Die Entscheidung und ihre Alternativen sind in [ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert. diff --git a/examples/Demoanlage/README.md b/examples/Demoanlage/README.md index 179b68e..784a421 100644 --- a/examples/Demoanlage/README.md +++ b/examples/Demoanlage/README.md @@ -9,6 +9,8 @@ Die Demo erzeugt idempotent eine vollständige, spielbare EMS-Anlage in IP-Symco - einen bidirektionalen Batteriespeicher (10 kWh, +/-3,5 kW), - eine technische Anlagentopologie mit Hybridwechselrichter, PV-Fläche und Batteriespeicher für die Prognoseanbindung, +- Prognosetelemetrie aus PV, Hausverbrauch, Netzleistung und Batterie-SOC im + 60-Sekunden-Intervall, - simulierte PV-Erzeugung, variable Bewölkung, Tageslastgang, Zusatzlast, Außentemperatur, Wärmeverluste, Netzleistung und Speicherzustände, - native Symcon-Instanz `Energy Distribution` für acht Energieflussknoten, @@ -45,7 +47,9 @@ Die Demo übernimmt die einstellbare PV-Spitzenleistung als AC- und DC-Anlagenleistung. Für die feste Beispieltopologie gelten 30 Grad Neigung und Südausrichtung. Der 10-kWh-Speicher nutzt den gemeinsamen Hybridwechselrichter; seine dynamischen Lade- und Entladegrenzen werden beim Bootstrap in die -technischen Stammdaten übernommen. +technischen Stammdaten übernommen. Bei aktiver Manager-Lizenz uebertraegt der +Manager zusaetzlich PV-Leistung, Hausverbrauch, Netzleistung und Batterie-SOC +alle 60 Sekunden an den Enelix-Prognosedienst. ## Bedienung diff --git a/examples/Demoanlage/bootstrap.php b/examples/Demoanlage/bootstrap.php index 4b2e7a4..1e68780 100644 --- a/examples/Demoanlage/bootstrap.php +++ b/examples/Demoanlage/bootstrap.php @@ -313,6 +313,10 @@ function enelixDemoInstall(): array $managerProperties = [ 'NetzleistungVariableID' => $grid, 'Netzleistungsfaktor' => 1.0, + 'PrognosePVVariableID' => $pvDisplay, + 'PrognoseHausverbrauchVariableID' => $houseDisplay, + 'PrognoseSOCVariableID' => $batterySoc, + 'PrognoseSendeintervall' => 60, 'MesswertMaxAlter' => 10, 'AutomatischeSuche' => false, 'SuchbereichID' => $root, diff --git a/tests/DemoanlageBootstrapTest.php b/tests/DemoanlageBootstrapTest.php index 13225f7..bb8ab86 100644 --- a/tests/DemoanlageBootstrapTest.php +++ b/tests/DemoanlageBootstrapTest.php @@ -119,6 +119,10 @@ final class DemoanlageBootstrapTest extends TestCase self::assertStringContainsString("'AnlagenWechselrichter'", $source); self::assertStringContainsString("'AnlagenPVFlaechen'", $source); self::assertStringContainsString("'AnlagenBatterien'", $source); + self::assertStringContainsString("'PrognosePVVariableID' => \$pvDisplay", $source); + self::assertStringContainsString("'PrognoseHausverbrauchVariableID' => \$houseDisplay", $source); + self::assertStringContainsString("'PrognoseSOCVariableID' => \$batterySoc", $source); + self::assertStringContainsString("'PrognoseSendeintervall' => 60", $source); self::assertStringContainsString("'demo-hybrid-1'", $source); self::assertStringContainsString("'demo-pv-1'", $source); self::assertStringContainsString("'demo-battery-1'", $source); diff --git a/tests/ManagerModulstrukturTest.php b/tests/ManagerModulstrukturTest.php index 18d6745..20764a0 100644 --- a/tests/ManagerModulstrukturTest.php +++ b/tests/ManagerModulstrukturTest.php @@ -59,6 +59,10 @@ final class ManagerModulstrukturTest extends TestCase 'AnlagenWechselrichter', 'AnlagenPVFlaechen', 'AnlagenBatterien', + 'PrognosePVVariableID', + 'PrognoseHausverbrauchVariableID', + 'PrognoseSOCVariableID', + 'PrognoseSendeintervall', ] as $property) { self::assertStringContainsString($property, $json); } @@ -190,7 +194,7 @@ final class ManagerModulstrukturTest extends TestCase self::assertStringContainsString("'issueDeviceToken' =>", $inhalt); self::assertStringContainsString("unset(\$neueLease['deviceToken'])", $inhalt); self::assertStringContainsString( - "\$geraetezugangFehlt = \$this->hatAnlagentopologie()", + "\$geraetezugangFehlt = (\$this->hatAnlagentopologie() || \$this->prognoseTelemetrieKonfiguriert())", $inhalt ); self::assertStringContainsString( @@ -198,6 +202,15 @@ final class ManagerModulstrukturTest extends TestCase $inhalt ); self::assertStringContainsString('synchronisiereAnlagentopologie()', $inhalt); + self::assertStringContainsString( + 'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry', + $inhalt + ); + self::assertStringContainsString("'PrognoseSenden',", $inhalt); + self::assertStringContainsString("'capturedAt' => gmdate(", $inhalt); + foreach (["'PV'", "'Hausverbrauch'", "'Netzleistung'", "'SOC'"] as $messwert) { + self::assertStringContainsString($messwert, $inhalt); + } self::assertStringContainsString("'Authorization: Bearer ' . \$token", $inhalt); self::assertStringNotContainsString( "protokolliere('Prognosesynchronisation', \$token)",