RegisterPropertyString('TransportMode', 'webhook_spike'); $this->RegisterPropertyBoolean('EnableWebhook', true); $this->RegisterPropertyString('HookPath', '/hook/ocpp'); $this->RegisterPropertyInteger('DefaultTargetInstance', 0); $this->RegisterPropertyString('Ladepunkte', json_encode([])); $this->RegisterPropertyInteger('HeartbeatSeconds', 30); $this->RegisterPropertyInteger('DebugLevel', 0); $this->RegisterAttributeString(self::ATTR_CONNECTIONS, ConnectionRegistry::toJson(ConnectionRegistry::empty())); $this->RegisterAttributeString(self::ATTR_OUTBOUND_QUEUES, json_encode([])); $this->RegisterVariableString('TransportStatus', 'TransportStatus', '', 10); $this->RegisterVariableString('LastInboundFrame', 'LastInboundFrame', '', 20); $this->RegisterVariableString('LastOutboundFrame', 'LastOutboundFrame', '', 21); $this->RegisterVariableString('LastRouteResult', 'LastRouteResult', '', 22); $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); $this->RegisterTimer('Timer_TransportWatchdog', 30000, 'IPS_RequestAction(' . $this->InstanceID . ', "TransportWatchdog", "");'); $this->SetValue('TransportStatus', 'Scaffold'); $this->SetValue('WebSocketSupportStatus', 'Nicht geprueft'); $this->SetValue('LetzteMeldung', 'OCPP Server Scaffold initialisiert'); } public function ApplyChanges() { parent::ApplyChanges(); $this->SetTimerInterval('Timer_TransportWatchdog', max(5, $this->ReadPropertyInteger('HeartbeatSeconds')) * 1000); $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 ($transportMode === 'webhook_spike' && $this->ReadPropertyBoolean('EnableWebhook')) { $this->tryRegisterHook(); } $defaultTarget = $this->ReadPropertyInteger('DefaultTargetInstance'); if ($defaultTarget > 0) { $this->RegisterReference($defaultTarget); } $this->SetStatus(102); } public function RequestAction($Ident, $Value) { switch ($Ident) { case 'RegisterHook': $this->tryRegisterHook(); break; case 'QueueOutboundFrame': $this->QueueOutboundFrame((string)$Value); break; case 'RouteInboundFrame': return $this->RouteInboundFrame((string)$Value); case 'ReceiveExternalFrame': return $this->RouteInboundFrame((string)$Value); case 'DequeueOutboundFrame': return $this->DequeueOutboundFrame((string)$Value); case 'TransportWatchdog': $this->TransportWatchdog(); break; case 'ClearBuffers': $this->SetValue('LastInboundFrame', ''); $this->SetValue('LastOutboundFrame', ''); $this->SetValue('LastRouteResult', ''); $this->WriteAttributeString(self::ATTR_OUTBOUND_QUEUES, json_encode([])); $this->SetValue('OutboundQueueCount', 0); $this->setMessage('Puffer geloescht'); break; default: throw new Exception('Invalid Ident'); } } 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; } $path = $_SERVER['REQUEST_URI'] ?? $this->ReadPropertyString('HookPath'); $chargePointId = (new OCPPFrameRouter())->extractChargePointId((string)$path); $responseFrame = $this->RouteInboundFrame(json_encode([ 'ChargePointId' => $chargePointId, 'Frame' => $raw, 'Remote' => ($_SERVER['REMOTE_ADDR'] ?? '') . ':' . ($_SERVER['REMOTE_PORT'] ?? '') ])); header('Content-Type: application/json'); if ($responseFrame !== '') { echo $responseFrame; return; } echo json_encode(['status' => 'accepted']); } public function QueueOutboundFrame(string $json): void { $envelope = json_decode($json, true); if (!is_array($envelope)) { $envelope = [ 'ChargePointId' => '', 'Frame' => $json, 'Timestamp' => time() ]; } $frame = (string)($envelope['Frame'] ?? $json); $chargePointId = (string)($envelope['ChargePointId'] ?? ''); $this->SetValue('LastOutboundFrame', $frame); $this->SetValue('LastMessageTime', time()); $this->enqueueOutboundFrame($chargePointId, $frame); 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 { $data = json_decode($json, true); if (!is_array($data)) { $data = [ 'ChargePointId' => '', 'Frame' => $json, 'Remote' => '' ]; } $chargePointId = (string)($data['ChargePointId'] ?? ''); $frame = (string)($data['Frame'] ?? ''); $remote = (string)($data['Remote'] ?? ''); $this->SetValue('LastInboundFrame', $frame); $this->SetValue('LastMessageTime', time()); $connections = ConnectionRegistry::touch( ConnectionRegistry::fromJson($this->ReadAttributeString(self::ATTR_CONNECTIONS)), $chargePointId, $remote ); $this->WriteAttributeString(self::ATTR_CONNECTIONS, ConnectionRegistry::toJson($connections)); $this->SetValue('ConnectionCount', count($connections['connections'])); $routes = json_decode($this->ReadPropertyString('Ladepunkte'), true); if (!is_array($routes)) { $routes = []; } $target = (new OCPPFrameRouter())->route( $routes, $chargePointId, 1, 1, $this->ReadPropertyInteger('DefaultTargetInstance') ); $this->SetValue('LastRouteResult', json_encode([ 'chargePointId' => $chargePointId, 'target' => $target, 'timestamp' => time() ])); if ($target > 0 && IPS_InstanceExists($target)) { IPS_RequestAction($target, 'HandleInboundFrame', $frame); $this->setMessage('Inbound Frame an Zielinstanz ' . $target . ' geroutet.'); return $this->DequeueOutboundFrame($chargePointId, $this->extractUniqueId($frame)); } $this->setMessage('Inbound Frame empfangen, aber keine Zielinstanz gefunden.'); return ''; } public function DequeueOutboundFrame(string $chargePointId = '', string $preferredUniqueId = ''): string { $queues = $this->readOutboundQueues(); if ($preferredUniqueId !== '') { foreach ($queues as $candidateKey => $queue) { if (!is_array($queue)) { continue; } foreach ($queue as $candidateIndex => $candidateFrame) { if ($this->isCallResultForUniqueId((string)$candidateFrame, $preferredUniqueId)) { $frame = (string)$candidateFrame; array_splice($queues[$candidateKey], (int)$candidateIndex, 1); if (empty($queues[$candidateKey])) { unset($queues[$candidateKey]); } $this->WriteAttributeString(self::ATTR_OUTBOUND_QUEUES, json_encode($queues)); $this->SetValue('OutboundQueueCount', $this->countOutboundFrames($queues)); return $frame; } } } } $key = $chargePointId; if ($key === '' || !isset($queues[$key]) || empty($queues[$key])) { foreach ($queues as $candidate => $queue) { if (!empty($queue)) { $key = (string)$candidate; break; } } } if ($key === '' || !isset($queues[$key]) || empty($queues[$key])) { $this->SetValue('OutboundQueueCount', $this->countOutboundFrames($queues)); return ''; } $frame = (string)array_shift($queues[$key]); if (empty($queues[$key])) { unset($queues[$key]); } $this->WriteAttributeString(self::ATTR_OUTBOUND_QUEUES, json_encode($queues)); $this->SetValue('OutboundQueueCount', $this->countOutboundFrames($queues)); return $frame; } public function TransportWatchdog(): void { $last = (int)$this->GetValue('LastMessageTime'); if ($last === 0) { $this->SetValue('TransportStatus', $this->ReadPropertyString('TransportMode') === 'webhook_spike' ? 'WebHook-Spike wartet' : 'Wartet auf WebSocket Verbindung'); return; } $age = time() - $last; if ($age > max(90, 3 * $this->ReadPropertyInteger('HeartbeatSeconds'))) { $this->SetValue('TransportStatus', 'Timeout'); $this->setMessage('Transport-Watchdog Timeout nach ' . $age . ' Sekunden.'); return; } $this->SetValue('TransportStatus', $this->ReadPropertyString('TransportMode') === 'webhook_spike' ? 'Aktiv/WebHook-Spike' : 'Aktiv/WebSocket-Transport'); } private function tryRegisterHook(): void { $hook = $this->ReadPropertyString('HookPath'); if (method_exists($this, 'RegisterHook')) { try { $this->RegisterHook($hook); $this->SetValue('WebSocketSupportStatus', 'RegisterHook aufgerufen fuer ' . $hook . '. Nur WebHook-Spike, kein produktiver OCPP-WebSocket-Dauerbetrieb.'); $this->setMessage('Webhook registriert: ' . $hook); return; } catch (Throwable $e) { $this->SetValue('WebSocketSupportStatus', 'RegisterHook Fehler: ' . $e->getMessage()); $this->setMessage('Webhook konnte nicht registriert werden.'); return; } } $this->SetValue('WebSocketSupportStatus', 'RegisterHook nicht verfuegbar. WebHook Control manuell pruefen.'); $this->setMessage('RegisterHook nicht verfuegbar.'); } private function setMessage(string $message): void { $this->SetValue('LetzteMeldung', $message); $this->SetValue('LetzteMeldungZeit', time()); if ($this->ReadPropertyInteger('DebugLevel') > 0) { $this->SendDebug('OCPP_Server', $message, 0); } } private function enqueueOutboundFrame(string $chargePointId, string $frame): void { $key = $chargePointId === '' ? '_default' : $chargePointId; $queues = $this->readOutboundQueues(); if (!isset($queues[$key]) || !is_array($queues[$key])) { $queues[$key] = []; } $queues[$key][] = $frame; $this->WriteAttributeString(self::ATTR_OUTBOUND_QUEUES, json_encode($queues)); $this->SetValue('OutboundQueueCount', $this->countOutboundFrames($queues)); } private function readOutboundQueues(): array { $queues = json_decode($this->ReadAttributeString(self::ATTR_OUTBOUND_QUEUES), true); return is_array($queues) ? $queues : []; } private function countOutboundFrames(array $queues): int { $count = 0; foreach ($queues as $queue) { if (is_array($queue)) { $count += count($queue); } } return $count; } private function extractUniqueId(string $frame): string { $data = json_decode($frame, true); if (!is_array($data) || count($data) < 2) { return ''; } return (string)$data[1]; } private function isCallResultForUniqueId(string $frame, string $uniqueId): bool { $data = json_decode($frame, true); return is_array($data) && (int)($data[0] ?? 0) === 3 && (string)($data[1] ?? '') === $uniqueId; } } ?>