Neues Ladestationsmodul mit ocpp Modul erstellt.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# OCPP_Server
|
||||
|
||||
`OCPP_Server` ist ein Transport- und Routing-Scaffold fuer das neue `Ladestation_OCPP` Modul.
|
||||
|
||||
## Aufgabe
|
||||
|
||||
- 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`.
|
||||
- Dokumentation des WebHook/WebSocket-Spikes.
|
||||
|
||||
## 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 oder einem Simulator getestet werden.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
- `HookPath`: Standard `/hook/ocpp`
|
||||
- `DefaultTargetInstance`: Zielinstanz, wenn kein spezifisches Routing gefunden wird
|
||||
- `Ladepunkte`: optionale Routingliste je ChargePoint/EVSE/Connector
|
||||
- `HeartbeatSeconds`: Watchdog-Basis
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "Transport-Scaffold fuer OCPP. WebHook/WebSocket wird als technischer Spike dokumentiert."
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "EnableWebhook",
|
||||
"caption": "Webhook registrieren, falls Symcon-Version RegisterHook unterstuetzt"
|
||||
},
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "HookPath",
|
||||
"caption": "Hook Pfad"
|
||||
},
|
||||
{
|
||||
"type": "SelectInstance",
|
||||
"name": "DefaultTargetInstance",
|
||||
"caption": "Default Ladestation_OCPP Instanz",
|
||||
"test": true
|
||||
},
|
||||
{
|
||||
"type": "List",
|
||||
"name": "Ladepunkte",
|
||||
"caption": "Routing Ladepunkte",
|
||||
"add": true,
|
||||
"delete": true,
|
||||
"columns": [
|
||||
{
|
||||
"caption": "ChargePointId",
|
||||
"name": "ChargePointId",
|
||||
"width": "220px",
|
||||
"add": "",
|
||||
"edit": { "type": "ValidationTextBox" }
|
||||
},
|
||||
{
|
||||
"caption": "EVSEId",
|
||||
"name": "EVSEId",
|
||||
"width": "100px",
|
||||
"add": 1,
|
||||
"edit": { "type": "NumberSpinner" }
|
||||
},
|
||||
{
|
||||
"caption": "ConnectorId",
|
||||
"name": "ConnectorId",
|
||||
"width": "100px",
|
||||
"add": 1,
|
||||
"edit": { "type": "NumberSpinner" }
|
||||
},
|
||||
{
|
||||
"caption": "Zielinstanz",
|
||||
"name": "TargetInstance",
|
||||
"width": "220px",
|
||||
"add": 0,
|
||||
"edit": { "type": "SelectInstance" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "HeartbeatSeconds",
|
||||
"caption": "Watchdog Intervall",
|
||||
"suffix": "Sekunden"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "DebugLevel",
|
||||
"caption": "Debug Level"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "Button",
|
||||
"caption": "Hook pruefen",
|
||||
"onClick": "IPS_RequestAction($id, \"RegisterHook\", \"\");"
|
||||
},
|
||||
{
|
||||
"type": "Button",
|
||||
"caption": "Puffer loeschen",
|
||||
"onClick": "IPS_RequestAction($id, \"ClearBuffers\", \"\");"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
class ConnectionRegistry
|
||||
{
|
||||
public static function empty(): array
|
||||
{
|
||||
return [
|
||||
'connections' => [],
|
||||
'lastSeen' => []
|
||||
];
|
||||
}
|
||||
|
||||
public static function fromJson(string $json): array
|
||||
{
|
||||
$data = json_decode($json, true);
|
||||
if (!is_array($data)) {
|
||||
return self::empty();
|
||||
}
|
||||
return array_replace(self::empty(), $data);
|
||||
}
|
||||
|
||||
public static function toJson(array $state): string
|
||||
{
|
||||
return json_encode(array_replace(self::empty(), $state));
|
||||
}
|
||||
|
||||
public static function touch(array $state, string $chargePointId, string $remote = ''): array
|
||||
{
|
||||
$state = array_replace(self::empty(), $state);
|
||||
$state['connections'][$chargePointId] = [
|
||||
'chargePointId' => $chargePointId,
|
||||
'remote' => $remote,
|
||||
'timestamp' => time()
|
||||
];
|
||||
$state['lastSeen'][$chargePointId] = time();
|
||||
return $state;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
class OCPPFrameRouter
|
||||
{
|
||||
public function route(array $routes, string $chargePointId, int $evseId = 1, int $connectorId = 1, int $defaultTarget = 0): int
|
||||
{
|
||||
foreach ($routes as $route) {
|
||||
if (!is_array($route)) {
|
||||
continue;
|
||||
}
|
||||
if ((string)($route['ChargePointId'] ?? '') !== $chargePointId) {
|
||||
continue;
|
||||
}
|
||||
$routeEvse = (int)($route['EVSEId'] ?? 1);
|
||||
$routeConnector = (int)($route['ConnectorId'] ?? 1);
|
||||
if ($routeEvse === $evseId && $routeConnector === $connectorId) {
|
||||
return (int)($route['TargetInstance'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $defaultTarget;
|
||||
}
|
||||
|
||||
public function extractChargePointId(string $path, string $fallback = ''): string
|
||||
{
|
||||
$path = trim($path, '/');
|
||||
if ($path === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$parts = explode('/', $path);
|
||||
return (string)end($parts);
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
class WebSocketEndpoint
|
||||
{
|
||||
public static function supportSummary(bool $registerHookAvailable, string $hookPath): array
|
||||
{
|
||||
if ($registerHookAvailable) {
|
||||
return [
|
||||
'status' => 'Hook vorbereitet',
|
||||
'detail' => 'Symcon RegisterHook ist verfuegbar. WebSocket-Dauerbetrieb muss mit echter OCPP-Station verifiziert werden.',
|
||||
'hookPath' => $hookPath
|
||||
];
|
||||
}
|
||||
|
||||
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.',
|
||||
'hookPath' => $hookPath
|
||||
];
|
||||
}
|
||||
|
||||
public static function readRawBody(): string
|
||||
{
|
||||
$data = @file_get_contents('php://input');
|
||||
return is_string($data) ? $data : '';
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "{E0A65257-3F98-4D4F-9BAB-52822D5B435F}",
|
||||
"name": "OCPP_Server",
|
||||
"type": 3,
|
||||
"vendor": "Belevo AG",
|
||||
"aliases": [
|
||||
"OCPP Server"
|
||||
],
|
||||
"parentRequirements": [],
|
||||
"childRequirements": [],
|
||||
"implemented": [],
|
||||
"prefix": "GEF",
|
||||
"url": ""
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<?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';
|
||||
|
||||
public function Create()
|
||||
{
|
||||
parent::Create();
|
||||
|
||||
$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->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->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);
|
||||
$summary = WebSocketEndpoint::supportSummary(method_exists($this, 'RegisterHook'), $this->ReadPropertyString('HookPath'));
|
||||
$this->SetValue('WebSocketSupportStatus', $summary['status'] . ': ' . $summary['detail']);
|
||||
$this->SetSummary($summary['status']);
|
||||
|
||||
if ($this->ReadPropertyBoolean('EnableWebhook')) {
|
||||
$this->tryRegisterHook();
|
||||
}
|
||||
|
||||
$this->SetStatus(102);
|
||||
}
|
||||
|
||||
public function RequestAction($Ident, $Value)
|
||||
{
|
||||
switch ($Ident) {
|
||||
case 'RegisterHook':
|
||||
$this->tryRegisterHook();
|
||||
break;
|
||||
|
||||
case 'QueueOutboundFrame':
|
||||
$this->QueueOutboundFrame((string)$Value);
|
||||
break;
|
||||
|
||||
case 'RouteInboundFrame':
|
||||
$this->RouteInboundFrame((string)$Value);
|
||||
break;
|
||||
|
||||
case 'TransportWatchdog':
|
||||
$this->TransportWatchdog();
|
||||
break;
|
||||
|
||||
case 'ClearBuffers':
|
||||
$this->SetValue('LastInboundFrame', '');
|
||||
$this->SetValue('LastOutboundFrame', '');
|
||||
$this->SetValue('LastRouteResult', '');
|
||||
$this->setMessage('Puffer geloescht');
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Exception('Invalid Ident');
|
||||
}
|
||||
}
|
||||
|
||||
protected function ProcessHookData($JSONString = '')
|
||||
{
|
||||
$raw = WebSocketEndpoint::readRawBody();
|
||||
if ($raw === '' && is_string($JSONString)) {
|
||||
$raw = $JSONString;
|
||||
}
|
||||
|
||||
$path = $_SERVER['REQUEST_URI'] ?? $this->ReadPropertyString('HookPath');
|
||||
$chargePointId = (new OCPPFrameRouter())->extractChargePointId((string)$path);
|
||||
|
||||
$this->RouteInboundFrame(json_encode([
|
||||
'ChargePointId' => $chargePointId,
|
||||
'Frame' => $raw,
|
||||
'Remote' => ($_SERVER['REMOTE_ADDR'] ?? '') . ':' . ($_SERVER['REMOTE_PORT'] ?? '')
|
||||
]));
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'status' => 'accepted',
|
||||
'note' => 'OCPP transport scaffold. Produktiver WebSocket-Dauerbetrieb muss mit Station verifiziert werden.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function QueueOutboundFrame(string $json): void
|
||||
{
|
||||
$this->SetValue('LastOutboundFrame', $json);
|
||||
$this->SetValue('LastMessageTime', time());
|
||||
$this->setMessage('Outbound Frame vorgemerkt. Aktiver WebSocket-Sendekanal ist noch Scaffold.');
|
||||
}
|
||||
|
||||
public function RouteInboundFrame(string $json): void
|
||||
{
|
||||
$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->setMessage('Inbound Frame empfangen, aber keine Zielinstanz gefunden.');
|
||||
}
|
||||
|
||||
public function TransportWatchdog(): void
|
||||
{
|
||||
$last = (int)$this->GetValue('LastMessageTime');
|
||||
if ($last === 0) {
|
||||
$this->SetValue('TransportStatus', 'Wartet auf OCPP 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', 'Aktiv/Scaffold');
|
||||
}
|
||||
|
||||
private function tryRegisterHook(): void
|
||||
{
|
||||
$hook = $this->ReadPropertyString('HookPath');
|
||||
if (method_exists($this, 'RegisterHook')) {
|
||||
try {
|
||||
$this->RegisterHook($hook);
|
||||
$this->SetValue('WebSocketSupportStatus', 'RegisterHook aufgerufen fuer ' . $hook . '. WebSocket-Dauerbetrieb noch testen.');
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
Reference in New Issue
Block a user