Update Websocket und weiteres.

This commit is contained in:
2026-05-10 17:26:05 +02:00
parent e240b17d3d
commit 233aa56d40
9 changed files with 233 additions and 60 deletions
+1 -1
View File
@@ -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
+123 -20
View File
@@ -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
+19 -4
View File
@@ -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(<ChargePointId>)` 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.
+11 -1
View File
@@ -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",
+5 -4
View File
@@ -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));
}
}
+14 -3
View File
@@ -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
];
}
+33 -10
View File
@@ -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) {
+15 -6
View File
@@ -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(<ChargePointId>)` holen und aktiv zur bestehenden Session pushen.
- Handshake, Ping/Pong, Close, Reconnect, Fragmentierung und Session-Lifecycle liegen beim Transport.
Quellen:
+12 -11
View File
@@ -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://<symcon-host>/hook/ocpp/<ChargePointId>
```
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