Files
2026-05-10 17:26:05 +02:00

367 lines
14 KiB
PHP

<?php
require_once __DIR__ . '/libs/WebSocketEndpoint.php';
require_once __DIR__ . '/libs/ConnectionRegistry.php';
require_once __DIR__ . '/libs/OCPPFrameRouter.php';
class OCPP_Server extends IPSModule
{
private const ATTR_CONNECTIONS = 'Connections';
private const ATTR_OUTBOUND_QUEUES = 'OutboundQueues';
public function Create()
{
parent::Create();
$this->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;
}
}
?>