diff --git a/Ladestation_OCPP/README.md b/Ladestation_OCPP/README.md index 93218b5..223ee7a 100644 --- a/Ladestation_OCPP/README.md +++ b/Ladestation_OCPP/README.md @@ -73,7 +73,7 @@ Details stehen in `docs/OCPP/WATTPILOT_GEN1.md`. OCPP braucht eine WebSocket-Verbindung zwischen Ladestation und CSMS. In dieser Architektur uebernimmt Symcon die CSMS-Rolle. Der Transport ist getrennt im Modul `OCPP_Server` vorbereitet. -Wichtig: Der erste Commit ist kein vollstaendiger produktiver OCPP-CSMS. Er dokumentiert und kapselt den Transport-Spike, damit echte Stationstests gezielt folgen koennen. +Wichtig: Der WebHook-Modus von `OCPP_Server` ist nur ein Diagnose-/Spike-Pfad. Fuer echte Wattpilot- und OCPP-Tests muss ein WebSocket-Transport die dauerhafte Session, Ping/Pong, Reconnect und aktiven CSMS-Push uebernehmen und Frames an `RouteInboundFrame`/`DequeueOutboundFrame` koppeln. Erst dann koennen `RemoteStartTransaction`, `SetChargingProfile` und `ChangeAvailability` zuverlaessig aktiv zur Ladestation gesendet werden. ## Dokumentation diff --git a/Ladestation_OCPP/module.php b/Ladestation_OCPP/module.php index 02ddda5..67f29bf 100644 --- a/Ladestation_OCPP/module.php +++ b/Ladestation_OCPP/module.php @@ -22,7 +22,8 @@ class Ladestation_OCPP extends IPSModule private const ATTR_CAPABILITIES = 'Capabilities'; private const ATTR_LAST_EMS_UPDATE = 'LastEmsUpdate'; private const ATTR_LAST_OCPP_HEARTBEAT = 'LastOcppHeartbeat'; - private const ATTR_PENDING_COMMAND = 'PendingCommand'; + private const ATTR_PENDING_COMMANDS = 'PendingCommands'; + private const ATTR_LAST_ENERGY_AGGREGATION = 'LastEnergyAggregation'; public function Create() { @@ -48,7 +49,7 @@ class Ladestation_OCPP extends IPSModule $this->RegisterPropertyInteger('MeterPowerIntervalSeconds', 1); $this->RegisterPropertyInteger('MeterEnergyIntervalSeconds', 60); $this->RegisterPropertyInteger('IdleCounterMax', 2); - $this->RegisterPropertyInteger('Interval', 1); + $this->RegisterPropertyInteger('Interval', 2); $this->RegisterPropertyInteger('PhaseSwitchHoldSeconds', 120); $this->RegisterPropertyInteger('PhaseSwitchPauseSeconds', 30); $this->RegisterPropertyBoolean('AllowAutomaticPhaseSwitch', false); @@ -65,7 +66,8 @@ class Ladestation_OCPP extends IPSModule $this->RegisterAttributeString(self::ATTR_CAPABILITIES, json_encode(CapabilityModel::defaults())); $this->RegisterAttributeInteger(self::ATTR_LAST_EMS_UPDATE, 0); $this->RegisterAttributeInteger(self::ATTR_LAST_OCPP_HEARTBEAT, 0); - $this->RegisterAttributeString(self::ATTR_PENDING_COMMAND, json_encode([])); + $this->RegisterAttributeString(self::ATTR_PENDING_COMMANDS, json_encode([])); + $this->RegisterAttributeInteger(self::ATTR_LAST_ENERGY_AGGREGATION, 0); $this->registerEmsVariables(); $this->registerControlVariables(); @@ -73,7 +75,7 @@ class Ladestation_OCPP extends IPSModule $this->registerMeterVariables(); $this->registerDiagnosticVariables(); - $this->RegisterTimer('Timer_Do_UserCalc', 1000, 'IPS_RequestAction(' . $this->InstanceID . ', "Do_UserCalc", "");'); + $this->RegisterTimer('Timer_Do_UserCalc', 2000, 'IPS_RequestAction(' . $this->InstanceID . ', "Do_UserCalc", "");'); $this->RegisterTimer('Timer_StatusWatchdog', 5000, 'IPS_RequestAction(' . $this->InstanceID . ', "StatusWatchdog", "");'); $this->RegisterTimer('Timer_EMSWatchdog', 5000, 'IPS_RequestAction(' . $this->InstanceID . ', "EMSWatchdog", "");'); $this->RegisterTimer('Timer_OCPPWatchdog', 5000, 'IPS_RequestAction(' . $this->InstanceID . ', "OCPPWatchdog", "");'); @@ -86,7 +88,7 @@ class Ladestation_OCPP extends IPSModule { parent::ApplyChanges(); - $interval = max(1, $this->ReadPropertyInteger('Interval')); + $interval = max(2, $this->ReadPropertyInteger('Interval')); $this->SetTimerInterval('Timer_Do_UserCalc', $interval * 1000); $this->SetTimerInterval('Timer_StatusWatchdog', 5000); $this->SetTimerInterval('Timer_EMSWatchdog', 5000); @@ -621,6 +623,7 @@ class Ladestation_OCPP extends IPSModule private function queueOcppCommandInternal(string $action, array $payload, int $retryCount, bool $force): void { + $this->processPendingCommands(); $version = $this->effectiveOcppVersion(); if ($action === 'SetChargingProfile' && !$force && !$this->canUseSmartCharging()) { $this->SetValue('LetzterCommandStatus', 'SetChargingProfile blockiert: Capability nicht bestaetigt.'); @@ -630,13 +633,17 @@ class Ladestation_OCPP extends IPSModule $message = OCPPMessage::call($action, $payload, $version, $this->ReadPropertyString('ChargePointId')); $this->SetValue('LetzterCommandTimestamp', time()); $this->SetValue('LetzterCommandStatus', 'Queued OCPP: ' . $action); - $this->WriteAttributeString(self::ATTR_PENDING_COMMAND, json_encode([ + $pendingCommands = $this->readPendingCommands(); + $pendingCommands[$message->uniqueId] = [ 'uniqueId' => $message->uniqueId, 'action' => $action, 'payload' => $payload, 'retryCount' => $retryCount, - 'timestamp' => time() - ])); + 'timestamp' => time(), + 'ackDeadline' => time() + max(5, $this->ReadPropertyInteger('CommandAckTimeoutSeconds')), + 'status' => 'pending' + ]; + $this->writePendingCommands($pendingCommands); $this->queueOcppMessage($message); } @@ -654,6 +661,8 @@ class Ladestation_OCPP extends IPSModule private function runWatchdogs(): void { + $this->processPendingCommands(); + $manager = new FailSafeManager(); $result = $manager->evaluate([ 'lastEmsUpdate' => $this->ReadAttributeInteger(self::ATTR_LAST_EMS_UPDATE), @@ -815,9 +824,21 @@ class Ladestation_OCPP extends IPSModule if ($this->GetValue('LetzterMeterValueZeitpunkt') > 0) { return; } + $now = time(); + $lastAggregation = $this->ReadAttributeInteger(self::ATTR_LAST_ENERGY_AGGREGATION); + if ($lastAggregation <= 0) { + $this->WriteAttributeInteger(self::ATTR_LAST_ENERGY_AGGREGATION, $now); + return; + } + $seconds = max(0, $now - $lastAggregation); + if ($seconds <= 0) { + return; + } + $energy = (float)$this->GetValue('Bezogene_Energie'); - $energy += (float)$this->GetValue('Ladeleistung_Effektiv') * (max(1, $this->ReadPropertyInteger('MeterEnergyIntervalSeconds')) / 3600); + $energy += ((float)$this->GetValue('Ladeleistung_Effektiv') * $seconds) / 3600.0; $this->SetValue('Bezogene_Energie', $energy); + $this->WriteAttributeInteger(self::ATTR_LAST_ENERGY_AGGREGATION, $now); } private function extractIdToken(array $payload): string @@ -970,16 +991,19 @@ class Ladestation_OCPP extends IPSModule private function handleCallResult(OCPPMessage $message): void { - $pending = $this->readPendingCommand(); - if (($pending['uniqueId'] ?? '') !== $message->uniqueId) { + $pendingCommands = $this->readPendingCommands(); + if (!isset($pendingCommands[$message->uniqueId])) { $this->SetValue('LetzterCommandStatus', 'CallResult ohne passenden Pending Command: ' . $message->uniqueId); return; } + $pending = $pendingCommands[$message->uniqueId]; + unset($pendingCommands[$message->uniqueId]); + $this->writePendingCommands($pendingCommands); + $action = (string)($pending['action'] ?? ''); $status = (string)($message->payload['status'] ?? 'Accepted'); $this->SetValue('LetzterCommandStatus', $action . ' bestaetigt: ' . $status); - $this->WriteAttributeString(self::ATTR_PENDING_COMMAND, json_encode([])); if ($action === 'SetChargingProfile') { if ($status === 'Accepted') { @@ -997,7 +1021,13 @@ class Ladestation_OCPP extends IPSModule private function handleCallError(OCPPMessage $message): void { - $pending = $this->readPendingCommand(); + $pendingCommands = $this->readPendingCommands(); + $pending = $pendingCommands[$message->uniqueId] ?? []; + if (isset($pendingCommands[$message->uniqueId])) { + unset($pendingCommands[$message->uniqueId]); + $this->writePendingCommands($pendingCommands); + } + $action = (string)($pending['action'] ?? 'Unknown'); $code = (string)($message->payload['errorCode'] ?? $message->action); $description = (string)($message->payload['errorDescription'] ?? ''); @@ -1010,7 +1040,7 @@ class Ladestation_OCPP extends IPSModule return; } - $this->WriteAttributeString(self::ATTR_PENDING_COMMAND, json_encode([])); + $this->writePendingCommands($pendingCommands); } private function retryOrBlockSmartCharging(array $pending, string $reason): void @@ -1019,9 +1049,22 @@ class Ladestation_OCPP extends IPSModule $limit = max(0, $this->ReadPropertyInteger('SmartChargingRetryLimit')); if ($retryCount < $limit) { $next = $retryCount + 1; + $delay = min(300, (int)pow(2, $next) * 5); + $retryKey = 'retry-' . str_replace('.', '', uniqid('', true)); + $pendingCommands = $this->readPendingCommands(); + $pendingCommands[$retryKey] = [ + 'uniqueId' => $retryKey, + 'action' => 'SetChargingProfile', + 'payload' => (array)($pending['payload'] ?? []), + 'retryCount' => $next, + 'timestamp' => time(), + 'nextRetryAt' => time() + $delay, + 'status' => 'retry_wait', + 'reason' => $reason + ]; + $this->writePendingCommands($pendingCommands); $this->SetValue('WattpilotProfileRetries', $next); - $this->SetValue('LetzterCommandStatus', 'SetChargingProfile Retry ' . $next . '/' . $limit . ': ' . $reason); - $this->queueOcppCommandInternal('SetChargingProfile', (array)($pending['payload'] ?? []), $next, true); + $this->SetValue('LetzterCommandStatus', 'SetChargingProfile Retry ' . $next . '/' . $limit . ' in ' . $delay . 's: ' . $reason); return; } @@ -1030,7 +1073,6 @@ class Ladestation_OCPP extends IPSModule $this->writeCapabilities($capabilities); $this->SetValue('LetztesChargingProfileAccepted', false); $this->SetValue('WattpilotSmartChargingBlocked', true); - $this->WriteAttributeString(self::ATTR_PENDING_COMMAND, json_encode([])); $this->setDiagnostic('Warnung', 'Wattpilot Gen1', 'SmartCharging nach ' . $limit . ' Retries deaktiviert: ' . $reason); } @@ -1082,10 +1124,71 @@ class Ladestation_OCPP extends IPSModule $this->SetValue('Capability_SimpleAmpereProfiles', (bool)($capabilities['simpleAmpereProfiles'] ?? false)); } - private function readPendingCommand(): array + private function readPendingCommands(): array { - $pending = json_decode($this->ReadAttributeString(self::ATTR_PENDING_COMMAND), true); - return is_array($pending) ? $pending : []; + $pending = json_decode($this->ReadAttributeString(self::ATTR_PENDING_COMMANDS), true); + if (!is_array($pending)) { + return []; + } + if (isset($pending['uniqueId'])) { + return [(string)$pending['uniqueId'] => $pending]; + } + return $pending; + } + + private function writePendingCommands(array $pendingCommands): void + { + $this->WriteAttributeString(self::ATTR_PENDING_COMMANDS, json_encode($pendingCommands)); + } + + private function processPendingCommands(): void + { + $pendingCommands = $this->readPendingCommands(); + if (empty($pendingCommands)) { + return; + } + + $now = time(); + $changed = false; + foreach ($pendingCommands as $uniqueId => $pending) { + $status = (string)($pending['status'] ?? 'pending'); + if ($status === 'retry_wait') { + if ($now >= (int)($pending['nextRetryAt'] ?? 0)) { + unset($pendingCommands[$uniqueId]); + $this->writePendingCommands($pendingCommands); + $this->queueOcppCommandInternal( + (string)($pending['action'] ?? 'SetChargingProfile'), + (array)($pending['payload'] ?? []), + (int)($pending['retryCount'] ?? 0), + true + ); + $pendingCommands = $this->readPendingCommands(); + $changed = true; + } + continue; + } + + if ($status === 'pending' && $now > (int)($pending['ackDeadline'] ?? 0)) { + unset($pendingCommands[$uniqueId]); + $changed = true; + if ((string)($pending['action'] ?? '') === 'SetChargingProfile') { + $this->writePendingCommands($pendingCommands); + $this->retryOrBlockSmartCharging($pending, 'ACK timeout'); + $pendingCommands = $this->readPendingCommands(); + } else { + $this->SetValue('LetzterCommandStatus', (string)($pending['action'] ?? 'Command') . ' ACK timeout'); + } + } + + if ($now - (int)($pending['timestamp'] ?? $now) > 3600) { + unset($pendingCommands[$uniqueId]); + $changed = true; + } + } + + if ($changed) { + $this->writePendingCommands($pendingCommands); + } } private function updateMeterCapabilities(array $values): void diff --git a/OCPP_Server/README.md b/OCPP_Server/README.md index df5c5be..f5400c1 100644 --- a/OCPP_Server/README.md +++ b/OCPP_Server/README.md @@ -7,18 +7,24 @@ - Vorbereitung der CSMS-Transportrolle innerhalb von IP-Symcon. - Routing von eingehenden OCPP-Frames nach `ChargePointId`, `EVSEId` und `ConnectorId`. - Entgegennahme von ausgehenden Frames aus `Ladestation_OCPP`. -- Pufferung ausgehender Frames je `ChargePointId` fuer den WebHook/WebSocket-Spike. -- Dokumentation des WebHook/WebSocket-Spikes. +- Pufferung ausgehender Frames je `ChargePointId`. +- Dokumentation des Transport-Spikes und Vorbereitung eines echten WebSocket-Adapters. ## Status -Dieses Modul ist bewusst ein Scaffold. Symcon bietet einen WebSocket Client sowie WebHook Control mit WebSocket-Support und `ProcessHookData()` fuer PHP-Module. Ob diese Mechanik fuer dauerhafte OCPP-CSMS-Verbindungen mit realen Ladestationen robust genug ist, muss mit einer OCPP-Referenzstation, einem Simulator oder dem Fronius Wattpilot getestet werden. +Dieses Modul ist bewusst ein Scaffold. Der WebHook-Pfad ist nur fuer Diagnose und Spike geeignet. Er ist kein vollwertiger OCPP-CSMS-WebSocket-Server, weil ein Charge Point fuer OCPP eine dauerhafte bidirektionale WebSocket-Session und asynchronen CSMS-Push erwartet. -Eingehende Calls werden an `Ladestation_OCPP` geroutet. Die dort erzeugten OCPP-CallResults werden in `OCPP_Server` gepuffert und fuer denselben `ChargePointId` wieder ausgegeben, sofern der Symcon-Hook diesen Rueckkanal bereitstellt. +Produktiv muss `TransportMode` auf einen echten WebSocket-Transport zeigen: + +- `external_websocket_adapter`: lokaler Adapter/Service uebernimmt WebSocket-Handshake, Sessions, Ping/Pong und Push. +- `symcon_websocket_parent`: spaeterer Symcon-Parent/Splitter mit echter WebSocket-Server-Funktion. + +`webhook_spike` bleibt nur fuer Tests, bei denen eingehende Frames synchron verarbeitet und direkte CallResults zurueckgegeben werden. RemoteStart, SetChargingProfile und ChangeAvailability koennen damit nicht zuverlaessig aktiv gepusht werden. ## Konfiguration - `HookPath`: Standard `/hook/ocpp` +- `TransportMode`: `webhook_spike`, `external_websocket_adapter` oder `symcon_websocket_parent` - `DefaultTargetInstance`: Zielinstanz, wenn kein spezifisches Routing gefunden wird - `Ladepunkte`: optionale Routingliste je ChargePoint/EVSE/Connector - `HeartbeatSeconds`: Watchdog-Basis @@ -26,3 +32,12 @@ Eingehende Calls werden an `Ladestation_OCPP` geroutet. Die dort erzeugten OCPP- ## Zusammenspiel `Ladestation_OCPP` bleibt das fachliche Ladepunktmodul und setzt den EMS-Vertrag um. `OCPP_Server` bleibt Transport/Routing. Mehrere Ladepunkte koennen spaeter ueber denselben Transport angebunden werden. + +## Adapter-Vertrag + +Ein echter WebSocket-Transport muss die dauerhafte Session halten und nur Frames an `OCPP_Server` koppeln: + +- Inbound vom Charge Point: `RouteInboundFrame` oder `ReceiveExternalFrame` mit JSON `{"ChargePointId":"...","Frame":"[...]","Remote":"ip:port"}` aufrufen. +- Rueckgabe von `RouteInboundFrame` sofort als OCPP-CallResult/CallError ueber dieselbe WebSocket-Session senden, falls nicht leer. +- Outbound vom CSMS: `DequeueOutboundFrame()` abrufen und ueber die bestehende WebSocket-Session aktiv an den Charge Point senden. +- Der Adapter muss WebSocket-Handshake, Ping/Pong, Close, Reconnect, Fragmentierung und Session-Zuordnung selbst robust behandeln. diff --git a/OCPP_Server/form.json b/OCPP_Server/form.json index 64fde43..286ba50 100644 --- a/OCPP_Server/form.json +++ b/OCPP_Server/form.json @@ -2,7 +2,17 @@ "elements": [ { "type": "Label", - "caption": "Transport-Scaffold fuer OCPP. WebHook/WebSocket wird als technischer Spike dokumentiert." + "caption": "Transport-Scaffold fuer OCPP. WebHook ist nur ein Spike; produktiv ist ein echter WebSocket-Transport erforderlich." + }, + { + "type": "Select", + "name": "TransportMode", + "caption": "Transportmodus", + "options": [ + { "caption": "WebHook Spike / Diagnose", "value": "webhook_spike" }, + { "caption": "Externer WebSocket Adapter", "value": "external_websocket_adapter" }, + { "caption": "Symcon WebSocket Parent/Splitter", "value": "symcon_websocket_parent" } + ] }, { "type": "CheckBox", diff --git a/OCPP_Server/libs/OCPPFrameRouter.php b/OCPP_Server/libs/OCPPFrameRouter.php index 1c1b1f3..333637f 100644 --- a/OCPP_Server/libs/OCPPFrameRouter.php +++ b/OCPP_Server/libs/OCPPFrameRouter.php @@ -8,13 +8,14 @@ class OCPPFrameRouter if (!is_array($route)) { continue; } - if ((string)($route['ChargePointId'] ?? '') !== $chargePointId) { + $routeChargePointId = (string)($route['ChargePointId'] ?? ($route['chargePointId'] ?? '')); + if ($routeChargePointId !== $chargePointId) { continue; } - $routeEvse = (int)($route['EVSEId'] ?? 1); - $routeConnector = (int)($route['ConnectorId'] ?? 1); + $routeEvse = (int)($route['EVSEId'] ?? ($route['evseId'] ?? 1)); + $routeConnector = (int)($route['ConnectorId'] ?? ($route['connectorId'] ?? 1)); if ($routeEvse === $evseId && $routeConnector === $connectorId) { - return (int)($route['TargetInstance'] ?? 0); + return (int)($route['TargetInstance'] ?? ($route['instanceId'] ?? 0)); } } diff --git a/OCPP_Server/libs/WebSocketEndpoint.php b/OCPP_Server/libs/WebSocketEndpoint.php index 9678c78..7ae41d1 100644 --- a/OCPP_Server/libs/WebSocketEndpoint.php +++ b/OCPP_Server/libs/WebSocketEndpoint.php @@ -2,12 +2,22 @@ class WebSocketEndpoint { - public static function supportSummary(bool $registerHookAvailable, string $hookPath): array + public static function supportSummary(bool $registerHookAvailable, string $hookPath, string $transportMode = 'webhook_spike'): array { + if ($transportMode !== 'webhook_spike') { + return [ + 'status' => 'Externer WebSocket-Transport', + 'detail' => 'OCPP_Server erwartet einen echten WebSocket-Server/Parent oder lokalen Adapter und uebernimmt nur Routing und Protokollzustand.', + 'warning' => 'WebHook Control wird in diesem Modus nicht als OCPP-CSMS-Endpunkt verwendet.', + 'hookPath' => $hookPath + ]; + } + if ($registerHookAvailable) { return [ - 'status' => 'Hook vorbereitet', - 'detail' => 'Symcon RegisterHook ist verfuegbar. WebSocket-Dauerbetrieb muss mit echter OCPP-Station verifiziert werden.', + 'status' => 'WebHook-Spike', + 'detail' => 'RegisterHook ist verfuegbar, aber der WebHook-Pfad ist kein vollwertiger OCPP-CSMS-WebSocket-Server mit garantiertem Async-Push.', + 'warning' => 'Nur fuer Spike/Diagnose verwenden. Produktiv ist ein echter WebSocket-Server/Parent erforderlich.', 'hookPath' => $hookPath ]; } @@ -15,6 +25,7 @@ class WebSocketEndpoint return [ 'status' => 'Hook manuell/spaeter', 'detail' => 'RegisterHook ist in dieser Symcon-Umgebung nicht als Modul-Methode verfuegbar. WebHook Control muss manuell oder nach Upgrade verbunden werden.', + 'warning' => 'Kein produktiver OCPP-WebSocket-Transport aktiv.', 'hookPath' => $hookPath ]; } diff --git a/OCPP_Server/module.php b/OCPP_Server/module.php index 1ce234d..8384b35 100644 --- a/OCPP_Server/module.php +++ b/OCPP_Server/module.php @@ -13,6 +13,7 @@ class OCPP_Server extends IPSModule { parent::Create(); + $this->RegisterPropertyString('TransportMode', 'webhook_spike'); $this->RegisterPropertyBoolean('EnableWebhook', true); $this->RegisterPropertyString('HookPath', '/hook/ocpp'); $this->RegisterPropertyInteger('DefaultTargetInstance', 0); @@ -30,6 +31,9 @@ class OCPP_Server extends IPSModule $this->RegisterVariableInteger('ConnectionCount', 'ConnectionCount', '', 30); $this->RegisterVariableInteger('LastMessageTime', 'LastMessageTime', '', 31); $this->RegisterVariableInteger('OutboundQueueCount', 'OutboundQueueCount', '', 32); + $this->RegisterVariableString('TransportModeStatus', 'TransportModeStatus', '', 33); + $this->RegisterVariableBoolean('OutboundPushSupported', 'OutboundPushSupported', '~Switch', 34); + $this->RegisterVariableString('TransportWarning', 'TransportWarning', '', 35); $this->RegisterVariableString('WebSocketSupportStatus', 'WebSocketSupportStatus', '', 40); $this->RegisterVariableString('LetzteMeldung', 'LetzteMeldung', '', 41); $this->RegisterVariableInteger('LetzteMeldungZeit', 'LetzteMeldungZeit', '', 42); @@ -46,11 +50,15 @@ class OCPP_Server extends IPSModule parent::ApplyChanges(); $this->SetTimerInterval('Timer_TransportWatchdog', max(5, $this->ReadPropertyInteger('HeartbeatSeconds')) * 1000); - $summary = WebSocketEndpoint::supportSummary(method_exists($this, 'RegisterHook'), $this->ReadPropertyString('HookPath')); + $transportMode = $this->ReadPropertyString('TransportMode'); + $summary = WebSocketEndpoint::supportSummary(method_exists($this, 'RegisterHook'), $this->ReadPropertyString('HookPath'), $transportMode); $this->SetValue('WebSocketSupportStatus', $summary['status'] . ': ' . $summary['detail']); + $this->SetValue('TransportModeStatus', $transportMode); + $this->SetValue('OutboundPushSupported', $transportMode !== 'webhook_spike'); + $this->SetValue('TransportWarning', $summary['warning']); $this->SetSummary($summary['status']); - if ($this->ReadPropertyBoolean('EnableWebhook')) { + if ($transportMode === 'webhook_spike' && $this->ReadPropertyBoolean('EnableWebhook')) { $this->tryRegisterHook(); } @@ -74,12 +82,13 @@ class OCPP_Server extends IPSModule break; case 'RouteInboundFrame': - $this->RouteInboundFrame((string)$Value); - break; + return $this->RouteInboundFrame((string)$Value); + + case 'ReceiveExternalFrame': + return $this->RouteInboundFrame((string)$Value); case 'DequeueOutboundFrame': - $this->DequeueOutboundFrame((string)$Value); - break; + return $this->DequeueOutboundFrame((string)$Value); case 'TransportWatchdog': $this->TransportWatchdog(); @@ -101,6 +110,16 @@ class OCPP_Server extends IPSModule protected function ProcessHookData($JSONString = '') { + if ($this->ReadPropertyString('TransportMode') !== 'webhook_spike') { + header('HTTP/1.1 501 Not Implemented'); + header('Content-Type: application/json'); + echo json_encode([ + 'status' => 'rejected', + 'reason' => 'ProcessHookData ist nur fuer den WebHook-Spike aktiv. Produktiver OCPP-Betrieb braucht einen echten WebSocket-Transport.' + ]); + return; + } + $raw = WebSocketEndpoint::readRawBody(); if ($raw === '' && is_string($JSONString)) { $raw = $JSONString; @@ -141,7 +160,11 @@ class OCPP_Server extends IPSModule $this->SetValue('LastOutboundFrame', $frame); $this->SetValue('LastMessageTime', time()); $this->enqueueOutboundFrame($chargePointId, $frame); - $this->setMessage('Outbound Frame fuer ' . ($chargePointId === '' ? 'unbekannt' : $chargePointId) . ' gepuffert.'); + if ($this->ReadPropertyString('TransportMode') === 'webhook_spike') { + $this->setMessage('Outbound Frame gepuffert, aber WebHook-Spike kann keinen echten Async-Push garantieren.'); + return; + } + $this->setMessage('Outbound Frame fuer echten WebSocket-Transport gepuffert: ' . ($chargePointId === '' ? 'unbekannt' : $chargePointId)); } public function RouteInboundFrame(string $json): string @@ -250,7 +273,7 @@ class OCPP_Server extends IPSModule { $last = (int)$this->GetValue('LastMessageTime'); if ($last === 0) { - $this->SetValue('TransportStatus', 'Wartet auf OCPP Verbindung'); + $this->SetValue('TransportStatus', $this->ReadPropertyString('TransportMode') === 'webhook_spike' ? 'WebHook-Spike wartet' : 'Wartet auf WebSocket Verbindung'); return; } @@ -261,7 +284,7 @@ class OCPP_Server extends IPSModule return; } - $this->SetValue('TransportStatus', 'Aktiv/Scaffold'); + $this->SetValue('TransportStatus', $this->ReadPropertyString('TransportMode') === 'webhook_spike' ? 'Aktiv/WebHook-Spike' : 'Aktiv/WebSocket-Transport'); } private function tryRegisterHook(): void @@ -270,7 +293,7 @@ class OCPP_Server extends IPSModule if (method_exists($this, 'RegisterHook')) { try { $this->RegisterHook($hook); - $this->SetValue('WebSocketSupportStatus', 'RegisterHook aufgerufen fuer ' . $hook . '. WebSocket-Dauerbetrieb noch testen.'); + $this->SetValue('WebSocketSupportStatus', 'RegisterHook aufgerufen fuer ' . $hook . '. Nur WebHook-Spike, kein produktiver OCPP-WebSocket-Dauerbetrieb.'); $this->setMessage('Webhook registriert: ' . $hook); return; } catch (Throwable $e) { diff --git a/docs/OCPP/README.md b/docs/OCPP/README.md index d48d05b..4e10065 100644 --- a/docs/OCPP/README.md +++ b/docs/OCPP/README.md @@ -22,7 +22,7 @@ Verbindliche Grundsaetze: `Ladestation_OCPP` ist die fachliche Ladepunkt-/Connector-Instanz. Sie bildet Status, Messwerte, Managementmodi, Diagnose und EMS-Schnittstelle ab. -`OCPP_Server` ist ein separates Transport-Scaffold. Es bereitet WebHook/WebSocket, Routing und Frame-Puffer vor. Die Trennung ist wichtig, weil mehrere Ladepunkte an einem Transport haengen koennen. +`OCPP_Server` ist ein separates Transport-Scaffold. Es bereitet Routing, Frame-Puffer und die Kopplung an einen echten WebSocket-Transport vor. Die Trennung ist wichtig, weil mehrere Ladepunkte an einem Transport haengen koennen. ## EMS-Vertrag @@ -68,7 +68,7 @@ Sichtbar und vorbereitet: Noch nicht produktiv fertig: -- echte dauerhafte WebSocket-CSMS-Verbindung mit realer Station. +- echte dauerhafte WebSocket-CSMS-Verbindung ohne externen/Parent-WebSocket-Transport. - vollstaendige OCPP-1.6/2.0.1 State Machine. - automatische 1p/3p-Umschaltung mit Hardwarefreigabe. - bidirektionales Laden. @@ -106,7 +106,7 @@ Ausgehend vorbereitet: - `ClearChargingProfile` - `Reset` -## Transport-Spike +## Transport und WebSocket OCPP erwartet, dass die Ladestation eine WebSocket-Verbindung zum CSMS aufbaut. Symcon muss hier die CSMS-Rolle uebernehmen. @@ -118,9 +118,18 @@ Relevante Symcon-Optionen: Bewertung fuer Phase 1: -- `OCPP_Server` kapselt diese Frage, damit `Ladestation_OCPP` fachlich stabil bleibt. -- Der produktive Rueckkanal und Dauerbetrieb muessen mit Simulator oder OCPP-1.6-Referenzstation getestet werden. -- Ohne bestaetigten Transport wird kein externer Middleware-Dienst als Standard eingebaut. +- `ProcessHookData()` und WebHook Control sind nur als WebHook-Spike zu betrachten. +- Ein produktiver OCPP-CSMS braucht eine persistente bidirektionale WebSocket-Session mit Async-Push, Ping/Pong und Session-Lifecycle. +- `OCPP_Server` kapselt Protokollzustand, Routing und Pufferung, ist aber nicht selbst der vollstaendige WebSocket-Handshake-/Frame-Server. +- Produktiv ist ein echter WebSocket-Parent/Splitter oder ein lokaler Transport-Adapter noetig. Dieser wird nicht automatisch eingebaut. +- Der WebHook-Spike darf fuer Diagnose und einzelne synchrone CallResult-Tests verwendet werden. + +Adapter-Vertrag fuer einen echten Transport: + +- eingehende Frames mit `ChargePointId`, Roh-Frame und Remote-Adresse an `RouteInboundFrame`/`ReceiveExternalFrame` uebergeben. +- direkte Rueckgabe aus `RouteInboundFrame` ueber dieselbe WebSocket-Session senden. +- gepufferte ausgehende Frames ueber `DequeueOutboundFrame()` holen und aktiv zur bestehenden Session pushen. +- Handshake, Ping/Pong, Close, Reconnect, Fragmentierung und Session-Lifecycle liegen beim Transport. Quellen: diff --git a/docs/OCPP/WATTPILOT_GEN1.md b/docs/OCPP/WATTPILOT_GEN1.md index 8d2b56e..1810a87 100644 --- a/docs/OCPP/WATTPILOT_GEN1.md +++ b/docs/OCPP/WATTPILOT_GEN1.md @@ -19,14 +19,15 @@ Der Wattpilot wird als unterstuetztes Geraet behandelt, aber nicht als Referenz ## Symcon-Konfiguration 1. `OCPP_Server` Instanz anlegen. -2. `HookPath` auf `/hook/ocpp` lassen oder passend setzen. -3. `Ladestation_OCPP` Instanz anlegen. -4. `DeviceProfile` auf `Fronius Wattpilot Gen1 OCPP 1.6J` setzen. -5. `OCPPVersionMode` auf `OCPP 1.6` oder `Automatisch` setzen. -6. `ChargePointId` exakt so setzen, wie sie im Wattpilot konfiguriert wird. -7. `OCPPServerInstance` auf die `OCPP_Server` Instanz setzen. -8. Im `OCPP_Server` entweder `DefaultTargetInstance` auf die Ladestationsinstanz setzen oder unter `Ladepunkte` eine Route fuer `ChargePointId`, `EVSEId = 1`, `ConnectorId = 1` anlegen. -9. Die `Ladestation_OCPP` Instanz im bestehenden `Manager` als Verbraucher eintragen. +2. `TransportMode` fuer echte Tests auf `external_websocket_adapter` oder spaeter `symcon_websocket_parent` setzen. `webhook_spike` ist nur Diagnose. +3. `HookPath` auf `/hook/ocpp` lassen oder passend setzen, falls der Spike bewusst genutzt wird. +4. `Ladestation_OCPP` Instanz anlegen. +5. `DeviceProfile` auf `Fronius Wattpilot Gen1 OCPP 1.6J` setzen. +6. `OCPPVersionMode` auf `OCPP 1.6` oder `Automatisch` setzen. +7. `ChargePointId` exakt so setzen, wie sie im Wattpilot konfiguriert wird. +8. `OCPPServerInstance` auf die `OCPP_Server` Instanz setzen. +9. Im `OCPP_Server` entweder `DefaultTargetInstance` auf die Ladestationsinstanz setzen oder unter `Ladepunkte` eine Route fuer `ChargePointId`, `EVSEId = 1`, `ConnectorId = 1` anlegen. +10. Die `Ladestation_OCPP` Instanz im bestehenden `Manager` als Verbraucher eintragen. ## Wattpilot-Konfiguration @@ -44,7 +45,7 @@ Bei TLS entsprechend: wss:///hook/ocpp/ ``` -Wenn der Wattpilot im Status nicht bis "connected and accepted" kommt, ist zuerst der Symcon-WebHook/WebSocket-Rueckkanal zu pruefen. Es wird kein externer Transport-Adapter automatisch eingebaut. +Wenn der Wattpilot im Status nicht bis "connected and accepted" kommt, ist zuerst der echte WebSocket-Transport zu pruefen. WebHook Control reicht fuer produktiven OCPP-Betrieb nicht als garantierter CSMS-Server. Es wird kein externer Transport-Adapter automatisch eingebaut. ## Implementierter OCPP-1.6J-Pfad @@ -85,7 +86,7 @@ Nicht verwendet: - `phaseToUse` - asymmetrische Phasenprofile -Wenn `SetChargingProfile` abgelehnt wird, versucht das Modul maximal `SmartChargingRetryLimit` Wiederholungen. Danach wird `Capability_SmartCharging = false` gesetzt und weitere Profile werden blockiert, bis "SmartCharging testen" erneut ausgefuehrt wird. +Wenn `SetChargingProfile` abgelehnt wird oder kein ACK kommt, plant das Modul maximal `SmartChargingRetryLimit` Wiederholungen mit Backoff. Danach wird `Capability_SmartCharging = false` gesetzt und weitere Profile werden blockiert, bis "SmartCharging testen" erneut ausgefuehrt wird. ## Testablauf @@ -117,7 +118,7 @@ Wenn `SetChargingProfile` abgelehnt wird, versucht das Modul maximal `SmartCharg ## Bekannte Grenze -`OCPP_Server` bleibt ein Symcon-Transport-Spike. Er puffert ausgehende Frames pro ChargePoint und gibt direkte CallResults zurueck. Ob die WebHook/WebSocket-Mechanik in der konkreten Symcon-Windows-Installation fuer dauerhafte OCPP-Verbindungen ausreicht, muss mit dem Wattpilot oder einem OCPP-1.6J-Simulator bestaetigt werden. +`OCPP_Server` bleibt ein Protokoll-/Routing-Modul. Der WebHook-Modus ist nur ein Spike und kann keine stabile dauerhafte WebSocket-Session mit aktivem CSMS-Push garantieren. Fuer produktive Wattpilot-Tests muss ein echter WebSocket-Server/Parent oder ein freigegebener lokaler Transport-Adapter die WebSocket-Verbindung halten und Frames an `RouteInboundFrame`/`QueueOutboundFrame` koppeln. ## Quellen