Files
2026-08-06 15:14:16 +02:00

777 lines
25 KiB
PHP

<?php
class EaseeGateway extends IPSModule
{
private const WEBSOCKET_MODULE_ID = "{D68FD31F-0E90-7019-F16C-1949BD3079EF}";
private const SIMPLE_RX_DATA_ID = "{018EF6B5-AB94-40C6-AA53-46943E824ACF}";
private const SIMPLE_TX_DATA_ID = "{79827379-F36E-4ADA-8A95-5F8D1DC92FA9}";
private const CHILD_REQUEST_DATA_ID = "{BC4E9B83-9C9B-44C3-A902-5CEB45B0E5B1}";
private const CHILD_EVENT_DATA_ID = "{A8EF2E37-6B35-4E92-A7D1-4F8172790BA2}";
private const SIGNALR_BASE_URL = "https://streams.easee.com/hubs/chargers";
private const API_BASE_URL = "https://api.easee.com";
private const RECORD_SEPARATOR = "\x1e";
public function Create()
{
parent::Create();
$this->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 &&
in_array($observationID, [47, 109, 110, 120, 182, 183, 184, 185], true)
) {
$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] = [];
}
if ($observationID === 109) {
$previousMode = (int)($cache[$serialNumber]["109"] ?? -1);
if ((int)$value === 1 || $previousMode === 1) {
foreach ([110, 182, 183, 184, 185] as $sessionObservationID) {
$cache[$serialNumber][(string)$sessionObservationID] = 0;
}
}
}
$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 : [];
}
}