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; } $this->speichereBeobachtungImCache($seriennummer, $id, $wert); $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 speichereBeobachtungImCache( string $seriennummer, int $id, $wert ): void { $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) ); } private function stelleAktuellenStatusSicher(string $seriennummer): void { $cache = $this->leseAttributArray('ObservationCache'); $zustand = $cache[$seriennummer] ?? []; if ( (is_array($zustand) && array_key_exists('109', $zustand)) || $this->ReadPropertyBoolean('Testmodus') || $this->GetBuffer('SignalRReady') !== '1' ) { return; } $antwort = $this->autorisierteApiAnfrage( 'GET', '/state/' . rawurlencode($seriennummer) . '/observations?ids=' . rawurlencode(implode(',', EaseeGatewayProtokoll::BEOBACHTUNGEN)), '' ); if (!($antwort['success'] ?? false)) { $this->setzeLetztenFehler( 'Easee-Status konnte nicht geladen werden: ' . (string) ($antwort['error'] ?? 'Unbekannter API-Fehler.') ); return; } $daten = json_decode((string) ($antwort['body'] ?? ''), true); if (!is_array($daten)) { $this->setzeLetztenFehler('Easee-Statusantwort ist ungueltig.'); return; } $beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen( $daten, [$seriennummer], $seriennummer ); $betriebsstatusGeladen = false; foreach ($beobachtungen as $beobachtung) { $this->speichereBeobachtungImCache( $beobachtung['Seriennummer'], $beobachtung['ID'], $beobachtung['Wert'] ); $betriebsstatusGeladen = $betriebsstatusGeladen || $beobachtung['ID'] === 109; } if (!$betriebsstatusGeladen) { $this->setzeLetztenFehler( 'Easee-Statusantwort enthaelt keinen Betriebsstatus (109).' ); return; } $this->setzeLetztenFehler(''); } private function erstelleStatusantwort(string $seriennummer): string { $this->stelleAktuellenStatusSicher($seriennummer); $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 : []; } }