From d03a7fd8fb53e0ceff16434e071656d2701d6750 Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Tue, 22 Sep 2026 07:09:59 +0000 Subject: [PATCH] Easee-Gateway und Ladestationsmodul implementieren --- EaseeGateway/README.md | 11 + EaseeGateway/form.json | 52 ++ EaseeGateway/module.json | 21 + EaseeGateway/module.php | 791 ++++++++++++++++ LadestationGateway/README.md | 11 + LadestationGateway/form.json | 116 +++ LadestationGateway/module.json | 18 + LadestationGateway/module.php | 985 ++++++++++++++++++++ Manager/module.php | 1 + docs/Schnittstelle-Easee-Gateway.md | 103 ++ docs/Schnittstelle.md | 8 + docs/module/Easee-Gateway/README.md | 86 +- docs/module/Ladestation-Gateway/README.md | 129 ++- docs/module/README.md | 10 +- docs/testing/README.md | 4 +- libs/EaseeGatewayProtokoll.php | 121 +++ libs/EaseeLadestatus.php | 137 +++ tests/DokumentationsstrukturTest.php | 16 +- tests/EaseeGatewayProtokollTest.php | 55 ++ tests/EaseeLadestatusTest.php | 97 ++ tests/EaseeModuleStrukturTest.php | 81 ++ tests/Symcon/manifest.php | 19 + tests/Symcon/modules/EaseeGateway.php | 89 ++ tests/Symcon/modules/LadestationGateway.php | 155 +++ tests/SymconTestContractTest.php | 19 +- 25 files changed, 3074 insertions(+), 61 deletions(-) create mode 100644 EaseeGateway/README.md create mode 100644 EaseeGateway/form.json create mode 100644 EaseeGateway/module.json create mode 100644 EaseeGateway/module.php create mode 100644 LadestationGateway/README.md create mode 100644 LadestationGateway/form.json create mode 100644 LadestationGateway/module.json create mode 100644 LadestationGateway/module.php create mode 100644 docs/Schnittstelle-Easee-Gateway.md create mode 100644 libs/EaseeGatewayProtokoll.php create mode 100644 libs/EaseeLadestatus.php create mode 100644 tests/EaseeGatewayProtokollTest.php create mode 100644 tests/EaseeLadestatusTest.php create mode 100644 tests/EaseeModuleStrukturTest.php create mode 100644 tests/Symcon/modules/EaseeGateway.php create mode 100644 tests/Symcon/modules/LadestationGateway.php diff --git a/EaseeGateway/README.md b/EaseeGateway/README.md new file mode 100644 index 0000000..f9861ad --- /dev/null +++ b/EaseeGateway/README.md @@ -0,0 +1,11 @@ +# Easee Gateway + +Gemeinsames IP-Symcon-Splittermodul fuer ein Easee-Nutzerkonto. Es verwaltet +Anmeldung, Token, Ereignisverbindung und REST-Stromvorgaben fuer mehrere +Ladestationen. + +Konfiguration, Betrieb und Fehlerbehandlung: +[Moduldokumentation](../docs/module/Easee-Gateway/README.md) + +Kind-Gateway-Vertrag: +[Schnittstellenbeschreibung](../docs/Schnittstelle-Easee-Gateway.md) diff --git a/EaseeGateway/form.json b/EaseeGateway/form.json new file mode 100644 index 0000000..191f4d6 --- /dev/null +++ b/EaseeGateway/form.json @@ -0,0 +1,52 @@ +{ + "elements": [ + { + "type": "CheckBox", + "name": "Active", + "caption": "Gateway aktiv" + }, + { + "type": "ValidationTextBox", + "name": "Username", + "caption": "Easee Benutzername" + }, + { + "type": "PasswordTextBox", + "name": "Password", + "caption": "Easee Passwort" + }, + { + "type": "CheckBox", + "name": "VerifyCertificate", + "caption": "TLS-Zertifikat pruefen" + }, + { + "type": "Label", + "caption": "Ein Gateway wird von allen Easee-Ladestationen desselben Kontos gemeinsam verwendet." + } + ], + "actions": [ + { + "type": "Button", + "caption": "Verbindung neu aufbauen", + "onClick": "IPS_RequestAction($id, \"Reconnect\", false);" + } + ], + "status": [ + { + "code": 201, + "icon": "error", + "caption": "Easee-Zugangsdaten fehlen" + }, + { + "code": 202, + "icon": "error", + "caption": "Easee-Anmeldung fehlgeschlagen" + }, + { + "code": 203, + "icon": "error", + "caption": "Easee-Ereignisverbindung fehlgeschlagen" + } + ] +} diff --git a/EaseeGateway/module.json b/EaseeGateway/module.json new file mode 100644 index 0000000..4510eba --- /dev/null +++ b/EaseeGateway/module.json @@ -0,0 +1,21 @@ +{ + "id": "{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}", + "name": "EaseeGateway", + "type": 2, + "vendor": "Enelix", + "aliases": [ + "Easee Gateway" + ], + "parentRequirements": [ + "{79827379-F36E-4ADA-8A95-5F8D1DC92FA9}" + ], + "childRequirements": [ + "{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}" + ], + "implemented": [ + "{018EF6B5-AB94-40C6-AA53-46943E824ACF}", + "{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}" + ], + "prefix": "ENELIXEASEE", + "url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/EaseeGateway" +} diff --git a/EaseeGateway/module.php b/EaseeGateway/module.php new file mode 100644 index 0000000..f0df0b6 --- /dev/null +++ b/EaseeGateway/module.php @@ -0,0 +1,791 @@ +RegisterPropertyBoolean('Active', true); + $this->RegisterPropertyString('Username', ''); + $this->RegisterPropertyString('Password', ''); + $this->RegisterPropertyBoolean('VerifyCertificate', true); + + // Nur fuer den automatisierten IP-Symcon-Funktionstest. + $this->RegisterPropertyBoolean('Testmodus', false); + + $this->RegisterAttributeString('ObservationCache', '{}'); + + $this->RegisterVariableBoolean('Connected', 'Easee verbunden', '~Switch', 10); + $this->RegisterVariableInteger( + 'SubscriptionCount', + 'Angemeldete Ladestationen', + '', + 20 + ); + $this->RegisterVariableString('LastError', 'Letzter Fehler', '', 30); + + $this->RegisterTimer( + 'MaintainConnectionTimer', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'MaintainConnection', false);" + ); + $this->RegisterTimer( + 'TokenRefreshTimer', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'RefreshToken', false);" + ); + + $this->RequireParent(self::WEBSOCKET_MODULE_ID); + } + + public function ApplyChanges(): void + { + parent::ApplyChanges(); + + $this->SetTimerInterval('MaintainConnectionTimer', 0); + $this->SetTimerInterval('TokenRefreshTimer', 0); + $this->SetBuffer('SignalRReady', '0'); + $this->SetBuffer('ReceiveBuffer', ''); + $this->SetBuffer('Subscriptions', '{}'); + $this->SetBuffer('SubscribedThisConnection', '{}'); + $this->SetBuffer('PendingInvocations', '{}'); + $this->SetValue('SubscriptionCount', 0); + $this->setzeVerbunden(false); + + if (!$this->ReadPropertyBoolean('Active')) { + $this->SetStatus(104); + return; + } + + if ($this->ReadPropertyBoolean('Testmodus')) { + $this->SetStatus(102); + $this->setzeLetztenFehler(''); + $this->setzeVerbunden(true); + return; + } + + if ( + trim($this->ReadPropertyString('Username')) === '' + || $this->ReadPropertyString('Password') === '' + ) { + $this->SetStatus(201); + $this->setzeLetztenFehler('Easee-Benutzername oder Passwort fehlt.'); + return; + } + + if (!$this->stelleWebSocketParentSicher()) { + $this->SetStatus(203); + return; + } + + $this->SetTimerInterval('MaintainConnectionTimer', 10000); + $this->SetTimerInterval('TokenRefreshTimer', 1800000); + if (!$this->erneuereZugangsdaten(false)) { + $this->SetStatus(202); + return; + } + + $this->verbindeSignalR(); + } + + public function RequestAction($ident, $wert): void + { + switch ($ident) { + case 'MaintainConnection': + $this->pflegeVerbindung(); + return; + + case 'RefreshToken': + if ($this->erneuereZugangsdaten(false)) { + $this->verbindeSignalR(); + } + return; + + case 'Reconnect': + $this->SetBuffer('AccessToken', ''); + $this->SetBuffer('RefreshToken', ''); + if ( + $this->ReadPropertyBoolean('Testmodus') + || $this->erneuereZugangsdaten(false) + ) { + $this->verbindeSignalR(); + } + return; + + case 'TestObservation': + if (!$this->ReadPropertyBoolean('Testmodus') || !is_string($wert)) { + throw new InvalidArgumentException('TestObservation ist nur im Testmodus zulaessig.'); + } + $daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($daten)) { + throw new InvalidArgumentException('TestObservation erwartet ein JSON-Objekt.'); + } + $this->veroeffentlicheBeobachtung( + EaseeGatewayProtokoll::seriennummer((string) ($daten['serialNumber'] ?? '')), + (int) ($daten['id'] ?? 0), + $daten['value'] ?? null + ); + return; + } + + throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident); + } + + public function GetConfigurationForParent(): string + { + return json_encode([ + 'Active' => $this->ReadPropertyBoolean('Active') + && !$this->ReadPropertyBoolean('Testmodus'), + 'URL' => $this->GetBuffer('WebSocketURL'), + 'VerifyCertificate' => $this->ReadPropertyBoolean('VerifyCertificate'), + 'Headers' => '[]', + ], JSON_THROW_ON_ERROR); + } + + public function ReceiveData($jsonString): void + { + $paket = json_decode((string) $jsonString, true); + if (!is_array($paket) || !isset($paket['Buffer'])) { + return; + } + + $this->SetBuffer('LastReceive', (string) time()); + $puffer = $this->GetBuffer('ReceiveBuffer') . (string) $paket['Buffer']; + $rahmen = explode(self::RECORD_SEPARATOR, $puffer); + $this->SetBuffer('ReceiveBuffer', (string) array_pop($rahmen)); + + foreach ($rahmen as $eintrag) { + if ($eintrag !== '') { + $this->verarbeiteSignalRRahmen($eintrag); + } + } + } + + public function ForwardData($jsonString): string + { + $paket = json_decode((string) $jsonString, true); + if (!is_array($paket) || !isset($paket['Buffer'])) { + return json_encode([ + 'success' => false, + 'error' => 'Ungueltiges Datenpaket.', + ], JSON_THROW_ON_ERROR); + } + + return $this->ProcessStationRequest((string) $paket['Buffer']); + } + + public function ProcessStationRequest($jsonString): string + { + $anfrage = json_decode((string) $jsonString, true); + if (!is_array($anfrage) || !isset($anfrage['action'])) { + return json_encode([ + 'success' => false, + 'error' => 'Ungueltige Gateway-Anfrage.', + ], JSON_THROW_ON_ERROR); + } + + $seriennummer = EaseeGatewayProtokoll::seriennummer( + (string) ($anfrage['serialNumber'] ?? '') + ); + + switch ($anfrage['action']) { + case 'Subscribe': + case 'GetState': + if ($seriennummer === '') { + return json_encode([ + 'success' => false, + 'error' => 'Seriennummer fehlt.', + ], JSON_THROW_ON_ERROR); + } + $this->registriereAbonnement($seriennummer); + return $this->erstelleStatusantwort($seriennummer); + + case 'SetDynamicChargerCurrent': + return json_encode($this->setzeDynamischenLadestrom( + $seriennummer, + (float) ($anfrage['amps'] ?? -1) + ), JSON_THROW_ON_ERROR); + } + + return json_encode([ + 'success' => false, + 'error' => 'Unbekannte Gateway-Aktion.', + ], JSON_THROW_ON_ERROR); + } + + private function stelleWebSocketParentSicher(): bool + { + $instanz = IPS_GetInstance($this->InstanceID); + $parentID = (int) $instanz['ConnectionID']; + + if ($parentID <= 0 && !$this->RequireParent(self::WEBSOCKET_MODULE_ID)) { + $this->setzeLetztenFehler('WebSocket-Client konnte nicht erstellt werden.'); + return false; + } + + $instanz = IPS_GetInstance($this->InstanceID); + $parentID = (int) $instanz['ConnectionID']; + if ($parentID <= 0 || !IPS_InstanceExists($parentID)) { + $this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.'); + return false; + } + + $parent = IPS_GetInstance($parentID); + if ($parent['ModuleInfo']['ModuleID'] !== self::WEBSOCKET_MODULE_ID) { + $this->setzeLetztenFehler('Ungueltige WebSocket-Schnittstelle.'); + return false; + } + + return true; + } + + private function pflegeVerbindung(): void + { + if (!$this->ReadPropertyBoolean('Active') || $this->ReadPropertyBoolean('Testmodus')) { + return; + } + + $jetzt = time(); + $letzteAushandlung = (int) $this->GetBuffer('LastNegotiation'); + $letzterEmpfang = (int) $this->GetBuffer('LastReceive'); + $bereit = $this->GetBuffer('SignalRReady') === '1'; + + if ($bereit) { + $this->sendeSignalRRahmen(['type' => 6]); + if ($letzterEmpfang > 0 && ($jetzt - $letzterEmpfang) <= 90) { + return; + } + $this->SetBuffer('SignalRReady', '0'); + $this->setzeVerbunden(false); + } + + if (($jetzt - $letzteAushandlung) >= 45) { + $this->verbindeSignalR(); + return; + } + + $this->sendeHandshake(); + } + + private function verbindeSignalR(): bool + { + if ($this->ReadPropertyBoolean('Testmodus')) { + $this->SetStatus(102); + $this->setzeVerbunden(true); + return true; + } + if (!$this->stelleAccessTokenSicher()) { + $this->SetStatus(202); + return false; + } + + $this->SetBuffer('LastNegotiation', (string) time()); + $token = $this->GetBuffer('AccessToken'); + $antwort = $this->httpAnfrage( + 'POST', + self::SIGNALR_BASE_URL . '/negotiate?negotiateVersion=1', + '', + $token + ); + if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) { + $token = $this->GetBuffer('AccessToken'); + $antwort = $this->httpAnfrage( + 'POST', + self::SIGNALR_BASE_URL . '/negotiate?negotiateVersion=1', + '', + $token + ); + } + if (!$antwort['success']) { + $this->SetStatus(203); + $this->setzeLetztenFehler( + 'Easee-Ereignisverbindung fehlgeschlagen: HTTP ' + . $antwort['httpCode'] + . ($antwort['error'] !== '' ? ' / ' . $antwort['error'] : '') + ); + return false; + } + + $daten = json_decode($antwort['body'], true); + $verbindungstoken = is_array($daten) + ? (string) ($daten['connectionToken'] ?? $daten['connectionId'] ?? '') + : ''; + if ($verbindungstoken === '') { + $this->SetStatus(203); + $this->setzeLetztenFehler('Easee liefert kein Verbindungstoken.'); + return false; + } + + $url = 'wss://streams.easee.com/hubs/chargers?id=' + . rawurlencode($verbindungstoken) + . '&access_token=' . rawurlencode($token); + $this->SetBuffer('WebSocketURL', $url); + $this->SetBuffer('SignalRReady', '0'); + $this->SetBuffer('ReceiveBuffer', ''); + $this->SetBuffer('SubscribedThisConnection', '{}'); + $this->SetBuffer('PendingInvocations', '{}'); + $this->WriteAttributeString('ObservationCache', '{}'); + $this->setzeVerbunden(false); + + if (!$this->konfiguriereWebSocketParent($url)) { + $this->SetStatus(203); + return false; + } + + $this->sendeHandshake(); + return true; + } + + private function konfiguriereWebSocketParent(string $url): bool + { + $parentID = (int) IPS_GetInstance($this->InstanceID)['ConnectionID']; + if ($parentID <= 0) { + $this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.'); + return false; + } + + IPS_SetProperty($parentID, 'Active', true); + IPS_SetProperty($parentID, 'URL', $url); + IPS_SetProperty( + $parentID, + 'VerifyCertificate', + $this->ReadPropertyBoolean('VerifyCertificate') + ); + IPS_SetProperty($parentID, 'Headers', '[]'); + IPS_ApplyChanges($parentID); + + return true; + } + + private function sendeHandshake(): void + { + $this->sendeRohdaten( + json_encode(['protocol' => 'json', 'version' => 1], JSON_THROW_ON_ERROR) + . self::RECORD_SEPARATOR + ); + } + + /** @param array $rahmen */ + private function sendeSignalRRahmen(array $rahmen): void + { + $this->sendeRohdaten( + json_encode($rahmen, JSON_THROW_ON_ERROR) . self::RECORD_SEPARATOR + ); + } + + private function sendeRohdaten(string $nutzdaten): void + { + if ($this->ReadPropertyBoolean('Testmodus')) { + return; + } + $this->SendDataToParent(json_encode([ + 'DataID' => self::SIMPLE_TX_DATA_ID, + 'Buffer' => $nutzdaten, + ], JSON_THROW_ON_ERROR)); + } + + private function verarbeiteSignalRRahmen(string $rahmen): void + { + if ($rahmen === '{}') { + $this->SetBuffer('SignalRReady', '1'); + $this->SetStatus(102); + $this->setzeLetztenFehler(''); + $this->setzeVerbunden(true); + $this->sendeAlleAbonnements(); + return; + } + + $nachricht = json_decode($rahmen, true); + if (!is_array($nachricht)) { + $this->setzeLetztenFehler('Ungueltige Easee-Ereignisnachricht.'); + return; + } + if (isset($nachricht['error'])) { + $this->SetStatus(203); + $this->setzeLetztenFehler('Easee-Ereignisfehler: ' . $nachricht['error']); + return; + } + + $typ = (int) ($nachricht['type'] ?? 0); + if ($typ === 6) { + return; + } + if ($typ === 7) { + $this->SetBuffer('SignalRReady', '0'); + $this->setzeVerbunden(false); + $this->setzeLetztenFehler('Easee-Ereignisverbindung wurde beendet.'); + return; + } + + $seriennummer = ''; + if ($typ === 3 && isset($nachricht['invocationId'])) { + $offen = $this->lesePufferArray('PendingInvocations'); + $aufrufID = (string) $nachricht['invocationId']; + $seriennummer = (string) ($offen[$aufrufID] ?? ''); + unset($offen[$aufrufID]); + $this->SetBuffer( + 'PendingInvocations', + json_encode($offen, JSON_THROW_ON_ERROR) + ); + } + + foreach (['arguments', 'result'] as $feld) { + if (!isset($nachricht[$feld])) { + continue; + } + $abonnements = array_keys($this->lesePufferArray('Subscriptions')); + foreach (EaseeGatewayProtokoll::extrahiereBeobachtungen( + $nachricht[$feld], + $abonnements, + $seriennummer + ) as $beobachtung) { + $this->veroeffentlicheBeobachtung( + $beobachtung['Seriennummer'], + $beobachtung['ID'], + $beobachtung['Wert'] + ); + } + } + } + + private function registriereAbonnement(string $seriennummer): void + { + $abonnements = $this->lesePufferArray('Subscriptions'); + if (!isset($abonnements[$seriennummer])) { + $abonnements[$seriennummer] = true; + $this->SetBuffer( + 'Subscriptions', + json_encode($abonnements, JSON_THROW_ON_ERROR) + ); + $this->SetValue('SubscriptionCount', count($abonnements)); + } + + if ($this->GetBuffer('SignalRReady') === '1') { + $this->sendeAbonnement($seriennummer); + } + } + + private function sendeAlleAbonnements(): void + { + foreach (array_keys($this->lesePufferArray('Subscriptions')) as $seriennummer) { + $this->sendeAbonnement((string) $seriennummer); + } + } + + private function sendeAbonnement(string $seriennummer): void + { + $gesendet = $this->lesePufferArray('SubscribedThisConnection'); + if (isset($gesendet[$seriennummer])) { + return; + } + + $aufrufID = (string) (((int) $this->GetBuffer('InvocationID')) + 1); + $this->SetBuffer('InvocationID', $aufrufID); + $offen = $this->lesePufferArray('PendingInvocations'); + $offen[$aufrufID] = $seriennummer; + $this->SetBuffer( + 'PendingInvocations', + json_encode($offen, JSON_THROW_ON_ERROR) + ); + + $this->sendeSignalRRahmen([ + 'type' => 1, + 'invocationId' => $aufrufID, + 'target' => 'SubscribeWithCurrentState', + 'arguments' => [$seriennummer, true], + ]); + + $gesendet[$seriennummer] = true; + $this->SetBuffer( + 'SubscribedThisConnection', + json_encode($gesendet, JSON_THROW_ON_ERROR) + ); + } + + private function veroeffentlicheBeobachtung( + string $seriennummer, + int $id, + $wert + ): void { + if ( + $seriennummer === '' + || !in_array($id, EaseeGatewayProtokoll::BEOBACHTUNGEN, true) + ) { + return; + } + + $cache = $this->leseAttributArray('ObservationCache'); + if (!isset($cache[$seriennummer]) || !is_array($cache[$seriennummer])) { + $cache[$seriennummer] = []; + } + if ($id === 109 && (int) $wert === 1) { + foreach ([110, 120, 182, 183, 184, 185] as $sessionID) { + $cache[$seriennummer][(string) $sessionID] = 0; + } + } + $cache[$seriennummer][(string) $id] = $wert; + $cache[$seriennummer]['updated'] = time(); + $this->WriteAttributeString( + 'ObservationCache', + json_encode($cache, JSON_THROW_ON_ERROR) + ); + + $this->SendDataToChildren(json_encode([ + 'DataID' => self::CHILD_EVENT_DATA_ID, + 'Buffer' => json_encode([ + 'type' => 'Observation', + 'serialNumber' => $seriennummer, + 'id' => $id, + 'value' => $wert, + 'timestamp' => time(), + ], JSON_THROW_ON_ERROR), + ], JSON_THROW_ON_ERROR)); + } + + private function erstelleStatusantwort(string $seriennummer): string + { + $cache = $this->leseAttributArray('ObservationCache'); + return json_encode([ + 'success' => true, + 'connected' => $this->GetBuffer('SignalRReady') === '1' + || $this->ReadPropertyBoolean('Testmodus'), + 'state' => $cache[$seriennummer] ?? [], + ], JSON_THROW_ON_ERROR); + } + + /** @return array */ + private function setzeDynamischenLadestrom(string $seriennummer, float $ampere): array + { + if ($seriennummer === '') { + return ['success' => false, 'error' => 'Seriennummer fehlt.']; + } + if ( + !is_finite($ampere) + || floor($ampere) !== $ampere + || $ampere < 0 + || $ampere > 32 + || ($ampere > 0 && $ampere < 6) + ) { + return [ + 'success' => false, + 'error' => 'Strom muss 0 A oder eine ganze Zahl zwischen 6 und 32 A sein.', + ]; + } + if ($this->ReadPropertyBoolean('Testmodus')) { + $this->SetBuffer('LetzterTestbefehl', json_encode([ + 'serialNumber' => $seriennummer, + 'amps' => $ampere, + ], JSON_THROW_ON_ERROR)); + return ['success' => true, 'httpCode' => 200, 'body' => '{}']; + } + + return $this->autorisierteApiAnfrage( + 'POST', + '/api/chargers/' . rawurlencode($seriennummer) + . '/commands/set_dynamic_charger_current', + json_encode([ + 'amps' => (int) round($ampere), + 'minutes' => 0, + ], JSON_THROW_ON_ERROR) + ); + } + + /** @return array */ + private function autorisierteApiAnfrage( + string $methode, + string $pfad, + string $inhalt + ): array { + if (!$this->stelleAccessTokenSicher()) { + return ['success' => false, 'error' => 'Kein Easee-Access-Token.']; + } + + $antwort = $this->httpAnfrage( + $methode, + self::API_BASE_URL . $pfad, + $inhalt, + $this->GetBuffer('AccessToken') + ); + if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) { + $antwort = $this->httpAnfrage( + $methode, + self::API_BASE_URL . $pfad, + $inhalt, + $this->GetBuffer('AccessToken') + ); + } + if (!$antwort['success']) { + return [ + 'success' => false, + 'error' => $antwort['error'] !== '' + ? $antwort['error'] + : 'Easee-HTTP-Fehler ' . $antwort['httpCode'] . '.', + 'httpCode' => $antwort['httpCode'], + ]; + } + + return [ + 'success' => true, + 'httpCode' => $antwort['httpCode'], + 'body' => $antwort['body'], + ]; + } + + private function erneuereZugangsdaten(bool $neuVerbinden = true): bool + { + $tokenpaar = null; + $refreshToken = $this->GetBuffer('RefreshToken'); + if ($refreshToken !== '') { + $tokenpaar = $this->fordereTokenpaarAn( + self::API_BASE_URL . '/api/accounts/refresh_token', + ['refreshToken' => $refreshToken] + ); + } + if ($tokenpaar === null) { + $tokenpaar = $this->fordereTokenpaarAn( + self::API_BASE_URL . '/api/accounts/login', + [ + 'userName' => $this->ReadPropertyString('Username'), + 'password' => $this->ReadPropertyString('Password'), + ] + ); + } + if ($tokenpaar === null || !isset($tokenpaar['accessToken'])) { + $this->setzeLetztenFehler('Easee-Anmeldung oder Token-Erneuerung fehlgeschlagen.'); + $this->SetStatus(202); + return false; + } + + $this->SetBuffer('AccessToken', (string) $tokenpaar['accessToken']); + if (isset($tokenpaar['refreshToken'])) { + $this->SetBuffer('RefreshToken', (string) $tokenpaar['refreshToken']); + } + if ($neuVerbinden) { + return $this->verbindeSignalR(); + } + + return true; + } + + private function stelleAccessTokenSicher(): bool + { + return $this->GetBuffer('AccessToken') !== '' + || $this->erneuereZugangsdaten(false); + } + + /** @param array $nutzdaten + * @return array|null + */ + private function fordereTokenpaarAn(string $url, array $nutzdaten): ?array + { + $antwort = $this->httpAnfrage( + 'POST', + $url, + json_encode($nutzdaten, JSON_THROW_ON_ERROR) + ); + if (!$antwort['success']) { + return null; + } + + $daten = json_decode($antwort['body'], true); + return is_array($daten) ? $daten : null; + } + + /** + * @return array{success: bool, body: string, error: string, httpCode: int} + */ + private function httpAnfrage( + string $methode, + string $url, + string $inhalt = '', + string $bearerToken = '' + ): array { + $kopf = ['Accept: application/json', 'Content-Type: application/json']; + if ($bearerToken !== '') { + $kopf[] = 'Authorization: Bearer ' . $bearerToken; + } + + $curl = curl_init($url); + if ($curl === false) { + return [ + 'success' => false, + 'body' => '', + 'error' => 'HTTP-Anfrage konnte nicht initialisiert werden.', + 'httpCode' => 0, + ]; + } + curl_setopt_array($curl, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => $methode, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 30, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_HTTPHEADER => $kopf, + CURLOPT_POSTFIELDS => $inhalt, + CURLOPT_SSL_VERIFYPEER => $this->ReadPropertyBoolean('VerifyCertificate'), + CURLOPT_SSL_VERIFYHOST => $this->ReadPropertyBoolean('VerifyCertificate') ? 2 : 0, + ]); + + $antwort = curl_exec($curl); + $fehler = curl_error($curl); + $httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + return [ + 'success' => $antwort !== false + && $fehler === '' + && $httpStatus >= 200 + && $httpStatus < 300, + 'body' => $antwort === false ? '' : (string) $antwort, + 'error' => $fehler, + 'httpCode' => $httpStatus, + ]; + } + + private function setzeVerbunden(bool $verbunden): void + { + $this->SetValue('Connected', $verbunden); + $this->SendDataToChildren(json_encode([ + 'DataID' => self::CHILD_EVENT_DATA_ID, + 'Buffer' => json_encode([ + 'type' => 'GatewayStatus', + 'connected' => $verbunden, + ], JSON_THROW_ON_ERROR), + ], JSON_THROW_ON_ERROR)); + } + + private function setzeLetztenFehler(string $nachricht): void + { + $this->SetValue('LastError', $nachricht); + } + + /** @return array */ + private function leseAttributArray(string $name): array + { + $wert = json_decode($this->ReadAttributeString($name), true); + return is_array($wert) ? $wert : []; + } + + /** @return array */ + private function lesePufferArray(string $name): array + { + $wert = json_decode($this->GetBuffer($name), true); + return is_array($wert) ? $wert : []; + } +} diff --git a/LadestationGateway/README.md b/LadestationGateway/README.md new file mode 100644 index 0000000..46c7bc8 --- /dev/null +++ b/LadestationGateway/README.md @@ -0,0 +1,11 @@ +# Ladestation Gateway + +Eventbasiertes Enelix-EMS-Verbrauchermodul fuer eine Easee-Ladestation. Das +Modul liest Fahrzeugstatus und aktive 1-/3-Phasenladung direkt aus Easee +Observations und sendet Stromvorgaben ueber das verbundene Easee Gateway. + +Konfiguration, Betriebsarten und Tests: +[Moduldokumentation](../docs/module/Ladestation-Gateway/README.md) + +Gateway-Vertrag: +[Schnittstellenbeschreibung](../docs/Schnittstelle-Easee-Gateway.md) diff --git a/LadestationGateway/form.json b/LadestationGateway/form.json new file mode 100644 index 0000000..69a4d42 --- /dev/null +++ b/LadestationGateway/form.json @@ -0,0 +1,116 @@ +{ + "elements": [ + { + "type": "Select", + "name": "Betriebsmodus", + "caption": "Variante", + "options": [ + { + "caption": "Easee", + "value": 0 + }, + { + "caption": "Easee - Nur Solarladen", + "value": 1 + } + ] + }, + { + "type": "ValidationTextBox", + "name": "Ladestationskennung", + "caption": "Easee Seriennummer" + }, + { + "type": "NumberSpinner", + "name": "MaximalerLadestrom", + "caption": "Maximaler Ladestrom", + "minimum": 6, + "maximum": 32, + "suffix": " A" + }, + { + "type": "CheckBox", + "name": "Ladefreigabe", + "caption": "Ladefreigabe beim Start" + }, + { + "type": "CheckBox", + "name": "Solarladen", + "caption": "Solarladen beim Start" + }, + { + "type": "NumberSpinner", + "name": "PrioritaetPV", + "caption": "Prioritaet PV", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "PrioritaetPeak", + "caption": "Prioritaet Peak", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "Meldeintervall", + "caption": "Meldeintervall", + "minimum": 1, + "suffix": " s" + }, + { + "type": "NumberSpinner", + "name": "VorgabeTimeout", + "caption": "Vorgabe-Timeout", + "minimum": 1, + "suffix": " s" + }, + { + "type": "CheckBox", + "name": "EinstellungenInVisu", + "caption": "Einstellungen in Visualisierung" + }, + { + "type": "CheckBox", + "name": "DiagnosevariablenAnzeigen", + "caption": "Diagnosevariablen anzeigen" + }, + { + "type": "CheckBox", + "name": "LoggingEin", + "caption": "Diagnoseprotokoll aktivieren" + }, + { + "type": "Label", + "caption": "Easee-Zugangsdaten werden ausschliesslich im verbundenen Easee Gateway gespeichert." + } + ], + "actions": [ + { + "type": "Button", + "caption": "Status neu anfordern", + "onClick": "IPS_RequestAction($id, \"StatusAnfordern\", false);" + } + ], + "status": [ + { + "code": 201, + "icon": "error", + "caption": "Konfiguration ungueltig" + }, + { + "code": 202, + "icon": "error", + "caption": "Easee Gateway nicht verbunden" + }, + { + "code": 203, + "icon": "inactive", + "caption": "Warte auf Easee-Status" + }, + { + "code": 204, + "icon": "error", + "caption": "Easee Ladestation meldet Fehler" + } + ] +} diff --git a/LadestationGateway/module.json b/LadestationGateway/module.json new file mode 100644 index 0000000..ae8b697 --- /dev/null +++ b/LadestationGateway/module.json @@ -0,0 +1,18 @@ +{ + "id": "{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}", + "name": "LadestationGateway", + "type": 3, + "vendor": "Enelix", + "aliases": [ + "Ladestation Gateway" + ], + "parentRequirements": [ + "{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}" + ], + "childRequirements": [], + "implemented": [ + "{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}" + ], + "prefix": "ENELIX", + "url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/LadestationGateway" +} diff --git a/LadestationGateway/module.php b/LadestationGateway/module.php new file mode 100644 index 0000000..d512d2a --- /dev/null +++ b/LadestationGateway/module.php @@ -0,0 +1,985 @@ + */ + private const DIAGNOSE_VARIABLEN = [ + 'Istleistung', + 'Leistungsquelle', + 'Sollleistung', + 'SollwertGueltig', + 'Verfuegbar', + 'AenderungMoeglich', + 'Stoerung', + 'Stoertext', + 'GatewayVerbunden', + 'ApiMaximalstrom', + 'LetzterGeraetebefehl', + 'LeistungsangebotDiagnose', + ]; + + public function Create(): void + { + parent::Create(); + + $this->registriereVerbraucherBasis(); + $this->RegisterPropertyInteger('Betriebsmodus', EaseeLadestatus::BETRIEBSMODUS_EASEE); + $this->RegisterPropertyString('Ladestationskennung', ''); + $this->RegisterPropertyInteger('MaximalerLadestrom', 16); + $this->RegisterPropertyBoolean('Ladefreigabe', false); + $this->RegisterPropertyBoolean('Solarladen', true); + $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); + + $this->RegisterVariableBoolean( + 'FahrzeugVerbunden', + 'Fahrzeug verbunden', + '~Switch', + 100 + ); + $this->RegisterVariableBoolean( + 'FahrzeugGeladen', + 'Fahrzeug geladen', + '~Switch', + 110 + ); + $this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 120); + $this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 130); + $this->RegisterVariableInteger('Phasenzahl', 'Phasenzahl', '', 140); + + $this->RegisterAttributeBoolean('InitialwerteGesetzt', false); + $this->RegisterAttributeBoolean('KonfigurationLadefreigabe', false); + $this->RegisterAttributeBoolean('KonfigurationSolarladen', true); + $this->RegisterAttributeBoolean('ZustandLadefreigabe', false); + $this->RegisterAttributeBoolean('ZustandSolarladen', true); + $this->RegisterAttributeBoolean('GatewayVerbunden', false); + $this->RegisterAttributeString('EaseeBeobachtungen', '{}'); + $this->RegisterAttributeInteger('LetzteBeobachtung', 0); + $this->RegisterAttributeInteger('ApiMaximalstrom', 16); + $this->RegisterAttributeBoolean('ZustandFahrzeugVerbunden', false); + $this->RegisterAttributeBoolean('ZustandFahrzeugGeladen', false); + $this->RegisterAttributeInteger('ZustandFahrzeugstatus', 0); + $this->RegisterAttributeFloat('ZustandLadestrom', 0.0); + $this->RegisterAttributeInteger('ZustandPhasenzahl', 0); + $this->RegisterAttributeFloat('ZustandIstleistung', 0.0); + $this->RegisterAttributeInteger('ZustandSollleistung', 0); + $this->RegisterAttributeBoolean('ZustandSollwertGueltig', false); + $this->RegisterAttributeBoolean('ZustandVerfuegbar', false); + $this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false); + $this->RegisterAttributeBoolean('ZustandStoerung', false); + $this->RegisterAttributeString('ZustandStoertext', ''); + $this->RegisterAttributeString('Leistungsangebot', '[0]'); + $this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV); + $this->RegisterAttributeInteger('LetzteManagerID', 0); + $this->RegisterAttributeInteger('LetzteVorgabeZeit', 0); + $this->RegisterAttributeInteger('LetzterGesetzterStrom', -1); + $this->RegisterAttributeString('LetzterGeraetebefehl', ''); + + $this->RegisterTimer( + 'Meldezyklus', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);" + ); + $this->RegisterTimer( + 'RueckmeldungVerzoegert', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);" + ); + $this->RegisterTimer( + 'VorgabeTimeout', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', false);" + ); + + $this->ConnectParent(self::EASEE_GATEWAY_MODULE_ID); + } + + public function ApplyChanges(): void + { + parent::ApplyChanges(); + + $this->initialisiereLokaleEinstellungen(); + $this->aktualisiereVariablen(); + $this->WriteAttributeBoolean('GatewayVerbunden', false); + $this->WriteAttributeInteger('LetzterGesetzterStrom', -1); + + try { + $this->pruefeKonfiguration(); + } catch (Throwable $fehler) { + $this->deaktiviereTimer(); + $this->setzeZustand('Verfuegbar', false); + $this->setzeZustand('AenderungMoeglich', false); + $this->setzeStoerung($fehler->getMessage()); + $this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG); + $this->protokolliere('Konfiguration', $fehler->getMessage()); + return; + } + + $this->SetTimerInterval( + 'Meldezyklus', + $this->ReadPropertyInteger('Meldeintervall') * 1000 + ); + $this->statusAnfordern(); + $this->aktualisiere(true); + } + + public function RequestAction($ident, $wert): void + { + switch ($ident) { + case 'Aktiv': + $this->SetValue('Aktiv', (bool) $wert); + if (!(bool) $wert) { + $this->setzeZustand('SollwertGueltig', false); + } + $this->aktualisiere(true); + return; + + case 'Ladefreigabe': + $this->WriteAttributeBoolean('ZustandLadefreigabe', (bool) $wert); + $this->setzeSichtbareVariable('Ladefreigabe', (bool) $wert); + $this->setzeZustand('SollwertGueltig', false); + $this->aktualisiere(true); + return; + + case 'Solarladen': + $solarladen = EaseeLadestatus::solarladenErzwungen( + $this->ReadPropertyInteger('Betriebsmodus') + ) ? true : (bool) $wert; + $this->WriteAttributeBoolean('ZustandSolarladen', $solarladen); + $this->setzeSichtbareVariable('Solarladen', $solarladen); + $this->setzeZustand('SollwertGueltig', false); + $this->aktualisiere(true); + return; + + case 'StatusAnfordern': + $this->statusAnfordern(); + return; + + case 'VorgabeTimeout': + $this->SetTimerInterval('VorgabeTimeout', 0); + $this->setzeZustand('SollwertGueltig', false); + $this->aktualisiere(true); + return; + + case 'Melden': + if ((bool) $wert) { + $this->SetTimerInterval('RueckmeldungVerzoegert', 0); + } + $this->sendeVerbraucherdaten(); + return; + + case 'ManagerdatenEmpfangen': + if (!is_string($wert)) { + throw new InvalidArgumentException( + 'Managerdaten muessen als JSON uebergeben werden.' + ); + } + $daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($daten)) { + throw new InvalidArgumentException( + 'Managerdaten muessen ein JSON-Objekt sein.' + ); + } + $this->ManagerdatenEmpfangen($daten); + return; + } + + throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident); + } + + public function ReceiveData($jsonString): void + { + $paket = json_decode((string) $jsonString, true); + if (!is_array($paket) || !isset($paket['Buffer'])) { + return; + } + $ereignis = json_decode((string) $paket['Buffer'], true); + if (!is_array($ereignis) || !isset($ereignis['type'])) { + return; + } + + if ($ereignis['type'] === 'GatewayStatus') { + $verbunden = (bool) ($ereignis['connected'] ?? false); + $this->WriteAttributeBoolean('GatewayVerbunden', $verbunden); + $this->setzeSichtbareVariable('GatewayVerbunden', $verbunden); + $this->WriteAttributeInteger('LetzterGesetzterStrom', -1); + if ($verbunden) { + $this->statusAnfordern(); + } else { + $this->verwerfeBeobachtungen(); + $this->aktualisiere(true); + } + return; + } + + $seriennummer = EaseeGatewayProtokoll::seriennummer( + (string) ($ereignis['serialNumber'] ?? '') + ); + if ( + $ereignis['type'] !== 'Observation' + || $seriennummer !== $this->seriennummer() + ) { + return; + } + + $this->speichereBeobachtung( + (int) ($ereignis['id'] ?? 0), + $ereignis['value'] ?? null, + (int) ($ereignis['timestamp'] ?? time()) + ); + $this->aktualisiere(true); + } + + /** @param array $daten */ + public function ManagerdatenEmpfangen(array $daten): void + { + Nachrichtenvertrag::pruefeManagerdaten($daten); + if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) { + throw new InvalidArgumentException( + 'Managerdaten sind an eine andere Instanz adressiert.' + ); + } + + $managerID = $daten['Kopf']['AbsenderID']; + if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) { + throw new InvalidArgumentException( + 'Der Manager hat diese Ladestation nicht aktiv zugeordnet.' + ); + } + + $this->WriteAttributeString('Betriebsart', $daten['Betriebsart']); + $this->aktualisiere(false); + $sollleistung = $daten['Sollleistung_W']; + if ($sollleistung === null) { + if ( + (bool) $this->leseZustand('SollwertGueltig') + && !in_array( + (int) $this->leseZustand('Sollleistung'), + $this->leseLeistungsangebot(), + true + ) + ) { + $this->setzeZustand('SollwertGueltig', false); + $this->aktualisiere(false); + } + $this->SetTimerInterval('RueckmeldungVerzoegert', 100); + return; + } + if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) { + throw new InvalidArgumentException( + 'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.' + ); + } + + $this->WriteAttributeInteger('LetzteManagerID', $managerID); + $this->WriteAttributeInteger('LetzteVorgabeZeit', time()); + $this->setzeZustand('Sollleistung', $sollleistung); + $this->setzeZustand('SollwertGueltig', true); + $this->SetTimerInterval( + 'VorgabeTimeout', + $this->ReadPropertyInteger('VorgabeTimeout') * 1000 + ); + $this->aktualisiere(false); + $this->SetTimerInterval('RueckmeldungVerzoegert', 100); + } + + private function statusAnfordern(): void + { + try { + $antwort = $this->gatewayAnfrage([ + 'action' => 'Subscribe', + 'serialNumber' => $this->seriennummer(), + ]); + if (!($antwort['success'] ?? false)) { + throw new RuntimeException( + (string) ($antwort['error'] ?? 'Easee Gateway nicht erreichbar.') + ); + } + $this->WriteAttributeBoolean( + 'GatewayVerbunden', + (bool) ($antwort['connected'] ?? false) + ); + $this->setzeSichtbareVariable( + 'GatewayVerbunden', + $this->ReadAttributeBoolean('GatewayVerbunden') + ); + $this->verwerfeBeobachtungen(); + if (is_array($antwort['state'] ?? null)) { + $zustand = $antwort['state']; + $zeitpunkt = (int) ($zustand['updated'] ?? time()); + foreach (EaseeGatewayProtokoll::BEOBACHTUNGEN as $id) { + if (array_key_exists((string) $id, $zustand)) { + $this->speichereBeobachtung( + $id, + $zustand[(string) $id], + $zeitpunkt + ); + } + } + } + $this->aktualisiere(true); + } catch (Throwable $fehler) { + $this->WriteAttributeBoolean('GatewayVerbunden', false); + $this->aktualisiere(true); + $this->protokolliere('Gateway', $fehler->getMessage()); + } + } + + /** @param array $anfrage + * @return array + */ + private function gatewayAnfrage(array $anfrage): array + { + $antwort = $this->SendDataToParent(json_encode([ + 'DataID' => self::GATEWAY_REQUEST_DATA_ID, + 'Buffer' => json_encode($anfrage, JSON_THROW_ON_ERROR), + ], JSON_THROW_ON_ERROR)); + + if (!is_string($antwort) || $antwort === '') { + throw new RuntimeException('Easee Gateway liefert keine Antwort.'); + } + $daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($daten)) { + throw new RuntimeException('Easee Gateway liefert ungueltige Daten.'); + } + + return $daten; + } + + private function speichereBeobachtung(int $id, $wert, int $zeitpunkt): void + { + if (!in_array($id, EaseeGatewayProtokoll::BEOBACHTUNGEN, true)) { + return; + } + $beobachtungen = $this->leseBeobachtungen(); + if ($id === 109 && (int) $wert === 1) { + foreach ([110, 120, 182, 183, 184, 185] as $sessionID) { + $beobachtungen[(string) $sessionID] = 0; + } + } + $beobachtungen[(string) $id] = $wert; + $this->WriteAttributeString( + 'EaseeBeobachtungen', + json_encode($beobachtungen, JSON_THROW_ON_ERROR) + ); + $this->WriteAttributeInteger('LetzteBeobachtung', $zeitpunkt); + } + + private function aktualisiere(bool $meldungPlanen): void + { + try { + $status = EaseeLadestatus::ausBeobachtungen( + $this->leseBeobachtungen(), + $this->ReadPropertyInteger('MaximalerLadestrom') + ); + foreach ([ + 'FahrzeugVerbunden', + 'FahrzeugGeladen', + 'Fahrzeugstatus', + 'Phasenzahl', + 'Istleistung_W', + 'Ladestrom_A', + ] as $feld) { + $this->setzeZustand($feld, $status[$feld]); + } + $this->WriteAttributeInteger( + 'ApiMaximalstrom', + $status['ApiMaximalstrom_A'] + ); + $this->setzeSichtbareVariable( + 'ApiMaximalstrom', + $status['ApiMaximalstrom_A'] + ); + + $gatewayVerbunden = $this->ReadAttributeBoolean('GatewayVerbunden'); + $phaseGueltig = in_array($status['Phasenzahl'], [1, 3], true); + $steuerbar = $gatewayVerbunden + && $status['StatusGueltig'] + && !$status['Stoerung'] + && (!$status['FahrzeugVerbunden'] || $phaseGueltig); + + $solarladen = $this->ReadAttributeBoolean('ZustandSolarladen'); + $peakbetrieb = $this->ReadAttributeString('Betriebsart') + === Nachrichtenvertrag::BETRIEBSART_PEAK; + $angebot = LadestationRegler::leistungsangebot( + (bool) $this->GetValue('Aktiv'), + $this->ReadAttributeBoolean('ZustandLadefreigabe'), + $steuerbar && $status['FahrzeugVerbunden'], + $status['FahrzeugGeladen'], + $phaseGueltig ? $status['Phasenzahl'] : 1, + $status['ApiMaximalstrom_A'], + $solarladen, + $peakbetrieb + ); + $verfuegbar = $steuerbar + && $status['FahrzeugVerbunden'] + && !$status['FahrzeugGeladen'] + && $angebot !== [0]; + $angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR); + $this->WriteAttributeString('Leistungsangebot', $angebotJson); + $this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson); + $this->setzeZustand('Verfuegbar', $verfuegbar); + $this->setzeZustand( + 'AenderungMoeglich', + $verfuegbar && count($angebot) > 1 + ); + + $stoertext = ''; + if (!$gatewayVerbunden) { + $stoertext = 'Easee Gateway ist nicht verbunden.'; + $this->SetStatus(self::STATUS_GATEWAY_GETRENNT); + } elseif (!$status['StatusGueltig']) { + $stoertext = 'Warte auf den aktuellen Easee-Ladestatus.'; + $this->SetStatus(self::STATUS_WARTET_AUF_DATEN); + } elseif ($status['Stoerung']) { + $stoertext = $status['Stoertext']; + $this->SetStatus(self::STATUS_LADEFEHLER); + } elseif ($status['FahrzeugVerbunden'] && !$phaseGueltig) { + $stoertext = 'Easee meldet keine unterstuetzte aktive Ausgangsphase.'; + $this->SetStatus(self::STATUS_WARTET_AUF_DATEN); + } else { + $this->SetStatus(self::STATUS_AKTIV); + } + $this->setzeStoerung($stoertext); + + if ($gatewayVerbunden && $status['StatusGueltig']) { + $this->setzeLadestrom( + $this->bestimmeWirksameSollleistung($angebot), + $status['Phasenzahl'] + ); + } else { + $this->WriteAttributeInteger('LetzterGesetzterStrom', -1); + } + } catch (Throwable $fehler) { + $this->setzeZustand('Verfuegbar', false); + $this->setzeZustand('AenderungMoeglich', false); + $this->setzeStoerung($fehler->getMessage()); + $this->SetStatus(self::STATUS_GATEWAY_GETRENNT); + $this->protokolliere('Easee', $fehler->getMessage()); + } + + if ($meldungPlanen) { + $this->SetTimerInterval('RueckmeldungVerzoegert', 100); + } + } + + /** @param list $angebot */ + private function bestimmeWirksameSollleistung(array $angebot): int + { + if ((bool) $this->leseZustand('SollwertGueltig')) { + $sollleistung = (int) $this->leseZustand('Sollleistung'); + if (in_array($sollleistung, $angebot, true)) { + return $sollleistung; + } + $this->setzeZustand('SollwertGueltig', false); + } + + if ( + !$this->ReadAttributeBoolean('ZustandSolarladen') + && $angebot !== [0] + ) { + return $angebot[count($angebot) - 1]; + } + + return 0; + } + + private function setzeLadestrom(int $leistung, int $phasenzahl): void + { + $strom = $leistung === 0 + ? 0 + : LadestationRegler::stromFuerLeistung($leistung, $phasenzahl); + if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) { + return; + } + + $antwort = $this->gatewayAnfrage([ + 'action' => 'SetDynamicChargerCurrent', + 'serialNumber' => $this->seriennummer(), + 'amps' => $strom, + ]); + if (!($antwort['success'] ?? false)) { + throw new RuntimeException( + (string) ($antwort['error'] ?? 'Easee-Stromvorgabe fehlgeschlagen.') + ); + } + + $befehl = json_encode([ + 'Aktion' => 'SetDynamicChargerCurrent', + 'Seriennummer' => $this->seriennummer(), + 'Ampere' => $strom, + ], JSON_THROW_ON_ERROR); + $this->WriteAttributeString('LetzterGeraetebefehl', $befehl); + $this->setzeSichtbareVariable('LetzterGeraetebefehl', $befehl); + $this->WriteAttributeInteger('LetzterGesetzterStrom', $strom); + } + + private function seriennummer(): string + { + return EaseeGatewayProtokoll::seriennummer( + $this->ReadPropertyString('Ladestationskennung') + ); + } + + private function verwerfeBeobachtungen(): void + { + $this->WriteAttributeString('EaseeBeobachtungen', '{}'); + $this->WriteAttributeInteger('LetzteBeobachtung', 0); + } + + /** @return array */ + private function leseBeobachtungen(): array + { + $daten = json_decode($this->ReadAttributeString('EaseeBeobachtungen'), true); + return is_array($daten) ? $daten : []; + } + + private function sendeVerbraucherdaten(): void + { + foreach ($this->zugeordneteManagerIDs() as $managerID) { + $daten = $this->baueVerbraucherdaten($managerID); + Nachrichtenvertrag::pruefeVerbraucherdaten($daten); + try { + IPS_RequestAction( + $managerID, + 'VerbraucherdatenEmpfangen', + json_encode($daten, JSON_THROW_ON_ERROR) + ); + } catch (Throwable $fehler) { + $this->protokolliere('Managerkommunikation', $fehler->getMessage()); + } + } + } + + /** @return array */ + private function baueVerbraucherdaten(int $managerID): array + { + $stoerung = (bool) $this->leseZustand('Stoerung'); + + return [ + 'Kopf' => [ + 'Version' => Nachrichtenvertrag::VERSION, + 'AbsenderID' => $this->InstanceID, + 'EmpfaengerID' => $managerID, + 'Zeitpunkt' => time(), + ], + 'Betriebsart' => $this->ReadAttributeString('Betriebsart'), + 'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'), + 'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'), + 'Leistungswerte_W' => $this->leseLeistungsangebot(), + 'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'), + 'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'), + 'Istleistung_W' => (float) $this->leseZustand('Istleistung'), + 'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN, + 'Zustand' => [ + [ + 'Kennung' => 'Sollleistung_W', + 'Art' => 'Sollwert', + 'Wert' => (bool) $this->leseZustand('SollwertGueltig') + ? (int) $this->leseZustand('Sollleistung') + : null, + 'Einheit' => 'W', + ], + [ + 'Kennung' => 'FahrzeugVerbunden', + 'Art' => 'Status', + 'Wert' => (bool) $this->leseZustand('FahrzeugVerbunden'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'FahrzeugGeladen', + 'Art' => 'Status', + 'Wert' => (bool) $this->leseZustand('FahrzeugGeladen'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Fahrzeugstatus', + 'Art' => 'Status', + 'Wert' => (int) $this->leseZustand('Fahrzeugstatus'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Phasenzahl', + 'Art' => 'Status', + 'Wert' => (int) $this->leseZustand('Phasenzahl'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Ladestrom_A', + 'Art' => 'Istwert', + 'Wert' => (float) $this->leseZustand('Ladestrom'), + 'Einheit' => 'A', + ], + [ + 'Kennung' => 'Ladefreigabe', + 'Art' => 'Status', + 'Wert' => $this->ReadAttributeBoolean('ZustandLadefreigabe'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Solarladen', + 'Art' => 'Status', + 'Wert' => $this->ReadAttributeBoolean('ZustandSolarladen'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Ladefehler', + 'Art' => 'Stoerung', + 'Wert' => $stoerung, + 'Einheit' => '', + 'Text' => (string) $this->leseZustand('Stoertext'), + ], + ], + ]; + } + + /** @return list */ + private function zugeordneteManagerIDs(): array + { + $ergebnis = []; + foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) { + try { + $automatisch = (bool) IPS_GetProperty( + $managerID, + 'AutomatischeSuche' + ); + $property = $automatisch + ? 'AutomatischeVerbraucherZuordnung' + : 'VerbraucherZuordnung'; + $zuordnung = $this->leseManagerZuordnung($managerID, $property); + if ($automatisch && $zuordnung === []) { + $zuordnung = $this->leseManagerZuordnung( + $managerID, + 'VerbraucherZuordnung' + ); + } + } catch (Throwable $fehler) { + continue; + } + + foreach ($zuordnung as $eintrag) { + if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) { + continue; + } + $instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null; + if ($instanzID === $this->InstanceID) { + $ergebnis[] = (int) $managerID; + break; + } + } + } + + return array_values(array_unique($ergebnis)); + } + + /** @return array */ + private function leseManagerZuordnung(int $managerID, string $property): array + { + $zuordnung = json_decode( + (string) IPS_GetProperty($managerID, $property), + true, + 512, + JSON_THROW_ON_ERROR + ); + + return is_array($zuordnung) ? $zuordnung : []; + } + + private function pruefeKonfiguration(): void + { + foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) { + if ($this->ReadPropertyInteger($property) < 0) { + throw new InvalidArgumentException( + $property . ' muss mindestens 0 sein.' + ); + } + } + foreach (['Meldeintervall', 'VorgabeTimeout'] as $property) { + if ($this->ReadPropertyInteger($property) <= 0) { + throw new InvalidArgumentException( + $property . ' muss groesser als 0 sein.' + ); + } + } + $maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom'); + if ($maximalstrom < 6 || $maximalstrom > 32) { + throw new InvalidArgumentException( + 'MaximalerLadestrom muss zwischen 6 und 32 A liegen.' + ); + } + EaseeLadestatus::solarladenErzwungen( + $this->ReadPropertyInteger('Betriebsmodus') + ); + if ($this->seriennummer() === '') { + throw new InvalidArgumentException('Easee Seriennummer fehlt.'); + } + } + + private function initialisiereLokaleEinstellungen(): void + { + $initialisiert = $this->ReadAttributeBoolean('InitialwerteGesetzt'); + $ladefreigabe = $this->ReadPropertyBoolean('Ladefreigabe'); + $solarladen = EaseeLadestatus::solarladenErzwungen( + $this->ReadPropertyInteger('Betriebsmodus') + ) ? true : $this->ReadPropertyBoolean('Solarladen'); + + if ( + !$initialisiert + || $ladefreigabe + !== $this->ReadAttributeBoolean('KonfigurationLadefreigabe') + ) { + $this->WriteAttributeBoolean('ZustandLadefreigabe', $ladefreigabe); + } + if ( + !$initialisiert + || $solarladen + !== $this->ReadAttributeBoolean('KonfigurationSolarladen') + ) { + $this->WriteAttributeBoolean('ZustandSolarladen', $solarladen); + } + + $this->WriteAttributeBoolean('KonfigurationLadefreigabe', $ladefreigabe); + $this->WriteAttributeBoolean('KonfigurationSolarladen', $solarladen); + $this->WriteAttributeBoolean('InitialwerteGesetzt', true); + } + + private function aktualisiereVariablen(): void + { + if ($this->ReadPropertyBoolean('EinstellungenInVisu')) { + $this->RegisterVariableBoolean( + 'Ladefreigabe', + 'Ladefreigabe', + '~Switch', + 150 + ); + $this->RegisterVariableBoolean( + 'Solarladen', + 'Solarladen', + '~Switch', + 160 + ); + $this->EnableAction('Ladefreigabe'); + $this->EnableAction('Solarladen'); + $this->SetValue( + 'Ladefreigabe', + $this->ReadAttributeBoolean('ZustandLadefreigabe') + ); + $this->SetValue( + 'Solarladen', + $this->ReadAttributeBoolean('ZustandSolarladen') + ); + } else { + $this->entferneVariable('Ladefreigabe'); + $this->entferneVariable('Solarladen'); + } + + if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) { + $this->registriereVerbraucherDiagnose(); + $this->RegisterVariableBoolean( + 'GatewayVerbunden', + 'Easee Gateway verbunden', + '~Switch', + 200 + ); + $this->RegisterVariableInteger( + 'ApiMaximalstrom', + 'API-Maximalstrom', + '', + 210 + ); + $this->RegisterVariableString( + 'LetzterGeraetebefehl', + 'Letzter Geraetebefehl', + '', + 220 + ); + $this->RegisterVariableString( + 'LeistungsangebotDiagnose', + 'Leistungsangebot', + '', + 230 + ); + $this->SetValue( + 'LeistungsangebotDiagnose', + $this->ReadAttributeString('Leistungsangebot') + ); + $this->SetValue( + 'LetzterGeraetebefehl', + $this->ReadAttributeString('LetzterGeraetebefehl') + ); + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + if (!in_array($ident, [ + 'GatewayVerbunden', + 'ApiMaximalstrom', + 'LetzterGeraetebefehl', + 'LeistungsangebotDiagnose', + ], true)) { + $this->setzeSichtbareVariable($ident, $this->leseZustand($ident)); + } + } + $this->setzeSichtbareVariable( + 'GatewayVerbunden', + $this->ReadAttributeBoolean('GatewayVerbunden') + ); + $this->setzeSichtbareVariable( + 'ApiMaximalstrom', + $this->ReadAttributeInteger('ApiMaximalstrom') + ); + } else { + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + $this->entferneVariable($ident); + } + } + } + + private function entferneVariable(string $ident): void + { + $id = @$this->GetIDForIdent($ident); + if (is_int($id) && $id > 0 && IPS_VariableExists($id)) { + $this->UnregisterVariable($ident); + } + } + + /** @param mixed $wert */ + private function setzeZustand(string $ident, $wert): void + { + switch ($ident) { + case 'FahrzeugVerbunden': + case 'FahrzeugGeladen': + case 'SollwertGueltig': + case 'Verfuegbar': + case 'AenderungMoeglich': + case 'Stoerung': + $this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert); + break; + case 'Fahrzeugstatus': + case 'Phasenzahl': + case 'Sollleistung': + $this->WriteAttributeInteger('Zustand' . $ident, (int) $wert); + break; + case 'Ladestrom_A': + $ident = 'Ladestrom'; + $this->WriteAttributeFloat('ZustandLadestrom', (float) $wert); + break; + case 'Istleistung_W': + $ident = 'Istleistung'; + $this->WriteAttributeFloat('ZustandIstleistung', (float) $wert); + break; + case 'Stoertext': + $this->WriteAttributeString('ZustandStoertext', (string) $wert); + break; + default: + throw new LogicException('Unbekannter Zustand: ' . $ident); + } + $this->setzeSichtbareVariable($ident, $wert); + if ($ident === 'Istleistung') { + $this->setzeSichtbareVariable( + 'Leistungsquelle', + Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN + ); + } + } + + /** @return mixed */ + private function leseZustand(string $ident) + { + if ($ident === 'Ladefreigabe' || $ident === 'Solarladen') { + return $this->ReadAttributeBoolean('Zustand' . $ident); + } + if (in_array($ident, [ + 'FahrzeugVerbunden', + 'FahrzeugGeladen', + 'SollwertGueltig', + 'Verfuegbar', + 'AenderungMoeglich', + 'Stoerung', + ], true)) { + return $this->ReadAttributeBoolean('Zustand' . $ident); + } + if (in_array($ident, [ + 'Fahrzeugstatus', + 'Phasenzahl', + 'Sollleistung', + ], true)) { + return $this->ReadAttributeInteger('Zustand' . $ident); + } + if ($ident === 'Ladestrom' || $ident === 'Istleistung') { + return $this->ReadAttributeFloat('Zustand' . $ident); + } + if ($ident === 'Stoertext') { + return $this->ReadAttributeString('ZustandStoertext'); + } + if ($ident === 'Leistungsquelle') { + return Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN; + } + throw new LogicException('Unbekannter Zustand: ' . $ident); + } + + /** @param mixed $wert */ + private function setzeSichtbareVariable(string $ident, $wert): void + { + $id = @$this->GetIDForIdent($ident); + if (is_int($id) && $id > 0 && IPS_VariableExists($id)) { + $this->SetValue($ident, $wert); + } + } + + /** @return list */ + private function leseLeistungsangebot(): array + { + $angebot = json_decode( + $this->ReadAttributeString('Leistungsangebot'), + true, + 512, + JSON_THROW_ON_ERROR + ); + + return is_array($angebot) ? array_map('intval', $angebot) : [0]; + } + + private function setzeStoerung(string $text): void + { + $this->setzeZustand('Stoerung', $text !== ''); + $this->setzeZustand('Stoertext', $text); + } + + private function deaktiviereTimer(): void + { + foreach (['Meldezyklus', 'RueckmeldungVerzoegert', 'VorgabeTimeout'] as $timer) { + $this->SetTimerInterval($timer, 0); + } + } + + private function protokolliere(string $bereich, string $nachricht): void + { + if ($this->ReadPropertyBoolean('LoggingEin')) { + $this->SendDebug($bereich, $nachricht, 0); + } + } +} diff --git a/Manager/module.php b/Manager/module.php index de0206a..6bf74c0 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -25,6 +25,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle '{B7C54AF4-AD7D-4FE4-B75D-203693906251}', '{15879A4E-D0C2-4495-83DE-46E1E462591E}', '{0D94913C-0F31-4C29-A685-6EB4AE58E55D}', + '{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}', '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}', ]; private const MONATSNAMEN = [ diff --git a/docs/Schnittstelle-Easee-Gateway.md b/docs/Schnittstelle-Easee-Gateway.md new file mode 100644 index 0000000..719e521 --- /dev/null +++ b/docs/Schnittstelle-Easee-Gateway.md @@ -0,0 +1,103 @@ +# Easee-Gateway-Schnittstelle + +Stand: 2026-09-22 + +Die Schnittstelle verbindet das kontobezogene Splittermodul `EaseeGateway` +mit beliebig vielen Kindinstanzen `LadestationGateway`. + +## IP-Symcon-Daten-IDs + +| Richtung | DataID | +| --- | --- | +| Ladestation an Gateway | `{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}` | +| Gateway an Ladestation | `{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}` | + +Die aeussere IP-Symcon-Nachricht enthaelt `DataID` und `Buffer`. `Buffer` +ist wiederum ein JSON-Objekt. + +## Anfragen an das Gateway + +### Subscribe und GetState + +```json +{ + "action": "Subscribe", + "serialNumber": "EH123456" +} +``` + +`GetState` besitzt dasselbe Format. Beide Aktionen registrieren die +Seriennummer und liefern den Cache: + +```json +{ + "success": true, + "connected": true, + "state": { + "109": 3, + "110": 30, + "120": 11.0, + "updated": 1790053200 + } +} +``` + +### SetDynamicChargerCurrent + +```json +{ + "action": "SetDynamicChargerCurrent", + "serialNumber": "EH123456", + "amps": 13 +} +``` + +Zulaessig sind `0 A` oder ganzzahlige Werte von `6 A` bis `32 A`. +Das Gateway ruft +`POST /api/chargers/{serialNumber}/commands/set_dynamic_charger_current` +mit `{"amps":13,"minutes":0}` auf. + +## Ereignisse an Kindinstanzen + +### Observation + +```json +{ + "type": "Observation", + "serialNumber": "EH123456", + "id": 110, + "value": 30, + "timestamp": 1790053200 +} +``` + +Verteilt werden die IDs `47`, `48`, `100`, `104`, `109`, `110`, +`119`, `120`, `182` bis `185` und `250`. Jede Kindinstanz verwirft +Ereignisse anderer Seriennummern. + +### GatewayStatus + +```json +{ + "type": "GatewayStatus", + "connected": false +} +``` + +Bei `false` setzt die Ladestation Verfuegbarkeit und Aenderbarkeit sofort +zurueck. Bei `true` fordert sie den aktuellen Zustand erneut an. + +## Zustandsabbildung + +Observation `109` wird gemaess Easee OpMode abgebildet: `0` offline, +`1` getrennt, `2/6/7/8` bereit, `3` laedt, `4` geladen und `5` Fehler. +Unbekannte Werte geben die Regelung nicht frei. Observation `110` liefert die +aktive Ausgangsphase: `10..15` einphasig und `30` dreiphasig. Es gibt keine +leistungsbasierte Phasenschaetzung. + +## Fehlervertrag + +Gateway-Antworten enthalten immer `success`. Bei `false` folgt ein +menschenlesbares Feld `error`; optional wird `httpCode` ergaenzt. +Zugangsdaten, Access Token und Refresh Token duerfen weder in Antworten noch +in Ereignissen oder Diagnosevariablen vorkommen. diff --git a/docs/Schnittstelle.md b/docs/Schnittstelle.md index ab61a67..4f1ae4b 100644 --- a/docs/Schnittstelle.md +++ b/docs/Schnittstelle.md @@ -132,6 +132,14 @@ Symcon-Datenpunkte registriert `VerbraucherBasisTrait`. `Leistungswerte_W`, `Betriebsart` und `Zustand` werden intern gehalten und direkt in die Nachricht geschrieben. +## Easee-Gateway-Transport + +Der technische JSON-Vertrag zwischen `EaseeGateway` und +`LadestationGateway` ist getrennt vom fachlichen Managervertrag dokumentiert: +[Easee-Gateway-Schnittstelle](Schnittstelle-Easee-Gateway.md). Die +Ladestation uebersetzt Gateway-Ereignisse in den hier beschriebenen +Verbrauchervertrag `4.0`. + ## Zeitverhalten - Rueckmeldung nach Start, relevanten Aenderungen und alle `Meldeintervall` diff --git a/docs/module/Easee-Gateway/README.md b/docs/module/Easee-Gateway/README.md index 2768779..77e2486 100644 --- a/docs/module/Easee-Gateway/README.md +++ b/docs/module/Easee-Gateway/README.md @@ -1,35 +1,77 @@ # Easee Gateway -> Status: Diskussionsentwurf. Kommunikationsmodul, kein Verbraucher und keine -> Verwendung der Verbraucherbasis. +> Status: implementiert fuer IP-Symcon 8 und die Easee Cloud API. -Das bestehende Gateway wird übernommen. Anzeigen werden deutsch; technische -Alt-Idents bleiben zur Kompatibilität erhalten. Eine passende vorhandene -Verbindung soll bei der Instanziierung wiederverwendet werden. +Das Modul stellt pro Easee-Nutzerkonto genau eine gemeinsame Verbindung bereit. +Benutzername, Passwort, Access Token und Refresh Token verbleiben im Gateway. +Mehrere Instanzen von **Ladestation Gateway** koennen denselben Elternknoten +verwenden. -## Variablen +## Funktionsumfang -| Technischer Ident / Anzeige | Typ / Zugriff | Beschreibung | -| --- | --- | --- | -| `Connected` / Verbunden | Boolean / Anzeige | SignalR-Verbindungsstatus. | -| `SubscriptionCount` / Angemeldete Ladestationen | Integer / Anzeige | Anzahl registrierter Geräte. | -| `LastError` / Letzter Fehler | String / Anzeige | Diagnose ohne Zugangsdaten. | +- Anmeldung mit Easee-Benutzerkonto und automatische Token-Erneuerung, +- gemeinsame Ereignisverbindung zu `streams.easee.com`, +- Abonnement mehrerer Ladestationen mit aktuellem Zustand, +- Verteilung der Easee-Observations an die passenden Kindinstanzen, +- Stromvorgabe ueber `set_dynamic_charger_current`, +- Wiederverbindung und erneute Anmeldung aller Stationen nach Unterbrechungen, +- TLS-Zertifikatspruefung standardmaessig aktiv. ## Properties -| Technischer Ident / Anzeige | Typ | Standard / Beschreibung | +| Ident | Typ | Standard | Beschreibung | +| --- | --- | --- | --- | +| `Active` | Boolean | `true` | Aktiviert Anmeldung und Ereignisverbindung. | +| `Username` | String | leer | E-Mail-Adresse oder Telefonnummer des Easee-Kontos. | +| `Password` | Passwort | leer | Passwort, nur im Gateway gespeichert. | +| `VerifyCertificate` | Boolean | `true` | Prueft TLS-Zertifikate fuer REST und WebSocket. | + +Die interne Property `Testmodus` ist nicht im Formular sichtbar und wird nur +vom automatisierten IP-Symcon-Test verwendet. + +## Variablen + +| Ident | Typ | Beschreibung | | --- | --- | --- | -| `Active` / Aktiv | Boolean | `true`; Verbindungsbetrieb, keine Ladefreigabe. | -| `Username` / Benutzername | String | leer; Easee-Konto. | -| `Password` / Passwort | String | leer; vertraulich. | -| `VerifyCertificate` / Zertifikat prüfen | Boolean | `true`; TLS-Prüfung. | +| `Connected` | Boolean | Ereignisverbindung ist betriebsbereit. | +| `SubscriptionCount` | Integer | Anzahl angemeldeter Seriennummern. | +| `LastError` | String | Letzter Fehler ohne Zugangsdaten oder Tokens. | -## Verhalten +## API und Ereignisse -Nach einer Wiederverbindung werden Gerätezustände erst nach neuer gültiger -Rückmeldung verwendet. Das Gateway sendet keine EMS-Verbrauchermeldung. +Der Gateway-Transport ist in +[Easee-Gateway-Schnittstelle](../../Schnittstelle-Easee-Gateway.md) +vollstaendig beschrieben. Fuer Fahrzeug- und Phasenstatus werden insbesondere +Observation `109` und `110` verteilt. Nach jeder Wiederverbindung wird der +Cache verworfen und durch `SubscribeWithCurrentState` neu aufgebaut. -## Offene Punkte +## Fehlerbehandlung -- Instanziierung und Wiederverwendung bestehender Verbindungen testen. -- Aktuelle Easee-Endpunkte und Ereignisfelder vor Übernahme verifizieren. +| Status | Bedeutung | +| ---: | --- | +| `102` | Verbindung aktiv. | +| `104` | Gateway deaktiviert. | +| `201` | Benutzername oder Passwort fehlt. | +| `202` | Anmeldung oder Token-Erneuerung fehlgeschlagen. | +| `203` | Ereignisverbindung fehlgeschlagen. | + +REST-Aufrufe verwenden 5 Sekunden Verbindungs- und 30 Sekunden +Gesamt-Timeout. HTTP-401 fuehrt einmalig zu einer Token-Erneuerung und +Wiederholung. Tokens werden nie als Variable oder Debugtext ausgegeben. + +## Inbetriebnahme + +1. Eine Instanz **Easee Gateway** erstellen. +2. Benutzername und Passwort des Easee-Kontos eintragen. +3. TLS-Pruefung aktiviert lassen. +4. Speichern und Status `102` sowie `Connected=true` abwarten. +5. Fuer jede Station eine Kindinstanz **Ladestation Gateway** anlegen. +6. Bei mehreren Konten je Konto eine eigene Gateway-Instanz verwenden. + +## Tests + +`composer check` prueft Syntax und Unit-Tests. Der Funktionstest laeuft mit: + +```bash +tests/Symcon/bin/run-symcon-tests.sh single EaseeGateway +``` diff --git a/docs/module/Ladestation-Gateway/README.md b/docs/module/Ladestation-Gateway/README.md index 8471724..ff21307 100644 --- a/docs/module/Ladestation-Gateway/README.md +++ b/docs/module/Ladestation-Gateway/README.md @@ -1,41 +1,112 @@ # Ladestation Gateway -> Status: Diskussionsentwurf. Eigenständiges Verbrauchermodul mit zugeordnetem -> Easee Gateway; keine gemeinsame Ladestations-Basisklasse. +> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`. -## Zusätzliche Variablen +Das Modul bindet genau eine Easee-Ladestation als EMS-Verbraucher an. Es ist +Kind eines **Easee Gateway** und besitzt keine Zugangsdaten. -| Ident | Typ / Zugriff | Beschreibung | +## Varianten + +| Betriebsmodus | Verhalten | +| --- | --- | +| `Easee` | Solarladen kann als Startwert konfiguriert und lokal umgeschaltet werden. | +| `Easee - Nur Solarladen` | Solarladen ist fest aktiv und kann nicht abgeschaltet werden. | + +Die alte eCarUp-Zusatzabhaengigkeit wird nicht uebernommen. Konto, +Ladestatus und Steuerung laufen ausschliesslich ueber Easee. + +## Ereignisbasierter Status + +Das Modul pollt die Ladestation nicht. Es verarbeitet unmittelbar die vom +Gateway gelieferten Easee-Observations: + +| Observation | Verwendung | +| ---: | --- | +| `47` | Maximalstrom der Ladestation. | +| `104` | Kabelstromgrenze. | +| `109` | Fahrzeugerkennung und Ladezustand. | +| `110` | Aktive Ausgangsphase: Werte 10 bis 15 ergeben 1 Phase, Wert 30 ergibt 3 Phasen. | +| `119` | Easee-Fehlercode. | +| `120` | Gemessene Gesamtleistung in kW. | +| `182..185` | Gemessene Leiterstroeme; der groesste Betrag ist der angezeigte Ladestrom. | + +Damit entfallen die alte 60-Sekunden-Erkennung, die 7500-W-Schwelle und die +Voll-Erkennung aus einem unterschaetzten Strom. Fahrzeug, Ladeende und +Phasenzahl stammen direkt aus der Easee API. Zweiphasige oder noch nicht +zugewiesene Ausgangsphasen sowie unbekannte Betriebszustaende werden sicher +als ungueltig behandelt und ergeben bis zur gueltigen 1-/3-Phasenmeldung nur +`[0]`. + +## Properties + +| Ident | Typ | Standard | Beschreibung | +| --- | --- | ---: | --- | +| `Betriebsmodus` | Integer | `0` | `0` Easee, `1` Easee - Nur Solarladen. | +| `Ladestationskennung` | String | leer | Easee-Seriennummer. | +| `MaximalerLadestrom` | Integer | `16 A` | Lokale Obergrenze von 6 bis 32 A. API- und Kabelgrenze wirken zusaetzlich. | +| `Ladefreigabe` | Boolean | `false` | Startwert der lokalen Ladefreigabe. | +| `Solarladen` | Boolean | `true` | Startwert im normalen Easee-Modus. | +| `PrioritaetPV` | Integer | `0` | Prioritaet im PV-Betrieb. | +| `PrioritaetPeak` | Integer | `0` | Prioritaet im Peakbetrieb. | +| `Meldeintervall` | Integer | `10 s` | Periodische Vollmeldung an den Manager. | +| `VorgabeTimeout` | Integer | `120 s` | Ablauf einer nicht erneuerten Manager-Vorgabe. | +| `EinstellungenInVisu` | Boolean | `false` | Zeigt Ladefreigabe und Solarladen. | +| `DiagnosevariablenAnzeigen` | Boolean | `false` | Zeigt technische Diagnosewerte. | +| `LoggingEin` | Boolean | `false` | Aktiviert Debugmeldungen ohne Geheimnisse. | + +## Variablen + +Immer sichtbar sind `Aktiv`, `FahrzeugVerbunden`, `FahrzeugGeladen`, +`Fahrzeugstatus`, `Ladestrom` und `Phasenzahl`. Der normalisierte +Fahrzeugstatus ist `0` unbekannt, `1` getrennt, `2` bereit, `3` laedt, +`4` geladen oder `5` Fehler. + +Optional sichtbar sind die gemeinsamen EMS-Diagnosewerte sowie +`GatewayVerbunden`, `ApiMaximalstrom`, `LetzterGeraetebefehl` und +`LeistungsangebotDiagnose`. + +## Regelung + +Die Leistungsstufen und das PV-/Peak-Verhalten entsprechen der +Ladestation Stand-Alone: + +| Betriebsart | Solarladen | Angebot | | --- | --- | --- | -| `FahrzeugVerbunden` | Boolean / Anzeige | Nur bei gültigem Gatewaystatus aussagekräftig. | -| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` nicht verbunden, `2` bereit, `3` lädt, `4` voll, `5` Fehler. | -| `Ladestrom` | Float / Anzeige | Aktueller Ladestrom in A. | -| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. | -| `Ladefreigabe` | Boolean / lokal bedienbar | Lokale Ladeerlaubnis zusätzlich zu `Aktiv`. | -| `Solarladen` | Boolean / lokal bedienbar | Überschussorientiertes Laden ein/aus. | +| PV | ein | `[0, ...Ladestufen]` | +| PV | aus | nur maximale Ladestufe | +| Peak | ein | `[0]` | +| Peak | aus | `[0, ...Ladestufen]` | -## Zusätzliche Properties +Die niedrigste Grenze aus Property, Observation `47` und Observation `104` +bestimmt den angebotenen Maximalstrom. Eine neue Observation berechnet das +Angebot sofort neu und meldet die Aenderung kurz gebuendelt an den Manager. +Bei einer Gateway-Unterbrechung werden Fahrzeug- und Phasenstatus sofort +verworfen; erst ein neuer aktueller Gateway-Zustand gibt die Regelung wieder +frei. Die eigentliche Vorgabe wird als dynamischer Ladestrom mit `minutes=0` +an Easee gesendet. -| Ident | Typ | Standard / Beschreibung | -| --- | --- | --- | -| `Ladefreigabe` | Boolean | `false`. | -| `Solarladen` | Boolean | `true`. | -| `MaximalerLadestrom` | Float | `0*` A; Installations- und Gerätegrenze. | -| `Ladestromschritt` | Float | `1` A; muss von Easee unterstützt sein. | -| `MindestEinzeit` | Integer | `0` s. | -| `MindestAuszeit` | Integer | `0` s. | -| `GatewayID` | Integer | `0`; kompatibles Easee Gateway, erforderlich. | -| `Ladestationskennung` | String | leer; eindeutige Station/Seriennummer im Gateway. | +## Managerkommunikation -Es gibt hier keine Zugangsdaten; sie liegen ausschliesslich im Easee Gateway. -Ladefreigabe und Solarladen werden über `EinstellungenInVisu` eingeblendet. +Das Modul implementiert den +[EMS-Nachrichtenvertrag `4.0`](../../Schnittstelle.md). Es wird im Manager +wie die Stand-Alone-Ladestation unter dem Lizenzkatalog `ev_charger` +gefuehrt. Es gibt keine Manager-ID-Property; die Zuordnung erfolgt nur im +Manager. -## Zustand +## Inbetriebnahme -`Fahrzeugstatus`, `FahrzeugVerbunden`, `Ladefreigabe`, `Solarladen`, -`Ladestrom_A`, `Ladefehler` und `Gatewayfehler`. +1. Ein konfiguriertes Easee Gateway mit Status `102` bereitstellen. +2. Darunter **Ladestation Gateway** erstellen. +3. Variante, Seriennummer und elektrische Maximalgrenze konfigurieren. +4. Ladefreigabe und Solarladen festlegen. +5. Die Instanz im Enelix Manager aktiv zuordnen. +6. Ohne Fahrzeug Status `1` und Angebot `[0]` pruefen. +7. Fahrzeug verbinden und die API-Werte fuer Status und Phasenzahl kontrollieren. +8. Erst danach `Aktiv` einschalten und eine kleine Vorgabe testen. -## Offene Punkte +## Tests -- Reaktion aller zugeordneten Stationen bei Gatewayausfall im Praxistest festlegen. -- Lokalen Mindestladebedarf bei deaktiviertem Solarladen genau festlegen. +```bash +composer check +tests/Symcon/bin/run-symcon-tests.sh single LadestationGateway +``` diff --git a/docs/module/README.md b/docs/module/README.md index 20f23bc..23545a9 100644 --- a/docs/module/README.md +++ b/docs/module/README.md @@ -1,8 +1,8 @@ # EMS-Module und Modulentwürfe -> Manager, Warmwassererwaermer, Verbraucher 1-Stufig und Ladestation -> Stand-Alone sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren -> Ordner enthalten Besprechungsgrundlagen. +> Manager, Warmwassererwaermer, Verbraucher 1-Stufig, Ladestation Stand-Alone, +> Easee Gateway und Ladestation Gateway sind als installierbare IP-Symcon-Module +> umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen. Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der [EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb @@ -17,8 +17,8 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte. | [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) | | [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready | | [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung (implementiert) | -| [Ladestation Gateway](Ladestation-Gateway/README.md) | Ladestation am Easee Gateway | -| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation | +| [Ladestation Gateway](Ladestation-Gateway/README.md) | Eventbasierte Ladestation am Easee Gateway (implementiert) | +| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation (implementiert) | ## Review-Regel diff --git a/docs/testing/README.md b/docs/testing/README.md index 1b3b9c2..71fe623 100644 --- a/docs/testing/README.md +++ b/docs/testing/README.md @@ -42,7 +42,9 @@ werden, wenn sie vor dem Lauf nicht existierten. - `single`: genau die als Auswahl übergebenen Module - `affected`: die durch geänderte Pfade ermittelten Module -Verfügbare Module: `LadestationStandAlone`, `Manager`, `VerbraucherEinStufig`, `Warmwassererwaermer`. +Verfuegbare Module: `EaseeGateway`, `LadestationGateway`, +`LadestationStandAlone`, `Manager`, `Pufferspeicher`, `VerbraucherEinStufig` +und `Warmwassererwaermer`. Der Manager-Test enthält Manager ohne Verbraucher, jeden Verbrauchertyp einzeln und alle aktuell implementierten Verbrauchertypen gemeinsam. diff --git a/libs/EaseeGatewayProtokoll.php b/libs/EaseeGatewayProtokoll.php new file mode 100644 index 0000000..9af91bd --- /dev/null +++ b/libs/EaseeGatewayProtokoll.php @@ -0,0 +1,121 @@ + */ + public const BEOBACHTUNGEN = [ + 47, 48, 100, 104, 109, 110, 119, 120, 182, 183, 184, 185, 250, + ]; + + public static function seriennummer(string $wert): string + { + return strtoupper(trim($wert)); + } + + /** + * @param mixed $knoten + * @param list $abonnements + * @return list + */ + public static function extrahiereBeobachtungen( + $knoten, + array $abonnements, + string $seriennummer = '', + int $tiefe = 0 + ): array { + if (!is_array($knoten) || $tiefe > 12) { + return []; + } + + $abonnements = array_values(array_unique(array_map( + [self::class, 'seriennummer'], + $abonnements + ))); + foreach (['serialNumber', 'SerialNumber', 'chargerId', 'ChargerId', 'mid', 'Mid'] as $feld) { + if (!isset($knoten[$feld]) || !is_scalar($knoten[$feld])) { + continue; + } + $kandidat = self::seriennummer((string) $knoten[$feld]); + if (in_array($kandidat, $abonnements, true)) { + $seriennummer = $kandidat; + break; + } + } + + if (self::istListe($knoten)) { + foreach ($knoten as $wert) { + if (!is_string($wert)) { + continue; + } + $kandidat = self::seriennummer($wert); + if (in_array($kandidat, $abonnements, true)) { + $seriennummer = $kandidat; + } + } + } + + $id = null; + foreach (['id', 'Id', 'observationId', 'ObservationId'] as $feld) { + if (array_key_exists($feld, $knoten) && is_numeric($knoten[$feld])) { + $id = (int) $knoten[$feld]; + break; + } + } + + $wertVorhanden = false; + $wert = null; + foreach (['value', 'Value'] as $feld) { + if (array_key_exists($feld, $knoten)) { + $wertVorhanden = true; + $wert = $knoten[$feld]; + break; + } + } + + $ergebnis = []; + if ( + $seriennummer !== '' + && $wertVorhanden + && in_array($id, self::BEOBACHTUNGEN, true) + ) { + $ergebnis[] = [ + 'Seriennummer' => $seriennummer, + 'ID' => $id, + 'Wert' => $wert, + ]; + } + + foreach ($knoten as $kind) { + if (is_array($kind)) { + $ergebnis = array_merge( + $ergebnis, + self::extrahiereBeobachtungen( + $kind, + $abonnements, + $seriennummer, + $tiefe + 1 + ) + ); + } + } + + return $ergebnis; + } + + /** @param array $wert */ + private static function istListe(array $wert): bool + { + $index = 0; + foreach ($wert as $schluessel => $_) { + if ($schluessel !== $index++) { + return false; + } + } + + return true; + } +} diff --git a/libs/EaseeLadestatus.php b/libs/EaseeLadestatus.php new file mode 100644 index 0000000..bf0737d --- /dev/null +++ b/libs/EaseeLadestatus.php @@ -0,0 +1,137 @@ + $beobachtungen + * @return array{ + * StatusGueltig: bool, + * FahrzeugVerbunden: bool, + * FahrzeugGeladen: bool, + * Fahrzeugstatus: int, + * Phasenzahl: int, + * Istleistung_W: float, + * Ladestrom_A: float, + * ApiMaximalstrom_A: int, + * Stoerung: bool, + * Stoertext: string + * } + */ + public static function ausBeobachtungen(array $beobachtungen, int $konfigurierterMaximalstrom): array + { + if ($konfigurierterMaximalstrom < 6 || $konfigurierterMaximalstrom > 32) { + throw new InvalidArgumentException( + 'MaximalerLadestrom muss zwischen 6 und 32 A liegen.' + ); + } + + $rohBetriebszustand = $beobachtungen['109'] ?? null; + $statusGueltig = is_numeric($rohBetriebszustand) + && floor((float) $rohBetriebszustand) === (float) $rohBetriebszustand + && in_array((int) $rohBetriebszustand, [0, 1, 2, 3, 4, 5, 6, 7, 8], true); + $betriebszustand = $statusGueltig ? (int) $rohBetriebszustand : 0; + $verbunden = $statusGueltig + && in_array($betriebszustand, [2, 3, 4, 5, 6, 7, 8], true); + $geladen = $betriebszustand === 4; + $fahrzeugstatus = self::fahrzeugstatus($betriebszustand); + + $phasenzahl = $verbunden + ? self::phasenzahl((int) ($beobachtungen['110'] ?? 0)) + : 0; + + $leistung = max(0.0, (float) ($beobachtungen['120'] ?? 0.0) * 1000.0); + $stroeme = []; + foreach ([182, 183, 184, 185] as $id) { + if (array_key_exists((string) $id, $beobachtungen)) { + $stroeme[] = abs((float) $beobachtungen[(string) $id]); + } + } + $ladestrom = $stroeme === [] ? 0.0 : max($stroeme); + + $grenzen = [$konfigurierterMaximalstrom]; + foreach ([47, 104] as $id) { + $grenze = (float) ($beobachtungen[(string) $id] ?? 0.0); + if ($grenze >= 6.0) { + $grenzen[] = (int) floor($grenze); + } + } + $apiMaximalstrom = max(6, min($grenzen)); + + $fehlercode = (int) ($beobachtungen['119'] ?? 0); + $stoerung = $betriebszustand === 5 || $fehlercode !== 0; + $stoertext = ''; + if ($betriebszustand === 5) { + $stoertext = 'Easee meldet einen Fehlerzustand.'; + } + if ($fehlercode !== 0) { + $stoertext .= ($stoertext === '' ? '' : ' ') . 'Easee-Fehlercode: ' . $fehlercode . '.'; + } + + return [ + 'StatusGueltig' => $statusGueltig, + 'FahrzeugVerbunden' => $verbunden, + 'FahrzeugGeladen' => $geladen, + 'Fahrzeugstatus' => $fahrzeugstatus, + 'Phasenzahl' => $phasenzahl, + 'Istleistung_W' => $leistung, + 'Ladestrom_A' => $ladestrom, + 'ApiMaximalstrom_A' => $apiMaximalstrom, + 'Stoerung' => $stoerung, + 'Stoertext' => trim($stoertext), + ]; + } + + public static function solarladenErzwungen(int $betriebsmodus): bool + { + if (!in_array($betriebsmodus, [ + self::BETRIEBSMODUS_EASEE, + self::BETRIEBSMODUS_NUR_SOLAR, + ], true)) { + throw new InvalidArgumentException('Unbekannter Easee-Betriebsmodus.'); + } + + return $betriebsmodus === self::BETRIEBSMODUS_NUR_SOLAR; + } + + private static function fahrzeugstatus(int $betriebszustand): int + { + if ($betriebszustand === 1) { + return 1; + } + if (in_array($betriebszustand, [2, 6, 7, 8], true)) { + return 2; + } + if ($betriebszustand === 3) { + return 3; + } + if ($betriebszustand === 4) { + return 4; + } + if ($betriebszustand === 5) { + return 5; + } + + return 0; + } + + private static function phasenzahl(int $ausgangsphase): int + { + if ($ausgangsphase >= 10 && $ausgangsphase <= 15) { + return 1; + } + if ($ausgangsphase === 30) { + return 3; + } + + return 0; + } +} diff --git a/tests/DokumentationsstrukturTest.php b/tests/DokumentationsstrukturTest.php index d00ceea..6048d1a 100644 --- a/tests/DokumentationsstrukturTest.php +++ b/tests/DokumentationsstrukturTest.php @@ -14,8 +14,6 @@ final class DokumentationsstrukturTest extends TestCase return [ ['Batterie'], ['Waermepumpe'], - ['Ladestation-Gateway'], - ['Easee-Gateway'], ]; } @@ -48,9 +46,23 @@ final class DokumentationsstrukturTest extends TestCase self::assertStringContainsString('Fake-HTTP-Transport', $inhalt); } + public function testEaseeModuleDokumentierenDenImplementiertenStand(): void + { + foreach (['Easee-Gateway', 'Ladestation-Gateway'] as $modul) { + $inhalt = file_get_contents( + __DIR__ . '/../docs/module/' . $modul . '/README.md' + ); + self::assertNotFalse($inhalt); + self::assertStringContainsString('Status: implementiert', $inhalt); + self::assertStringContainsString('Properties', $inhalt); + self::assertStringContainsString('Tests', $inhalt); + } + } + public function testSchnittstellenDokumentiertSind(): void { self::assertFileExists(__DIR__ . '/../docs/Schnittstelle.md'); + self::assertFileExists(__DIR__ . '/../docs/Schnittstelle-Easee-Gateway.md'); self::assertFileExists(__DIR__ . '/../docs/Obere-Anschluesse.md'); self::assertFileExists(__DIR__ . '/../libs/VerbraucherBasisTrait.php'); } diff --git a/tests/EaseeGatewayProtokollTest.php b/tests/EaseeGatewayProtokollTest.php new file mode 100644 index 0000000..9e86ee9 --- /dev/null +++ b/tests/EaseeGatewayProtokollTest.php @@ -0,0 +1,55 @@ + [ + 'EH123456', + [ + ['id' => 109, 'value' => 3], + ['ObservationId' => 110, 'Value' => 30], + ], + ], + ], ['EH123456']); + + self::assertSame([ + ['Seriennummer' => 'EH123456', 'ID' => 109, 'Wert' => 3], + ['Seriennummer' => 'EH123456', 'ID' => 110, 'Wert' => 30], + ], $beobachtungen); + } + + public function testNichtAbonnierteStationenUndUnbekannteIdsWerdenIgnoriert(): void + { + $beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([ + [ + 'serialNumber' => 'EH999999', + 'id' => 109, + 'value' => 3, + ], + [ + 'serialNumber' => 'EH123456', + 'id' => 999, + 'value' => 1, + ], + ], ['EH123456']); + + self::assertSame([], $beobachtungen); + } + + public function testSeriennummerWirdNormalisiert(): void + { + self::assertSame( + 'EH123456', + EaseeGatewayProtokoll::seriennummer(' eh123456 ') + ); + } +} diff --git a/tests/EaseeLadestatusTest.php b/tests/EaseeLadestatusTest.php new file mode 100644 index 0000000..2f3f100 --- /dev/null +++ b/tests/EaseeLadestatusTest.php @@ -0,0 +1,97 @@ + 3, + '110' => 10, + '120' => 2.3, + '182' => 10.1, + ], 16); + + self::assertTrue($status['FahrzeugVerbunden']); + self::assertFalse($status['FahrzeugGeladen']); + self::assertSame(3, $status['Fahrzeugstatus']); + self::assertSame(1, $status['Phasenzahl']); + self::assertEqualsWithDelta(2300.0, $status['Istleistung_W'], 0.01); + self::assertEqualsWithDelta(10.1, $status['Ladestrom_A'], 0.01); + } + + public function testDreiphasigesLadenWirdNichtAusLeistungGeschaetzt(): void + { + $status = EaseeLadestatus::ausBeobachtungen([ + '109' => 3, + '110' => 30, + '120' => 1.0, + '182' => 2.0, + '183' => 2.2, + '184' => 2.1, + ], 32); + + self::assertSame(3, $status['Phasenzahl']); + self::assertEqualsWithDelta(2.2, $status['Ladestrom_A'], 0.01); + } + + public function testZweiphasigerApiWertWirdNichtAlsEinOderDreiGeschaetzt(): void + { + $status = EaseeLadestatus::ausBeobachtungen([ + '109' => 3, + '110' => 20, + '120' => 4.6, + ], 16); + + self::assertSame(0, $status['Phasenzahl']); + } + + public function testGetrenntUndGeladenFolgenDemEaseeBetriebszustand(): void + { + $getrennt = EaseeLadestatus::ausBeobachtungen(['109' => 1, '110' => 30], 16); + $geladen = EaseeLadestatus::ausBeobachtungen(['109' => 4, '110' => 30], 16); + + self::assertFalse($getrennt['FahrzeugVerbunden']); + self::assertSame(0, $getrennt['Phasenzahl']); + self::assertTrue($geladen['FahrzeugVerbunden']); + self::assertTrue($geladen['FahrzeugGeladen']); + self::assertSame(4, $geladen['Fahrzeugstatus']); + } + + public function testUnbekannterApiBetriebszustandGibtRegelungNichtFrei(): void + { + $status = EaseeLadestatus::ausBeobachtungen([ + '109' => 99, + '110' => 30, + ], 16); + + self::assertFalse($status['StatusGueltig']); + self::assertFalse($status['FahrzeugVerbunden']); + self::assertSame(0, $status['Fahrzeugstatus']); + self::assertSame(0, $status['Phasenzahl']); + } + + public function testApiUndKabelgrenzenBegrenzenDenKonfiguriertenStrom(): void + { + $status = EaseeLadestatus::ausBeobachtungen([ + '109' => 2, + '110' => 10, + '47' => 25.0, + '104' => 13.7, + ], 32); + + self::assertSame(13, $status['ApiMaximalstrom_A']); + } + + public function testNurSolarladenIstFestErzwungen(): void + { + self::assertFalse(EaseeLadestatus::solarladenErzwungen(0)); + self::assertTrue(EaseeLadestatus::solarladenErzwungen(1)); + } +} diff --git a/tests/EaseeModuleStrukturTest.php b/tests/EaseeModuleStrukturTest.php new file mode 100644 index 0000000..33a0dc7 --- /dev/null +++ b/tests/EaseeModuleStrukturTest.php @@ -0,0 +1,81 @@ +module('EaseeGateway'); + $ladestation = $this->module('LadestationGateway'); + + self::assertContains( + '{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}', + $gateway['implemented'] + ); + self::assertContains( + '{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}', + $gateway['childRequirements'] + ); + self::assertContains( + '{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}', + $ladestation['parentRequirements'] + ); + self::assertContains( + '{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}', + $ladestation['implemented'] + ); + } + + public function testZugangsdatenLiegenNurImGateway(): void + { + $gatewayForm = (string) file_get_contents( + __DIR__ . '/../EaseeGateway/form.json' + ); + $ladestationForm = (string) file_get_contents( + __DIR__ . '/../LadestationGateway/form.json' + ); + + self::assertStringContainsString('PasswordTextBox', $gatewayForm); + self::assertStringNotContainsString('PasswordTextBox', $ladestationForm); + self::assertStringNotContainsString('Username', $ladestationForm); + } + + public function testLadestationVerwendetApiStatusUndGemeinsamenVertrag(): void + { + $modul = (string) file_get_contents( + __DIR__ . '/../LadestationGateway/module.php' + ); + + self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul); + self::assertStringContainsString('EaseeLadestatus::ausBeobachtungen', $modul); + self::assertStringContainsString("'FahrzeugVerbunden'", $modul); + self::assertStringContainsString("'Phasenzahl'", $modul); + self::assertStringNotContainsString('7500', $modul); + self::assertStringNotContainsString('Abfrageintervall', $modul); + } + + /** @return array */ + private function module(string $name): array + { + return json_decode( + (string) file_get_contents(__DIR__ . '/../' . $name . '/module.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + } +} diff --git a/tests/Symcon/manifest.php b/tests/Symcon/manifest.php index b366241..04609ac 100644 --- a/tests/Symcon/manifest.php +++ b/tests/Symcon/manifest.php @@ -3,6 +3,25 @@ declare(strict_types=1); return [ + 'EaseeGateway' => [ + 'file' => __DIR__ . '/modules/EaseeGateway.php', + 'paths' => [ + 'EaseeGateway/*', + 'libs/EaseeGatewayProtokoll.php', + ], + ], + 'LadestationGateway' => [ + 'file' => __DIR__ . '/modules/LadestationGateway.php', + 'paths' => [ + 'LadestationGateway/*', + 'libs/EaseeGatewayProtokoll.php', + 'libs/EaseeLadestatus.php', + 'libs/LadestationRegler.php', + 'libs/VerbraucherBasisTrait.php', + 'libs/VerbraucherSchnittstelle.php', + 'libs/Nachrichtenvertrag.php', + ], + ], 'LadestationStandAlone' => [ 'file' => __DIR__ . '/modules/LadestationStandAlone.php', 'paths' => [ diff --git a/tests/Symcon/modules/EaseeGateway.php b/tests/Symcon/modules/EaseeGateway.php new file mode 100644 index 0000000..449e385 --- /dev/null +++ b/tests/Symcon/modules/EaseeGateway.php @@ -0,0 +1,89 @@ +createInstance( + '{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}', + 'Easee Gateway Test', + [ + 'Active' => true, + 'Testmodus' => true, + ] + ); + + $test->runCase('Testgateway ist ohne Zugangsdaten aktiv', static function ( + TestContext $test + ) use ($gateway): void { + $test->assertInstanceStatus($gateway); + $test->assertSame( + true, + GetValue($test->objectByIdent('Connected', $gateway)) + ); + }); + + $test->runCase('Abonnement liefert injizierten API-Zustand', static function ( + TestContext $test + ) use ($gateway): void { + ENELIXEASEE_ProcessStationRequest($gateway, json_encode([ + 'action' => 'Subscribe', + 'serialNumber' => 'EH123456', + ], JSON_THROW_ON_ERROR)); + foreach ([109 => 3, 110 => 30, 120 => 1.0] as $id => $value) { + IPS_RequestAction($gateway, 'TestObservation', json_encode([ + 'serialNumber' => 'EH123456', + 'id' => $id, + 'value' => $value, + ], JSON_THROW_ON_ERROR)); + } + + $antwort = json_decode(ENELIXEASEE_ProcessStationRequest( + $gateway, + json_encode([ + 'action' => 'GetState', + 'serialNumber' => 'EH123456', + ], JSON_THROW_ON_ERROR) + ), true, 512, JSON_THROW_ON_ERROR); + + $test->assertSame(true, $antwort['success']); + $test->assertSame(3, $antwort['state']['109']); + $test->assertSame(30, $antwort['state']['110']); + $test->assertSame( + 1, + GetValue($test->objectByIdent('SubscriptionCount', $gateway)) + ); + }); + + $test->runCase('Stromvorgabe wird ueber Gateway angenommen', static function ( + TestContext $test + ) use ($gateway): void { + $antwort = json_decode(ENELIXEASEE_ProcessStationRequest( + $gateway, + json_encode([ + 'action' => 'SetDynamicChargerCurrent', + 'serialNumber' => 'EH123456', + 'amps' => 13, + ], JSON_THROW_ON_ERROR) + ), true, 512, JSON_THROW_ON_ERROR); + + $test->assertSame(true, $antwort['success']); + $test->assertSame(200, $antwort['httpCode']); + }); + + $test->runCase('Gebrochene Stromvorgabe wird abgelehnt', static function ( + TestContext $test + ) use ($gateway): void { + $antwort = json_decode(ENELIXEASEE_ProcessStationRequest( + $gateway, + json_encode([ + 'action' => 'SetDynamicChargerCurrent', + 'serialNumber' => 'EH123456', + 'amps' => 6.5, + ], JSON_THROW_ON_ERROR) + ), true, 512, JSON_THROW_ON_ERROR); + + $test->assertSame(false, $antwort['success']); + }); +}; diff --git a/tests/Symcon/modules/LadestationGateway.php b/tests/Symcon/modules/LadestationGateway.php new file mode 100644 index 0000000..88688b8 --- /dev/null +++ b/tests/Symcon/modules/LadestationGateway.php @@ -0,0 +1,155 @@ +createInstance( + '{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}', + 'Easee Gateway fuer Ladestation', + [ + 'Active' => true, + 'Testmodus' => true, + ] + ); + $station = $test->createInstance( + '{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}', + 'Easee Ladestation', + [ + 'Betriebsmodus' => 0, + 'Ladestationskennung' => 'EH123456', + 'MaximalerLadestrom' => 16, + 'Ladefreigabe' => true, + 'Solarladen' => true, + 'EinstellungenInVisu' => true, + 'DiagnosevariablenAnzeigen' => true, + ] + ); + IPS_ConnectInstance($station, $gateway); + IPS_ApplyChanges($station); + + $nurSolar = $test->createInstance( + '{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}', + 'Easee nur Solarladen', + [ + 'Betriebsmodus' => 1, + 'Ladestationskennung' => 'EH654321', + 'MaximalerLadestrom' => 16, + 'Ladefreigabe' => true, + 'Solarladen' => false, + 'EinstellungenInVisu' => true, + 'DiagnosevariablenAnzeigen' => true, + ] + ); + IPS_ConnectInstance($nurSolar, $gateway); + IPS_ApplyChanges($nurSolar); + + $sendeBeobachtung = static function ( + string $seriennummer, + int $id, + $value + ) use ($gateway): void { + IPS_RequestAction($gateway, 'TestObservation', json_encode([ + 'serialNumber' => $seriennummer, + 'id' => $id, + 'value' => $value, + ], JSON_THROW_ON_ERROR)); + }; + + foreach (['EH123456', 'EH654321'] as $seriennummer) { + $sendeBeobachtung($seriennummer, 47, 16.0); + $sendeBeobachtung($seriennummer, 104, 32.0); + $sendeBeobachtung($seriennummer, 109, 3); + $sendeBeobachtung($seriennummer, 110, 30); + $sendeBeobachtung($seriennummer, 120, 1.0); + $sendeBeobachtung($seriennummer, 182, 2.1); + $sendeBeobachtung($seriennummer, 183, 2.0); + $sendeBeobachtung($seriennummer, 184, 2.2); + } + + $test->runCase('Fahrzeug und drei Phasen folgen direkt den API-Ereignissen', static function ( + TestContext $test + ) use ($station): void { + $test->assertSame( + true, + GetValue($test->objectByIdent('FahrzeugVerbunden', $station)) + ); + $test->assertSame( + 3, + GetValue($test->objectByIdent('Phasenzahl', $station)) + ); + $test->assertSame( + 3, + GetValue($test->objectByIdent('Fahrzeugstatus', $station)) + ); + $test->assertEquals( + 2.2, + (float) GetValue($test->objectByIdent('Ladestrom', $station)), + 0.01 + ); + }); + + $test->runCase('Event erzeugt ohne Polling ein dreiphasiges Leistungsangebot', static function ( + TestContext $test + ) use ($station): void { + IPS_RequestAction($station, 'Aktiv', true); + $test->assertSame( + '[0,4150,4850,5550,6250,6950,7600,8300,9000,9700,10300,11000]', + (string) GetValue( + $test->objectByIdent('LeistungsangebotDiagnose', $station) + ) + ); + $test->assertTrue(str_contains( + (string) GetValue( + $test->objectByIdent('LetzterGeraetebefehl', $station) + ), + '"Ampere":0' + )); + }); + + $test->runCase('Nur-Solar-Variante laesst Solarladen nicht abschalten', static function ( + TestContext $test + ) use ($nurSolar): void { + IPS_RequestAction($nurSolar, 'Solarladen', false); + $test->assertSame( + true, + GetValue($test->objectByIdent('Solarladen', $nurSolar)) + ); + }); + + $test->runCase('Unbekannter API-Status sperrt die Regelung', static function ( + TestContext $test + ) use ($sendeBeobachtung, $station): void { + $sendeBeobachtung('EH123456', 109, 99); + $test->assertSame( + false, + GetValue($test->objectByIdent('FahrzeugVerbunden', $station)) + ); + $test->assertInstanceStatus($station, 203); + + $sendeBeobachtung('EH123456', 109, 3); + $test->assertSame( + true, + GetValue($test->objectByIdent('FahrzeugVerbunden', $station)) + ); + $test->assertInstanceStatus($station); + }); + + $test->runCase('Gateway-Ausfall verwirft Fahrzeug- und Phasenstatus', static function ( + TestContext $test + ) use ($gateway, $station): void { + IPS_SetProperty($gateway, 'Active', false); + IPS_ApplyChanges($gateway); + + $test->assertSame( + false, + GetValue($test->objectByIdent('FahrzeugVerbunden', $station)) + ); + $test->assertSame( + 0, + GetValue($test->objectByIdent('Phasenzahl', $station)) + ); + $test->assertInstanceStatus($station, 202); + }); +}; diff --git a/tests/SymconTestContractTest.php b/tests/SymconTestContractTest.php index b6d6d08..3c66886 100644 --- a/tests/SymconTestContractTest.php +++ b/tests/SymconTestContractTest.php @@ -58,7 +58,14 @@ final class SymconTestContractTest extends TestCase public function testAffectedModuleResolverSelectsAllMatches(): void { self::assertSame( - ['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'], + [ + 'LadestationGateway', + 'LadestationStandAlone', + 'Manager', + 'Pufferspeicher', + 'VerbraucherEinStufig', + 'Warmwassererwaermer', + ], $this->resolveAffectedModules(['libs/Nachrichtenvertrag.php']) ); } @@ -74,7 +81,15 @@ final class SymconTestContractTest extends TestCase public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void { self::assertSame( - ['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'], + [ + 'EaseeGateway', + 'LadestationGateway', + 'LadestationStandAlone', + 'Manager', + 'Pufferspeicher', + 'VerbraucherEinStufig', + 'Warmwassererwaermer', + ], $this->resolveAffectedModules(['tests/Symcon/TestContext.php']) ); }