RegisterPropertyBoolean("Active", true); $this->RegisterPropertyString("Username", ""); $this->RegisterPropertyString("Password", ""); $this->RegisterPropertyBoolean("VerifyCertificate", true); $this->RegisterAttributeString("ObservationCache", "{}"); $this->RegisterVariableBoolean("Connected", "SignalR verbunden", "~Switch", false); $this->RegisterVariableInteger("SubscriptionCount", "Abonnierte Ladestationen", "", 0); $this->RegisterVariableString("LastError", "Letzter Fehler", "", 0); $this->RegisterTimer( "MaintainConnectionTimer", 0, 'IPS_RequestAction(' . $this->InstanceID . ', "MaintainConnection", 0);' ); $this->RegisterTimer( "TokenRefreshTimer", 0, 'IPS_RequestAction(' . $this->InstanceID . ', "RefreshToken", 0);' ); } public function ApplyChanges() { 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->SetConnected(false); if (!$this->ReadPropertyBoolean("Active")) { $this->SetStatus(104); return; } if ( trim($this->ReadPropertyString("Username")) === "" || $this->ReadPropertyString("Password") === "" ) { $this->SetStatus(201); $this->SetLastError("Easee-Benutzername oder Passwort fehlt"); return; } if (!$this->EnsureWebSocketParent()) { $this->SetStatus(203); return; } $this->SetTimerInterval("MaintainConnectionTimer", 10000); $this->SetTimerInterval("TokenRefreshTimer", 1800000); if (!$this->RefreshCredentials(false)) { $this->SetStatus(202); return; } $this->ConnectSignalR(); } private function EnsureWebSocketParent(): bool { $instance = IPS_GetInstance($this->InstanceID); $parentID = (int)$instance["ConnectionID"]; if ($parentID > 0) { $parent = IPS_GetInstance($parentID); if ($parent["ModuleInfo"]["ModuleID"] === self::WEBSOCKET_MODULE_ID) { return true; } IPS_DisconnectInstance($this->InstanceID); } $parentID = IPS_CreateInstance(self::WEBSOCKET_MODULE_ID); if ($parentID <= 0) { $this->SetLastError("WebSocket-Client konnte nicht erstellt werden"); return false; } IPS_SetName($parentID, "Easee SignalR WebSocket"); $objectParentID = IPS_GetParent($this->InstanceID); if ($objectParentID > 0) { IPS_SetParent($parentID, $objectParentID); } if (!IPS_ConnectInstance($this->InstanceID, $parentID)) { $this->SetLastError("WebSocket-Client konnte nicht verbunden werden"); return false; } return true; } public function RequestAction($Ident, $Value) { switch ($Ident) { case "MaintainConnection": $this->MaintainConnection(); break; case "RefreshToken": if ($this->RefreshCredentials(false)) { $this->ConnectSignalR(); } break; case "Reconnect": $this->SetBuffer("AccessToken", ""); $this->SetBuffer("RefreshToken", ""); if ($this->RefreshCredentials(false)) { $this->ConnectSignalR(); } break; default: throw new Exception("Unbekannte Aktion"); } } public function GetConfigurationForParent() { return json_encode([ "Active" => $this->ReadPropertyBoolean("Active"), "URL" => $this->GetBuffer("WebSocketURL"), "VerifyCertificate" => $this->ReadPropertyBoolean("VerifyCertificate"), "Headers" => "[]", ]); } public function ReceiveData($JSONString) { $packet = json_decode($JSONString, true); if (!is_array($packet) || !isset($packet["Buffer"])) { return; } $this->SetBuffer("LastReceive", (string)time()); $buffer = $this->GetBuffer("ReceiveBuffer") . $packet["Buffer"]; $frames = explode(self::RECORD_SEPARATOR, $buffer); $this->SetBuffer("ReceiveBuffer", array_pop($frames)); foreach ($frames as $frame) { if ($frame !== "") { $this->HandleSignalRFrame($frame); } } } public function ForwardData($JSONString) { $packet = json_decode($JSONString, true); if (!is_array($packet) || !isset($packet["Buffer"])) { return json_encode(["success" => false, "error" => "Ungültiges Datenpaket"]); } return $this->ProcessStationRequest($packet["Buffer"]); } public function ProcessStationRequest($JSONString) { $request = json_decode($JSONString, true); if (!is_array($request) || !isset($request["action"])) { return json_encode(["success" => false, "error" => "Ungültige Gateway-Anfrage"]); } $serialNumber = strtoupper(trim((string)($request["serialNumber"] ?? ""))); switch ($request["action"]) { case "Subscribe": if ($serialNumber === "") { return json_encode(["success" => false, "error" => "Seriennummer fehlt"]); } $this->RegisterSubscription($serialNumber); return $this->CreateStateResponse($serialNumber); case "GetState": if ($serialNumber === "") { return json_encode(["success" => false, "error" => "Seriennummer fehlt"]); } $this->RegisterSubscription($serialNumber); return $this->CreateStateResponse($serialNumber); case "SetDynamicChargerCurrent": case "SetCurrent": // Kompatibilität mit Versionen bis 1.7 return json_encode( $this->SetDynamicChargerCurrent( $serialNumber, (float)($request["amps"] ?? -1) ) ); default: return json_encode(["success" => false, "error" => "Unbekannte Gateway-Aktion"]); } } private function MaintainConnection() { if (!$this->ReadPropertyBoolean("Active")) { return; } $now = time(); $lastNegotiation = (int)$this->GetBuffer("LastNegotiation"); $lastReceive = (int)$this->GetBuffer("LastReceive"); $ready = $this->GetBuffer("SignalRReady") === "1"; if ($ready) { $this->SendSignalRFrame(["type" => 6]); if ($lastReceive > 0 && ($now - $lastReceive) <= 90) { return; } $this->SetConnected(false); $this->SetBuffer("SignalRReady", "0"); } if (($now - $lastNegotiation) >= 45) { $this->ConnectSignalR(); return; } $this->SendHandshake(); } private function ConnectSignalR() { if (!$this->EnsureAccessToken()) { $this->SetStatus(202); return false; } $this->SetBuffer("LastNegotiation", (string)time()); $token = $this->GetBuffer("AccessToken"); $response = $this->HttpRequest( "POST", self::SIGNALR_BASE_URL . "/negotiate?negotiateVersion=1", "", $token ); if ($response["httpCode"] === 401 && $this->RefreshCredentials(false)) { $token = $this->GetBuffer("AccessToken"); $response = $this->HttpRequest( "POST", self::SIGNALR_BASE_URL . "/negotiate?negotiateVersion=1", "", $token ); } if (!$response["success"]) { $this->SetStatus(203); $this->SetLastError( "SignalR-Aushandlung fehlgeschlagen: HTTP " . $response["httpCode"] . ($response["error"] !== "" ? " / " . $response["error"] : "") ); return false; } $data = json_decode($response["body"], true); $connectionToken = is_array($data) ? ($data["connectionToken"] ?? $data["connectionId"] ?? "") : ""; if ($connectionToken === "") { $this->SetStatus(203); $this->SetLastError("SignalR-Antwort enthält kein Verbindungstoken"); return false; } $webSocketURL = "wss://streams.easee.com/hubs/chargers?id=" . rawurlencode($connectionToken) . "&access_token=" . rawurlencode($token); $this->SetBuffer("WebSocketURL", $webSocketURL); $this->SetBuffer("SignalRReady", "0"); $this->SetBuffer("ReceiveBuffer", ""); $this->SetBuffer("SubscribedThisConnection", "{}"); $this->SetBuffer("PendingInvocations", "{}"); $this->SetConnected(false); if (!$this->ConfigureWebSocketParent($webSocketURL)) { $this->SetStatus(203); return false; } $this->SendHandshake(); return true; } private function ConfigureWebSocketParent(string $url): bool { $instance = IPS_GetInstance($this->InstanceID); $parentID = (int)$instance["ConnectionID"]; if ($parentID <= 0) { $this->SetLastError("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 SendHandshake() { $this->SendRawToWebSocket( json_encode(["protocol" => "json", "version" => 1]) . self::RECORD_SEPARATOR ); } private function SendSignalRFrame(array $frame) { $this->SendRawToWebSocket(json_encode($frame) . self::RECORD_SEPARATOR); } private function SendRawToWebSocket(string $payload) { return $this->SendDataToParent(json_encode([ "DataID" => self::SIMPLE_TX_DATA_ID, "Buffer" => $payload, ])); } private function HandleSignalRFrame(string $frame) { if ($frame === "{}") { $this->SetBuffer("SignalRReady", "1"); $this->SetStatus(102); $this->SetLastError(""); $this->SetConnected(true); $this->SendAllSubscriptions(); return; } $message = json_decode($frame, true); if (!is_array($message)) { $this->SetLastError("Ungültige SignalR-Nachricht"); return; } if (isset($message["error"])) { $this->SetStatus(203); $this->SetLastError("SignalR-Handshake: " . $message["error"]); return; } $type = (int)($message["type"] ?? 0); if ($type === 6) { return; } if ($type === 7) { $this->SetBuffer("SignalRReady", "0"); $this->SetConnected(false); $this->SetLastError("SignalR-Verbindung wurde beendet"); return; } $serialNumber = ""; if ($type === 3 && isset($message["invocationId"])) { $pending = $this->ReadBufferArray("PendingInvocations"); $invocationID = (string)$message["invocationId"]; $serialNumber = (string)($pending[$invocationID] ?? ""); unset($pending[$invocationID]); $this->SetBuffer("PendingInvocations", json_encode($pending)); } if (isset($message["arguments"])) { $this->ExtractObservations($message["arguments"], $serialNumber); } if (isset($message["result"])) { $this->ExtractObservations($message["result"], $serialNumber); } } private function RegisterSubscription(string $serialNumber) { $subscriptions = $this->ReadBufferArray("Subscriptions"); if (!isset($subscriptions[$serialNumber])) { $subscriptions[$serialNumber] = true; $this->SetBuffer("Subscriptions", json_encode($subscriptions)); $this->SetValue("SubscriptionCount", count($subscriptions)); } if ($this->GetBuffer("SignalRReady") === "1") { $this->SendSubscription($serialNumber); } } private function SendAllSubscriptions() { foreach (array_keys($this->ReadBufferArray("Subscriptions")) as $serialNumber) { $this->SendSubscription($serialNumber); } } private function SendSubscription(string $serialNumber) { $sent = $this->ReadBufferArray("SubscribedThisConnection"); if (isset($sent[$serialNumber])) { return; } $invocationID = (string)(((int)$this->GetBuffer("InvocationID")) + 1); $this->SetBuffer("InvocationID", $invocationID); $pending = $this->ReadBufferArray("PendingInvocations"); $pending[$invocationID] = $serialNumber; $this->SetBuffer("PendingInvocations", json_encode($pending)); $this->SendSignalRFrame([ "type" => 1, "invocationId" => $invocationID, "target" => "SubscribeWithCurrentState", "arguments" => [$serialNumber, true], ]); $sent[$serialNumber] = true; $this->SetBuffer("SubscribedThisConnection", json_encode($sent)); } private function ExtractObservations($node, string $serialNumber = "", int $depth = 0) { if (!is_array($node) || $depth > 12) { return; } foreach (["serialNumber", "SerialNumber", "chargerId", "ChargerId", "mid", "Mid"] as $key) { if (isset($node[$key]) && is_scalar($node[$key])) { $candidate = strtoupper((string)$node[$key]); if ($this->IsSubscribedSerial($candidate)) { $serialNumber = $candidate; break; } } } if ($this->IsList($node)) { foreach ($node as $item) { if (is_string($item)) { $candidate = strtoupper($item); if ($this->IsSubscribedSerial($candidate)) { $serialNumber = $candidate; } } } } $observationID = null; foreach (["id", "Id", "observationId", "ObservationId"] as $key) { if (array_key_exists($key, $node) && is_numeric($node[$key])) { $observationID = (int)$node[$key]; break; } } $valueExists = false; $value = null; foreach (["value", "Value"] as $key) { if (array_key_exists($key, $node)) { $valueExists = true; $value = $node[$key]; break; } } if ( $serialNumber !== "" && $valueExists && ($observationID === 109 || $observationID === 120) ) { $this->PublishObservation($serialNumber, $observationID, $value); } foreach ($node as $item) { if (is_array($item)) { $this->ExtractObservations($item, $serialNumber, $depth + 1); } } } private function PublishObservation(string $serialNumber, int $observationID, $value) { $cache = $this->ReadAttributeArray("ObservationCache"); if (!isset($cache[$serialNumber]) || !is_array($cache[$serialNumber])) { $cache[$serialNumber] = []; } $cache[$serialNumber][(string)$observationID] = $value; $cache[$serialNumber]["updated"] = time(); $this->WriteAttributeString("ObservationCache", json_encode($cache)); $this->SendDataToChildren(json_encode([ "DataID" => self::CHILD_EVENT_DATA_ID, "Buffer" => json_encode([ "type" => "Observation", "serialNumber" => $serialNumber, "id" => $observationID, "value" => $value, "timestamp" => time(), ]), ])); } private function CreateStateResponse(string $serialNumber): string { $cache = $this->ReadAttributeArray("ObservationCache"); return json_encode([ "success" => true, "connected" => $this->GetBuffer("SignalRReady") === "1", "state" => $cache[$serialNumber] ?? [], ]); } private function SetDynamicChargerCurrent(string $serialNumber, float $amps): array { if ($serialNumber === "") { return ["success" => false, "error" => "Seriennummer fehlt"]; } if ($amps < 0 || $amps > 32) { return ["success" => false, "error" => "Strom muss zwischen 0 und 32 A liegen"]; } $path = "/api/chargers/" . rawurlencode($serialNumber) . "/commands/set_dynamic_charger_current"; $body = '{"amps":' . $amps . ',"minutes":0}'; return $this->AuthorizedApiRequest( "POST", $path, $body ); } private function AuthorizedApiRequest(string $method, string $path, string $body): array { if (!$this->EnsureAccessToken()) { return ["success" => false, "error" => "Kein Easee-Access-Token"]; } $response = $this->HttpRequest( $method, self::API_BASE_URL . $path, $body, $this->GetBuffer("AccessToken"), "application/*+json" ); if ($response["httpCode"] === 401 && $this->RefreshCredentials(false)) { $response = $this->HttpRequest( $method, self::API_BASE_URL . $path, $body, $this->GetBuffer("AccessToken"), "application/*+json" ); } if (!$response["success"]) { $error = $response["error"]; if ($error === "") { $error = "Easee-HTTP-Fehler " . $response["httpCode"]; } return [ "success" => false, "error" => $error, "httpCode" => $response["httpCode"], "body" => $response["body"], ]; } return [ "success" => true, "httpCode" => $response["httpCode"], "body" => $response["body"], ]; } public function RefreshCredentials(bool $reconnect = true): bool { $tokens = null; $accessToken = $this->GetBuffer("AccessToken"); $refreshToken = $this->GetBuffer("RefreshToken"); if ($accessToken !== "" && $refreshToken !== "") { $tokens = $this->RequestTokenPair( self::API_BASE_URL . "/api/accounts/refresh_token", [ "accessToken" => $accessToken, "refreshToken" => $refreshToken, ], $accessToken ); } if ($tokens === null) { $tokens = $this->RequestTokenPair( self::API_BASE_URL . "/api/accounts/login", [ "userName" => $this->ReadPropertyString("Username"), "password" => $this->ReadPropertyString("Password"), ] ); } if ($tokens === null || !isset($tokens["accessToken"])) { $this->SetLastError("Easee-Anmeldung oder Token-Erneuerung fehlgeschlagen"); $this->SetStatus(202); return false; } $this->SetBuffer("AccessToken", (string)$tokens["accessToken"]); if (isset($tokens["refreshToken"])) { $this->SetBuffer("RefreshToken", (string)$tokens["refreshToken"]); } $this->SetBuffer("TokenUpdated", (string)time()); if ($reconnect) { $this->ConnectSignalR(); } return true; } private function EnsureAccessToken(): bool { if ($this->GetBuffer("AccessToken") !== "") { return true; } return $this->RefreshCredentials(false); } private function RequestTokenPair(string $url, array $payload, string $bearerToken = "") { $response = $this->HttpRequest( "POST", $url, json_encode($payload), $bearerToken ); if (!$response["success"]) { return null; } $data = json_decode($response["body"], true); return is_array($data) ? $data : null; } private function HttpRequest( string $method, string $url, string $body = "", string $bearerToken = "", string $contentType = "application/json" ): array { $headers = [ "Accept: application/json", "Content-Type: " . $contentType, ]; if ($bearerToken !== "") { $headers[] = "Authorization: Bearer " . $bearerToken; } $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_ENCODING => "", CURLOPT_TIMEOUT => 30, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 10, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => $body, ]); $responseBody = curl_exec($ch); $curlError = curl_error($ch); $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return [ "success" => $responseBody !== false && $curlError === "" && $httpCode >= 200 && $httpCode < 300, "body" => $responseBody === false ? "" : $responseBody, "error" => $curlError, "httpCode" => $httpCode, ]; } private function SetConnected(bool $connected) { $this->SetValue("Connected", $connected); $this->SendDataToChildren(json_encode([ "DataID" => self::CHILD_EVENT_DATA_ID, "Buffer" => json_encode([ "type" => "GatewayStatus", "connected" => $connected, ]), ])); } private function SetLastError(string $message) { $this->SetValue("LastError", $message); } private function IsSubscribedSerial(string $candidate): bool { return isset($this->ReadBufferArray("Subscriptions")[$candidate]); } private function IsList(array $value): bool { $index = 0; foreach ($value as $key => $_) { if ($key !== $index++) { return false; } } return true; } private function ReadAttributeArray(string $name): array { $value = json_decode($this->ReadAttributeString($name), true); return is_array($value) ? $value : []; } private function ReadBufferArray(string $name): array { $value = json_decode($this->GetBuffer($name), true); return is_array($value) ? $value : []; } }