Neues Ladestationsmodul mit ocpp Modul erstellt.

This commit is contained in:
2026-05-10 10:56:34 +02:00
parent 51b27d568a
commit bba6494c59
27 changed files with 3290 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
# Ladestation_OCPP
`Ladestation_OCPP` ist das neue OCPP-orientierte Ladestationsmodul fuer das Belevo/Enelix Energiemanagement in IP-Symcon.
Status dieser Version: **M1 Scaffold**. Das Modul ist installierbar, haelt den bestehenden EMS-Vertrag ein und stellt die OCPP-Zielarchitektur bereit. Der produktive OCPP-WebSocket-Dauerbetrieb ist bewusst noch als Transport-Spike ueber `OCPP_Server` gekennzeichnet.
## Ziel
- OCPP-only Architektur fuer Ladestationen.
- Keine Hersteller-HTTP-APIs, keine Cloud-Token, keine direkte Kopie von `Ladestation_v2`.
- Fachliche Paritaet zu `Ladestation_v2` als Zielbild.
- Stabile Anbindung an den bestehenden `Manager` ueber `PowerSteps`, `GetCurrentData`, `SetAktuelle_Leistung` und `Do_UserCalc`.
## Manager-Anbindung
Das Modul ist aus Sicht des Energiemanagers ein Verbraucher. Es legt die Pflichtvariablen mit exakt diesen Idents an:
- `Sperre_Prio`
- `PV_Prio`
- `Idle`
- `Aktuelle_Leistung`
- `Bezogene_Energie`
- `PowerSteps`
- `Power`
- `Is_Peak_Shaving`
- `Leistung_Delta`
- `IdleCounter`
Pflicht-Actions:
- `GetCurrentData(bool Peak)`
- `SetAktuelle_Leistung(power)`
- `Do_UserCalc()`
## Phase-1-Modi
Diese Modi sind im Scaffold sichtbar und in der PowerStep-Berechnung vorbereitet:
- `0` = Nie laden
- `1` = Immer laden
- `2` = Konstanter Strom
- `3` = Nur Solar
Weitere Modi sind als Architektur vorbereitet, aber noch nicht produktiv ausgearbeitet.
## OCPP-Struktur
Die OCPP-Klassen liegen in `Ladestation_OCPP/libs/`:
- Nachrichtenmodell: `OCPPMessage`, `OCPPTransport`
- Versionsadapter: `OCPP16Adapter`, `OCPP201Adapter`, `OCPP21Adapter`
- Fachlogik: `PowerStepCalculator`, `ChargingProfileBuilder`, `MeterValueNormalizer`, `TransactionStore`
- Sicherheit/Diagnose: `FailSafeManager`, `Diagnostics`, `CapabilityModel`, `DataTransferRegistry`, `PhaseManager`
## Transport
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.
## Dokumentation
Die fachliche und technische Gesamtdokumentation liegt unter:
`docs/OCPP/README.md`
+125
View File
@@ -0,0 +1,125 @@
{
"elements": [
{
"type": "Label",
"caption": "OCPP-only Ladestationsmodul. Phase 1 ist ein EMS-faehiger Scaffold mit OCPP-Transport-Spike."
},
{
"type": "Select",
"name": "OCPPVersionMode",
"caption": "OCPP Version",
"options": [
{ "caption": "Automatisch", "value": "auto" },
{ "caption": "OCPP 1.6", "value": "1.6" },
{ "caption": "OCPP 2.0.1", "value": "2.0.1" },
{ "caption": "OCPP 2.1 vorbereitet", "value": "2.1" }
]
},
{
"type": "ValidationTextBox",
"name": "ChargePointId",
"caption": "ChargePointId"
},
{
"type": "NumberSpinner",
"name": "EVSEId",
"caption": "EVSE ID"
},
{
"type": "NumberSpinner",
"name": "ConnectorId",
"caption": "Connector ID"
},
{
"type": "SelectInstance",
"name": "OCPPServerInstance",
"caption": "OCPP Server Instanz",
"test": true
},
{
"type": "NumberSpinner",
"name": "MinCurrent",
"caption": "Mindeststrom",
"suffix": "A"
},
{
"type": "NumberSpinner",
"name": "MaxCurrentAbs",
"caption": "Maximalstrom Installation",
"suffix": "A"
},
{
"type": "NumberSpinner",
"name": "SafeCurrent",
"caption": "SafeCurrent",
"suffix": "A"
},
{
"type": "Select",
"name": "SafeOffStrategy",
"caption": "Fail-Safe Strategie",
"options": [
{ "caption": "0 A", "value": "0A" },
{ "caption": "SafeCurrent", "value": "SafeCurrent" },
{ "caption": "Letzten Wert begrenzen", "value": "LastKnown" },
{ "caption": "Unavailable", "value": "Unavailable" }
]
},
{
"type": "CheckBox",
"name": "AllowAutomaticPhaseSwitch",
"caption": "Automatische Phasenumschaltung erlauben"
},
{
"type": "CheckBox",
"name": "AllowDataTransfer",
"caption": "OCPP DataTransfer erlauben"
},
{
"type": "NumberSpinner",
"name": "Interval",
"caption": "Regelintervall",
"suffix": "Sekunden"
},
{
"type": "NumberSpinner",
"name": "IdleCounterMax",
"caption": "Zyklen bis Idle"
},
{
"type": "NumberSpinner",
"name": "EMSWatchdogSeconds",
"caption": "EMS Watchdog",
"suffix": "Sekunden"
},
{
"type": "NumberSpinner",
"name": "OCPPHeartbeatTimeoutSeconds",
"caption": "OCPP Heartbeat Timeout",
"suffix": "Sekunden"
},
{
"type": "NumberSpinner",
"name": "CommandAckTimeoutSeconds",
"caption": "Command ACK Timeout",
"suffix": "Sekunden"
},
{
"type": "NumberSpinner",
"name": "DebugLevel",
"caption": "Debug Level"
}
],
"actions": [
{
"type": "Button",
"caption": "Reset Diagnose",
"onClick": "IPS_RequestAction($id, \"Reset\", \"\");"
},
{
"type": "Button",
"caption": "Clear ChargingProfile",
"onClick": "IPS_RequestAction($id, \"ClearChargingProfile\", \"\");"
}
]
}
+62
View File
@@ -0,0 +1,62 @@
<?php
class CapabilityModel
{
public static function defaults(): array
{
return [
'smartCharging' => false,
'meterValues' => false,
'phaseMetering' => false,
'currentImportPerPhase' => false,
'voltagePerPhase' => false,
'powerImport' => false,
'powerExport' => false,
'energyImport' => false,
'energyExport' => false,
'numberPhases' => false,
'phaseToUse' => false,
'phaseSwitching' => false,
'dataTransfer' => false,
'getVariables' => false,
'setVariables' => false,
'transactionEvent' => false,
'soc' => false,
'temperature' => false,
'signedMeterValue' => false,
'bidirectional' => false
];
}
public static function detectFromBoot(string $version, array $payload): array
{
$capabilities = self::defaults();
$capabilities['meterValues'] = true;
if ($version === '2.0.1' || $version === '2.1') {
$capabilities['getVariables'] = true;
$capabilities['setVariables'] = true;
$capabilities['transactionEvent'] = true;
$capabilities['smartCharging'] = true;
}
if ($version === '2.1') {
$capabilities['bidirectional'] = true;
$capabilities['powerExport'] = true;
$capabilities['energyExport'] = true;
}
if (isset($payload['chargingStation']['model']) || isset($payload['chargePointModel'])) {
$capabilities['dataTransfer'] = true;
}
return $capabilities;
}
public static function merge(array $base, array $updates): array
{
return array_replace($base, array_intersect_key($updates, $base));
}
}
?>
@@ -0,0 +1,51 @@
<?php
class ChargingProfileBuilder
{
public function build(array $setpoint, string $version): array
{
$currentA = max(0.0, (float)($setpoint['effectiveCurrentA'] ?? 0));
$validTo = gmdate('Y-m-d\TH:i:s\Z', time() + 120);
if ($version === '1.6') {
return [
'connectorId' => (int)($setpoint['connectorId'] ?? 1),
'csChargingProfiles' => [
'chargingProfileId' => (int)(time() % 100000),
'stackLevel' => 0,
'chargingProfilePurpose' => 'TxDefaultProfile',
'chargingProfileKind' => 'Absolute',
'validTo' => $validTo,
'chargingSchedule' => [
'chargingRateUnit' => 'A',
'chargingSchedulePeriod' => [
['startPeriod' => 0, 'limit' => $currentA]
]
]
]
];
}
return [
'evseId' => (int)($setpoint['evseId'] ?? 1),
'chargingProfile' => [
'id' => (int)(time() % 100000),
'stackLevel' => 0,
'chargingProfilePurpose' => 'TxDefaultProfile',
'chargingProfileKind' => 'Absolute',
'validTo' => $validTo,
'chargingSchedule' => [
[
'id' => 1,
'chargingRateUnit' => 'A',
'chargingSchedulePeriod' => [
['startPeriod' => 0, 'limit' => $currentA]
]
]
]
]
];
}
}
?>
@@ -0,0 +1,31 @@
<?php
class DataTransferRegistry
{
private bool $allowed;
private array $entries;
public function __construct(bool $allowed, array $entries = [])
{
$this->allowed = $allowed;
$this->entries = $entries;
}
public function isAllowed(string $vendorId, string $messageId = ''): bool
{
if (!$this->allowed) {
return false;
}
if (empty($this->entries)) {
return true;
}
foreach ($this->entries as $entry) {
if (($entry['vendorId'] ?? '') === $vendorId && (($entry['messageId'] ?? '') === '' || ($entry['messageId'] ?? '') === $messageId)) {
return true;
}
}
return false;
}
}
?>
+25
View File
@@ -0,0 +1,25 @@
<?php
class Diagnostics
{
public static function message(string $severity, string $source, string $text, string $code = ''): array
{
return [
'timestamp' => time(),
'severity' => $severity,
'source' => $source,
'text' => $text,
'code' => $code
];
}
public static function statusFromFlags(bool $online, bool $fault): string
{
if ($fault) {
return 'Stoerung';
}
return $online ? 'Online' : 'Offline';
}
}
?>
+49
View File
@@ -0,0 +1,49 @@
<?php
class FailSafeManager
{
public function evaluate(array $state): array
{
$now = time();
$warnings = [];
$blockingReason = '';
$safePowerW = null;
$emsAge = $now - (int)($state['lastEmsUpdate'] ?? 0);
if (($state['lastEmsUpdate'] ?? 0) > 0 && $emsAge > (int)($state['emsWatchdogSeconds'] ?? 120)) {
$warnings[] = 'EMS watchdog expired';
$safePowerW = $this->safePower($state);
}
$heartbeatAge = $now - (int)($state['lastOcppHeartbeat'] ?? 0);
if (($state['ocppOnline'] ?? false) && ($state['lastOcppHeartbeat'] ?? 0) > 0 && $heartbeatAge > (int)($state['ocppHeartbeatTimeoutSeconds'] ?? 90)) {
$warnings[] = 'OCPP heartbeat timeout';
$blockingReason = 'OCPP timeout';
}
if (($state['criticalFault'] ?? false) === true) {
$warnings[] = 'Critical fault';
$blockingReason = 'Critical fault';
$safePowerW = 0;
}
return [
'warnings' => $warnings,
'blockingReason' => $blockingReason,
'safePowerW' => $safePowerW
];
}
private function safePower(array $state): int
{
$strategy = (string)($state['safeOffStrategy'] ?? 'SafeCurrent');
if ($strategy === '0A' || $strategy === 'Unavailable') {
return 0;
}
$phases = PhaseManager::normalizePhaseCount((int)($state['numberPhases'] ?? 3));
$safeCurrent = (float)($state['safeCurrentA'] ?? 6.0);
return PhaseManager::wattsFromCurrent($safeCurrent, $phases);
}
}
?>
@@ -0,0 +1,157 @@
<?php
class MeterValueNormalizer
{
public function normalize(array $payload): array
{
$result = [
'powerImportW' => null,
'powerExportW' => null,
'energyImportWh' => null,
'energyExportWh' => null,
'currentA' => [1 => null, 2 => null, 3 => null],
'voltageV' => [1 => null, 2 => null, 3 => null],
'powerPhaseW' => [1 => null, 2 => null, 3 => null],
'soc' => null,
'temperature' => null,
'quality' => 'OCPP-MeterValue',
'timestamp' => time()
];
foreach ($this->extractSamples($payload) as $sample) {
$measurand = (string)($sample['measurand'] ?? 'Energy.Active.Import.Register');
$value = $this->normalizeValue($sample);
$phase = $this->phaseIndex((string)($sample['phase'] ?? ''));
switch ($measurand) {
case 'Power.Active.Import':
$this->writePower($result, 'powerImportW', $value, $phase);
break;
case 'Power.Active.Export':
$this->writePower($result, 'powerExportW', $value, $phase);
break;
case 'Energy.Active.Import.Register':
case 'Energy.Active.Import.Interval':
$result['energyImportWh'] = $value;
break;
case 'Energy.Active.Export.Register':
case 'Energy.Active.Export.Interval':
$result['energyExportWh'] = $value;
break;
case 'Current.Import':
if ($phase > 0) {
$result['currentA'][$phase] = $value;
}
break;
case 'Voltage':
if ($phase > 0) {
$result['voltageV'][$phase] = $value;
}
break;
case 'SoC':
$result['soc'] = $value;
break;
case 'Temperature':
$result['temperature'] = $value;
break;
}
if (isset($sample['timestamp'])) {
$ts = strtotime((string)$sample['timestamp']);
if ($ts !== false) {
$result['timestamp'] = $ts;
}
}
}
if ($result['powerImportW'] === null) {
$result['powerImportW'] = $this->sumPhases($result['powerPhaseW']);
}
return $result;
}
private function extractSamples(array $payload): array
{
$samples = [];
$containers = $payload['meterValue'] ?? $payload['meterValues'] ?? [$payload];
if (!is_array($containers)) {
return [];
}
foreach ($containers as $container) {
if (!is_array($container)) {
continue;
}
$timestamp = $container['timestamp'] ?? null;
$sampled = $container['sampledValue'] ?? $container['sampledValues'] ?? [];
if (!is_array($sampled)) {
continue;
}
foreach ($sampled as $sample) {
if (!is_array($sample)) {
continue;
}
if ($timestamp !== null && !isset($sample['timestamp'])) {
$sample['timestamp'] = $timestamp;
}
$samples[] = $sample;
}
}
return $samples;
}
private function normalizeValue(array $sample): float
{
$value = (float)($sample['value'] ?? 0);
$unit = $sample['unit'] ?? ($sample['unitOfMeasure']['unit'] ?? '');
$unit = strtolower((string)$unit);
if ($unit === 'kwh') {
return $value * 1000.0;
}
if ($unit === 'kw') {
return $value * 1000.0;
}
return $value;
}
private function phaseIndex(string $phase): int
{
if (stripos($phase, 'L1') !== false) {
return 1;
}
if (stripos($phase, 'L2') !== false) {
return 2;
}
if (stripos($phase, 'L3') !== false) {
return 3;
}
return 0;
}
private function writePower(array &$result, string $key, float $value, int $phase): void
{
if ($phase > 0) {
$result['powerPhaseW'][$phase] = $value;
return;
}
$result[$key] = $value;
}
private function sumPhases(array $values): ?float
{
$sum = 0.0;
$found = false;
foreach ($values as $value) {
if ($value !== null) {
$sum += (float)$value;
$found = true;
}
}
return $found ? $sum : null;
}
}
?>
+31
View File
@@ -0,0 +1,31 @@
<?php
class OCPP16Adapter
{
public function normalizeInbound(OCPPMessage $message): array
{
return [
'version' => '1.6',
'action' => $message->action,
'uniqueId' => $message->uniqueId,
'payload' => $message->payload
];
}
public function buildSetChargingProfile(array $profile): OCPPMessage
{
return OCPPMessage::call('SetChargingProfile', $profile, '1.6');
}
public function buildClearChargingProfile(int $connectorId): OCPPMessage
{
return OCPPMessage::call('ClearChargingProfile', ['connectorId' => $connectorId], '1.6');
}
public function buildReset(string $type = 'Soft'): OCPPMessage
{
return OCPPMessage::call('Reset', ['type' => $type], '1.6');
}
}
?>
+31
View File
@@ -0,0 +1,31 @@
<?php
class OCPP201Adapter
{
public function normalizeInbound(OCPPMessage $message): array
{
return [
'version' => '2.0.1',
'action' => $message->action,
'uniqueId' => $message->uniqueId,
'payload' => $message->payload
];
}
public function buildSetChargingProfile(array $profile): OCPPMessage
{
return OCPPMessage::call('SetChargingProfile', $profile, '2.0.1');
}
public function buildClearChargingProfile(int $evseId): OCPPMessage
{
return OCPPMessage::call('ClearChargingProfile', ['evseId' => $evseId], '2.0.1');
}
public function buildReset(string $type = 'Immediate'): OCPPMessage
{
return OCPPMessage::call('Reset', ['type' => $type], '2.0.1');
}
}
?>
+18
View File
@@ -0,0 +1,18 @@
<?php
class OCPP21Adapter extends OCPP201Adapter
{
public function normalizeInbound(OCPPMessage $message): array
{
$data = parent::normalizeInbound($message);
$data['version'] = '2.1';
return $data;
}
public function supportsBidirectionalPreparation(): bool
{
return true;
}
}
?>
+126
View File
@@ -0,0 +1,126 @@
<?php
class OCPPMessage
{
public string $version;
public string $direction;
public string $action;
public string $uniqueId;
public array $payload;
public int $timestamp;
public string $chargePointId;
public int $messageTypeId;
public function __construct(
string $version,
string $direction,
string $action,
string $uniqueId,
array $payload,
string $chargePointId = '',
int $messageTypeId = 0
) {
$this->version = $version;
$this->direction = $direction;
$this->action = $action;
$this->uniqueId = $uniqueId;
$this->payload = $payload;
$this->timestamp = time();
$this->chargePointId = $chargePointId;
$this->messageTypeId = $messageTypeId;
}
public static function fromJson(string $json, string $version = 'auto', string $chargePointId = ''): ?self
{
$frame = json_decode($json, true);
if (!is_array($frame) || count($frame) < 3) {
return null;
}
$type = (int)($frame[0] ?? 0);
$uniqueId = (string)($frame[1] ?? '');
if ($type === 2) {
return new self($version, 'in', (string)($frame[2] ?? ''), $uniqueId, (array)($frame[3] ?? []), $chargePointId, $type);
}
if ($type === 3) {
return new self($version, 'in_result', 'CallResult', $uniqueId, (array)($frame[2] ?? []), $chargePointId, $type);
}
if ($type === 4) {
return new self($version, 'in_error', (string)($frame[2] ?? 'CallError'), $uniqueId, [
'errorCode' => (string)($frame[2] ?? ''),
'errorDescription' => (string)($frame[3] ?? ''),
'errorDetails' => (array)($frame[4] ?? [])
], $chargePointId, $type);
}
return null;
}
public static function call(string $action, array $payload, string $version = 'auto', string $chargePointId = ''): self
{
return new self($version, 'out', $action, self::newUniqueId(), $payload, $chargePointId, 2);
}
public static function callResult(string $uniqueId, array $payload, string $version = 'auto', string $chargePointId = ''): self
{
return new self($version, 'out_result', 'CallResult', $uniqueId, $payload, $chargePointId, 3);
}
public static function callError(string $uniqueId, string $code, string $description, array $details = [], string $version = 'auto', string $chargePointId = ''): self
{
return new self($version, 'out_error', $code, $uniqueId, [
'errorCode' => $code,
'errorDescription' => $description,
'errorDetails' => $details
], $chargePointId, 4);
}
public function toFrame(): array
{
if ($this->messageTypeId === 2) {
return [2, $this->uniqueId, $this->action, $this->payload];
}
if ($this->messageTypeId === 3) {
return [3, $this->uniqueId, $this->payload];
}
if ($this->messageTypeId === 4) {
return [
4,
$this->uniqueId,
(string)($this->payload['errorCode'] ?? $this->action),
(string)($this->payload['errorDescription'] ?? ''),
(array)($this->payload['errorDetails'] ?? [])
];
}
return [$this->messageTypeId, $this->uniqueId, $this->action, $this->payload];
}
public function toJson(): string
{
return json_encode($this->toFrame());
}
public function toArray(): array
{
return [
'version' => $this->version,
'direction' => $this->direction,
'action' => $this->action,
'uniqueId' => $this->uniqueId,
'payload' => $this->payload,
'timestamp' => $this->timestamp,
'chargePointId' => $this->chargePointId,
'messageTypeId' => $this->messageTypeId
];
}
private static function newUniqueId(): string
{
return str_replace('.', '', uniqid('ips-', true));
}
}
?>
+32
View File
@@ -0,0 +1,32 @@
<?php
class OCPPTransport
{
public static function buildEnvelope(OCPPMessage $message, int $targetInstance = 0): array
{
return [
'TargetInstance' => $targetInstance,
'ChargePointId' => $message->chargePointId,
'Version' => $message->version,
'Frame' => $message->toJson(),
'Timestamp' => time()
];
}
public static function decodeEnvelope(string $json): array
{
$data = json_decode($json, true);
if (!is_array($data)) {
return [];
}
return [
'TargetInstance' => (int)($data['TargetInstance'] ?? 0),
'ChargePointId' => (string)($data['ChargePointId'] ?? ''),
'Version' => (string)($data['Version'] ?? 'auto'),
'Frame' => (string)($data['Frame'] ?? ''),
'Timestamp' => (int)($data['Timestamp'] ?? time())
];
}
}
?>
+46
View File
@@ -0,0 +1,46 @@
<?php
class PhaseManager
{
public static function normalizePhaseCount(int $phases): int
{
if ($phases <= 1) {
return 1;
}
return 3;
}
public static function wattsFromCurrent(float $currentA, int $phases, float $voltage = 230.0): int
{
$phaseCount = self::normalizePhaseCount($phases);
return (int)round($currentA * $voltage * $phaseCount);
}
public static function currentFromPower(float $powerW, int $phases, float $voltage = 230.0): float
{
$phaseCount = self::normalizePhaseCount($phases);
if ($voltage <= 0 || $phaseCount <= 0) {
return 0.0;
}
return round($powerW / ($voltage * $phaseCount), 2);
}
public static function choosePhaseToUse(array $phaseCurrents): int
{
$bestPhase = 1;
$bestCurrent = null;
foreach ([1, 2, 3] as $phase) {
$value = isset($phaseCurrents[$phase]) ? (float)$phaseCurrents[$phase] : null;
if ($value === null) {
continue;
}
if ($bestCurrent === null || $value < $bestCurrent) {
$bestCurrent = $value;
$bestPhase = $phase;
}
}
return $bestPhase;
}
}
?>
@@ -0,0 +1,81 @@
<?php
class PowerStepCalculator
{
public const MODE_NEVER = 0;
public const MODE_ALWAYS = 1;
public const MODE_CONSTANT = 2;
public const MODE_SOLAR = 3;
public const MODE_MINIMAL_SOLAR = 4;
public const MODE_SOLAR_TARIFF = 5;
public const MODE_MINIMUM_ENERGY = 6;
public const MODE_TARGET_SOC = 7;
public const MODE_BIDIRECTIONAL = 8;
public function calculate(array $input): array
{
$currentPower = (int)round((float)($input['currentPowerW'] ?? 0));
if (!($input['idle'] ?? true)) {
return [$currentPower];
}
if (!($input['ladebereit'] ?? false) || ($input['manualLock'] ?? false)) {
return [0];
}
if (!($input['carDetected'] ?? false) || ($input['carFull'] ?? false)) {
return [0];
}
$mode = (int)($input['mode'] ?? self::MODE_NEVER);
if ($mode === self::MODE_NEVER) {
return [0];
}
$minCurrent = max(0.0, (float)($input['minCurrentA'] ?? 6.0));
$maxCurrent = max($minCurrent, (float)($input['maxCurrentA'] ?? 6.0));
$safeCurrent = max(0.0, (float)($input['safeCurrentA'] ?? 6.0));
$constantCurrent = max(0.0, (float)($input['constantCurrentA'] ?? $minCurrent));
$phaseCount = PhaseManager::normalizePhaseCount((int)($input['numberPhases'] ?? 3));
$voltage = (float)($input['voltage'] ?? 230.0);
$limitCurrent = $maxCurrent;
if (($input['groupLimitA'] ?? 0) > 0) {
$limitCurrent = min($limitCurrent, (float)$input['groupLimitA']);
}
if (($input['vnbLimitA'] ?? 0) > 0) {
$limitCurrent = min($limitCurrent, (float)$input['vnbLimitA']);
}
if (($input['vnbLimitW'] ?? 0) > 0) {
$limitCurrent = min($limitCurrent, PhaseManager::currentFromPower((float)$input['vnbLimitW'], $phaseCount, $voltage));
}
if ($limitCurrent < $minCurrent) {
return [0];
}
if ($mode === self::MODE_CONSTANT) {
$target = min(max($constantCurrent, $safeCurrent), $limitCurrent);
return $this->uniqueSorted([0, PhaseManager::wattsFromCurrent($target, $phaseCount, $voltage)]);
}
$steps = [0];
for ($current = (int)ceil($minCurrent); $current <= (int)floor($limitCurrent); $current++) {
$steps[] = PhaseManager::wattsFromCurrent((float)$current, $phaseCount, $voltage);
}
return $this->uniqueSorted($steps);
}
private function uniqueSorted(array $steps): array
{
$steps = array_map(static function ($value) {
return (int)round((float)$value);
}, $steps);
$steps = array_values(array_unique($steps));
sort($steps, SORT_NUMERIC);
return $steps;
}
}
?>
@@ -0,0 +1,36 @@
<?php
class TransactionStore
{
public static function empty(): array
{
return [
'activeTransactionId' => '',
'transactionStartTime' => 0,
'transactionStartEnergyImport' => 0.0,
'transactionStartEnergyExport' => 0.0,
'lastTransactionState' => '',
'lastIdToken' => '',
'sessionEnergyImport' => 0.0,
'sessionEnergyExport' => 0.0,
'sessionPeakPower' => 0.0,
'sessionStopReason' => ''
];
}
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));
}
}
?>
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{9C0DD018-2E06-4F03-8422-99FE85128F23}",
"name": "Ladestation_OCPP",
"type": 3,
"vendor": "Belevo AG",
"aliases": [
"Ladestation OCPP"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "GEF",
"url": ""
}
+735
View File
@@ -0,0 +1,735 @@
<?php
require_once __DIR__ . '/libs/OCPPMessage.php';
require_once __DIR__ . '/libs/OCPPTransport.php';
require_once __DIR__ . '/libs/OCPP16Adapter.php';
require_once __DIR__ . '/libs/OCPP201Adapter.php';
require_once __DIR__ . '/libs/OCPP21Adapter.php';
require_once __DIR__ . '/libs/CapabilityModel.php';
require_once __DIR__ . '/libs/MeterValueNormalizer.php';
require_once __DIR__ . '/libs/TransactionStore.php';
require_once __DIR__ . '/libs/ChargingProfileBuilder.php';
require_once __DIR__ . '/libs/PowerStepCalculator.php';
require_once __DIR__ . '/libs/PhaseManager.php';
require_once __DIR__ . '/libs/FailSafeManager.php';
require_once __DIR__ . '/libs/DataTransferRegistry.php';
require_once __DIR__ . '/libs/Diagnostics.php';
class Ladestation_OCPP extends IPSModule
{
private const ATTR_TRANSACTION = 'TransactionState';
private const ATTR_CAPABILITIES = 'Capabilities';
private const ATTR_LAST_EMS_UPDATE = 'LastEmsUpdate';
private const ATTR_LAST_OCPP_HEARTBEAT = 'LastOcppHeartbeat';
public function Create()
{
parent::Create();
$this->RegisterPropertyString('OCPPVersionMode', 'auto');
$this->RegisterPropertyString('ChargePointId', '');
$this->RegisterPropertyInteger('EVSEId', 1);
$this->RegisterPropertyInteger('ConnectorId', 1);
$this->RegisterPropertyInteger('OCPPServerInstance', 0);
$this->RegisterPropertyFloat('MaxCurrentAbs', 32.0);
$this->RegisterPropertyFloat('MinCurrent', 6.0);
$this->RegisterPropertyFloat('SafeCurrent', 6.0);
$this->RegisterPropertyString('SafeOffStrategy', 'SafeCurrent');
$this->RegisterPropertyInteger('EMSWatchdogSeconds', 120);
$this->RegisterPropertyInteger('OCPPHeartbeatTimeoutSeconds', 90);
$this->RegisterPropertyInteger('CommandAckTimeoutSeconds', 30);
$this->RegisterPropertyInteger('MeterPowerIntervalSeconds', 1);
$this->RegisterPropertyInteger('MeterEnergyIntervalSeconds', 60);
$this->RegisterPropertyInteger('IdleCounterMax', 2);
$this->RegisterPropertyInteger('Interval', 1);
$this->RegisterPropertyInteger('PhaseSwitchHoldSeconds', 120);
$this->RegisterPropertyInteger('PhaseSwitchPauseSeconds', 30);
$this->RegisterPropertyBoolean('AllowAutomaticPhaseSwitch', false);
$this->RegisterPropertyBoolean('AllowDataTransfer', false);
$this->RegisterPropertyString('GroupId', '');
$this->RegisterPropertyFloat('GroupLimitA', 0.0);
$this->RegisterPropertyFloat('GroupLimitW', 0.0);
$this->RegisterPropertyInteger('VNBInputMode', 0);
$this->RegisterPropertyInteger('DebugLevel', 0);
$this->RegisterPropertyFloat('SetpointTolerancePercent', 5.0);
$this->RegisterPropertyInteger('SetpointToleranceW', 300);
$this->RegisterAttributeString(self::ATTR_TRANSACTION, TransactionStore::toJson(TransactionStore::empty()));
$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->registerEmsVariables();
$this->registerControlVariables();
$this->registerStatusVariables();
$this->registerMeterVariables();
$this->registerDiagnosticVariables();
$this->RegisterTimer('Timer_Do_UserCalc', 1000, '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", "");');
$this->RegisterTimer('Timer_MeterAggregation', 60000, 'IPS_RequestAction(' . $this->InstanceID . ', "MeterAggregation", "");');
$this->setInitialDefaults();
}
public function ApplyChanges()
{
parent::ApplyChanges();
$interval = max(1, $this->ReadPropertyInteger('Interval'));
$this->SetTimerInterval('Timer_Do_UserCalc', $interval * 1000);
$this->SetTimerInterval('Timer_StatusWatchdog', 5000);
$this->SetTimerInterval('Timer_EMSWatchdog', 5000);
$this->SetTimerInterval('Timer_OCPPWatchdog', 5000);
$this->SetTimerInterval('Timer_MeterAggregation', max(10, $this->ReadPropertyInteger('MeterEnergyIntervalSeconds')) * 1000);
$this->EnableAction('Ladebereit');
$this->EnableAction('Managementmodus');
$this->EnableAction('Mindestladestrom');
$this->EnableAction('Konstantstrom');
$this->EnableAction('Max_Current_abs');
$this->EnableAction('SafeCurrent');
$this->EnableAction('AutomatischePhasenumschaltung');
$this->EnableAction('ManuelleSperre');
$this->EnableAction('VNB_Mode');
$this->EnableAction('VNB_Limit_A');
$this->EnableAction('VNB_Limit_W');
$this->EnableAction('Sperre_Prio');
$this->EnableAction('PV_Prio');
if ($this->GetValue('Max_Current_abs') <= 0) {
$this->SetValue('Max_Current_abs', $this->ReadPropertyFloat('MaxCurrentAbs'));
}
if ($this->GetValue('Mindestladestrom') <= 0) {
$this->SetValue('Mindestladestrom', $this->ReadPropertyFloat('MinCurrent'));
}
if ($this->GetValue('SafeCurrent') <= 0) {
$this->SetValue('SafeCurrent', $this->ReadPropertyFloat('SafeCurrent'));
}
$chargePointId = trim($this->ReadPropertyString('ChargePointId'));
$this->SetValue('ChargePointId', $chargePointId);
$this->SetValue('EVSEId', $this->ReadPropertyInteger('EVSEId'));
$this->SetValue('ConnectorId', $this->ReadPropertyInteger('ConnectorId'));
if ($chargePointId === '') {
$this->SetStatus(201);
$this->setDiagnostic('Warnung', 'Konfiguration', 'ChargePointId ist noch nicht gesetzt.');
$this->SetSummary('OCPP Scaffold: ChargePointId fehlt');
return;
}
$this->SetStatus(102);
$this->SetSummary('OCPP Scaffold: ' . $chargePointId);
}
public function RequestAction($Ident, $Value)
{
switch ($Ident) {
case 'SetAktuelle_Leistung':
$this->SetAktuelle_Leistung((float)$Value);
break;
case 'GetCurrentData':
$this->SetValue('Is_Peak_Shaving', (bool)$Value);
$this->GetCurrentData((bool)$Value);
break;
case 'Do_UserCalc':
$this->Do_UserCalc();
break;
case 'Ladebereit':
case 'AutomatischePhasenumschaltung':
case 'ManuelleSperre':
$this->SetValue($Ident, (bool)$Value);
$this->markPolicyChanged($Ident);
break;
case 'Managementmodus':
case 'VNB_Mode':
case 'Sperre_Prio':
case 'PV_Prio':
$this->SetValue($Ident, (int)$Value);
$this->markPolicyChanged($Ident);
break;
case 'Mindestladestrom':
case 'Konstantstrom':
case 'Max_Current_abs':
case 'SafeCurrent':
case 'VNB_Limit_A':
case 'VNB_Limit_W':
$this->SetValue($Ident, (float)$Value);
$this->markPolicyChanged($Ident);
break;
case 'Reset':
$this->resetDiagnostics();
break;
case 'ClearChargingProfile':
$this->queueOcppCommand('ClearChargingProfile', $this->buildClearProfilePayload());
break;
case 'HandleInboundFrame':
$this->HandleInboundFrame((string)$Value);
break;
case 'StatusWatchdog':
case 'EMSWatchdog':
case 'OCPPWatchdog':
$this->runWatchdogs();
break;
case 'MeterAggregation':
$this->aggregateEnergyFallback();
break;
default:
throw new Exception('Invalid Ident');
}
}
public function SetAktuelle_Leistung(float $power)
{
$lastPower = (float)$this->GetValue('Power');
$this->SetValue('Power', $power);
$this->WriteAttributeInteger(self::ATTR_LAST_EMS_UPDATE, time());
if (abs($lastPower - $power) > 1) {
$this->SetValue('Idle', false);
$this->SetValue('IdleCounter', $this->ReadPropertyInteger('IdleCounterMax'));
}
$this->setDiagnostic('Info', 'EMS', 'Neue EMS-Leistungsvorgabe gespeichert: ' . round($power) . ' W');
}
public function GetCurrentData(bool $Peak)
{
$this->SetValue('Is_Peak_Shaving', $Peak);
$calculator = new PowerStepCalculator();
$steps = $calculator->calculate($this->buildPowerStepInput());
$this->SetValue('PowerSteps', json_encode($steps));
$this->SetValue('Leistung_Delta', (float)$this->GetValue('Aktuelle_Leistung') - (float)$this->GetValue('Ladeleistung_Effektiv'));
$this->SetValue('SollIstAbweichung', (float)$this->GetValue('Leistung_Delta'));
return $steps;
}
public function Do_UserCalc()
{
$this->runWatchdogs();
$targetPower = $this->calculateEffectivePower();
$oldPower = (float)$this->GetValue('Aktuelle_Leistung');
$this->SetValue('Aktuelle_Leistung', $targetPower);
$this->SetValue('AktuellerEffektivSollwert', $targetPower);
$this->SetValue('Leistung_Delta', $targetPower - (float)$this->GetValue('Ladeleistung_Effektiv'));
$this->SetValue('SollIstAbweichung', $this->GetValue('Leistung_Delta'));
if ($this->shouldSendChargingProfile($oldPower, $targetPower)) {
$setpoint = $this->buildEffectiveSetpoint($targetPower);
$profile = (new ChargingProfileBuilder())->build($setpoint, $this->effectiveOcppVersion());
$this->queueOcppCommand('SetChargingProfile', $profile);
}
$this->ProcessIdleCounter();
$this->GetCurrentData($this->GetValue('Is_Peak_Shaving'));
}
public function HandleInboundFrame(string $json)
{
$message = OCPPMessage::fromJson($json, $this->effectiveOcppVersion(), $this->ReadPropertyString('ChargePointId'));
if ($message === null) {
$this->setDiagnostic('Fehler', 'OCPP', 'Ungueltiger OCPP Frame empfangen.');
return;
}
$this->SetValue('LetzteMeldungZeit', time());
$this->SetValue('LetzteMeldung', $message->action);
$this->SetValue('OCPP_Online', true);
$this->WriteAttributeInteger(self::ATTR_LAST_OCPP_HEARTBEAT, time());
switch ($message->action) {
case 'BootNotification':
$this->handleBootNotification($message);
break;
case 'Heartbeat':
$this->setDiagnostic('Info', 'OCPP', 'Heartbeat empfangen.');
break;
case 'StatusNotification':
$this->handleStatusNotification($message->payload);
break;
case 'MeterValues':
$this->handleMeterValues($message->payload);
break;
case 'StartTransaction':
case 'StopTransaction':
case 'TransactionEvent':
$this->handleTransaction($message);
break;
case 'Authorize':
$this->SetValue('LetztesIdToken', $this->extractIdToken($message->payload));
break;
case 'DataTransfer':
$this->handleDataTransfer($message->payload);
break;
default:
$this->setDiagnostic('Info', 'OCPP', 'OCPP Aktion empfangen: ' . $message->action);
break;
}
}
private function registerEmsVariables(): void
{
$this->RegisterVariableInteger('Sperre_Prio', 'Sperre_Prio', '', 10);
$this->RegisterVariableInteger('PV_Prio', 'PV_Prio', '', 11);
$this->RegisterVariableBoolean('Idle', 'Idle', '', 12);
$this->RegisterVariableFloat('Aktuelle_Leistung', 'Aktuelle_Leistung', '', 13);
$this->RegisterVariableFloat('Bezogene_Energie', 'Bezogene_Energie', '', 14);
$this->RegisterVariableString('PowerSteps', 'PowerSteps', '', 15);
$this->RegisterVariableFloat('Power', 'Power', '', 16);
$this->RegisterVariableBoolean('Is_Peak_Shaving', 'Is_Peak_Shaving', '', 17);
$this->RegisterVariableFloat('Leistung_Delta', 'Leistung_Delta', '', 18);
$this->RegisterVariableInteger('IdleCounter', 'IdleCounter', '', 19);
}
private function registerControlVariables(): void
{
$this->RegisterVariableBoolean('Ladebereit', 'Ladebereit', '~Switch', 30);
$this->RegisterVariableInteger('Managementmodus', 'Managementmodus', '', 31);
$this->RegisterVariableFloat('Mindestladestrom', 'Mindestladestrom', '', 32);
$this->RegisterVariableFloat('Konstantstrom', 'Konstantstrom', '', 33);
$this->RegisterVariableFloat('Max_Current_abs', 'Max_Current_abs', '', 34);
$this->RegisterVariableFloat('SafeCurrent', 'SafeCurrent', '', 35);
$this->RegisterVariableFloat('SafeOff', 'SafeOff', '', 36);
$this->RegisterVariableBoolean('AutomatischePhasenumschaltung', 'AutomatischePhasenumschaltung', '~Switch', 37);
$this->RegisterVariableBoolean('ManuelleSperre', 'ManuelleSperre', '~Switch', 38);
$this->RegisterVariableInteger('VNB_Mode', 'VNB_Mode', '', 39);
$this->RegisterVariableFloat('VNB_Limit_A', 'VNB_Limit_A', '', 40);
$this->RegisterVariableFloat('VNB_Limit_W', 'VNB_Limit_W', '', 41);
}
private function registerStatusVariables(): void
{
$this->RegisterVariableBoolean('OCPP_Online', 'OCPP_Online', '~Switch', 60);
$this->RegisterVariableString('OCPP_Version', 'OCPP_Version', '', 61);
$this->RegisterVariableString('ChargePointId', 'ChargePointId', '', 62);
$this->RegisterVariableInteger('EVSEId', 'EVSEId', '', 63);
$this->RegisterVariableInteger('ConnectorId', 'ConnectorId', '', 64);
$this->RegisterVariableString('ConnectorStatus', 'ConnectorStatus', '', 65);
$this->RegisterVariableString('ChargingState', 'ChargingState', '', 66);
$this->RegisterVariableString('TransactionId', 'TransactionId', '', 67);
$this->RegisterVariableBoolean('Car_detected', 'Car_detected', '~Switch', 68);
$this->RegisterVariableBoolean('Car_is_full', 'Car_is_full', '~Switch', 69);
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 70);
$this->RegisterVariableBoolean('Is_1_ph', 'Is_1_ph', '~Switch', 71);
$this->RegisterVariableInteger('NumberPhases', 'NumberPhases', '', 72);
$this->RegisterVariableInteger('PhaseToUse', 'PhaseToUse', '', 73);
$this->RegisterVariableFloat('AktuellerEffektivSollwert', 'AktuellerEffektivSollwert', '', 74);
$this->RegisterVariableString('AktiveFreigabequelle', 'AktiveFreigabequelle', '', 75);
$this->RegisterVariableString('AktiverSperrgrund', 'AktiverSperrgrund', '', 76);
$this->RegisterVariableString('AktiverReduktionsgrund', 'AktiverReduktionsgrund', '', 77);
$this->RegisterVariableInteger('LetzterFreigabewechsel', 'LetzterFreigabewechsel', '', 78);
$this->RegisterVariableString('LetztesIdToken', 'LetztesIdToken', '', 79);
}
private function registerMeterVariables(): void
{
$this->RegisterVariableFloat('Ladeleistung_Effektiv', 'Ladeleistung_Effektiv', '', 90);
$this->RegisterVariableFloat('Entladeleistung_Effektiv', 'Entladeleistung_Effektiv', '', 91);
$this->RegisterVariableFloat('Strom_L1', 'Strom_L1', '', 92);
$this->RegisterVariableFloat('Strom_L2', 'Strom_L2', '', 93);
$this->RegisterVariableFloat('Strom_L3', 'Strom_L3', '', 94);
$this->RegisterVariableFloat('Spannung_L1', 'Spannung_L1', '', 95);
$this->RegisterVariableFloat('Spannung_L2', 'Spannung_L2', '', 96);
$this->RegisterVariableFloat('Spannung_L3', 'Spannung_L3', '', 97);
$this->RegisterVariableFloat('Leistung_L1', 'Leistung_L1', '', 98);
$this->RegisterVariableFloat('Leistung_L2', 'Leistung_L2', '', 99);
$this->RegisterVariableFloat('Leistung_L3', 'Leistung_L3', '', 100);
$this->RegisterVariableFloat('Abgegebene_Energie', 'Abgegebene_Energie', '', 101);
$this->RegisterVariableFloat('SoC', 'SoC', '', 102);
$this->RegisterVariableFloat('Temperatur', 'Temperatur', '', 103);
$this->RegisterVariableString('MesswertQualitaet', 'MesswertQualitaet', '', 104);
$this->RegisterVariableInteger('LetzterMeterValueZeitpunkt', 'LetzterMeterValueZeitpunkt', '', 105);
}
private function registerDiagnosticVariables(): void
{
$this->RegisterVariableString('Kommunikationsstatus', 'Kommunikationsstatus', '', 120);
$this->RegisterVariableString('LetzteMeldung', 'LetzteMeldung', '', 121);
$this->RegisterVariableInteger('LetzteMeldungZeit', 'LetzteMeldungZeit', '', 122);
$this->RegisterVariableString('Fehlerklasse', 'Fehlerklasse', '', 123);
$this->RegisterVariableString('AktuelleStoerung', 'AktuelleStoerung', '', 124);
$this->RegisterVariableBoolean('WarnungAktiv', 'WarnungAktiv', '~Switch', 125);
$this->RegisterVariableBoolean('FehlerAktiv', 'FehlerAktiv', '~Switch', 126);
$this->RegisterVariableBoolean('StoerungAktiv', 'StoerungAktiv', '~Switch', 127);
$this->RegisterVariableString('LetzterOCPPFehlercode', 'LetzterOCPPFehlercode', '', 128);
$this->RegisterVariableBoolean('LetztesChargingProfileAccepted', 'LetztesChargingProfileAccepted', '~Switch', 129);
$this->RegisterVariableInteger('LetzterCommandTimestamp', 'LetzterCommandTimestamp', '', 130);
$this->RegisterVariableString('LetzterCommandStatus', 'LetzterCommandStatus', '', 131);
$this->RegisterVariableFloat('SollIstAbweichung', 'SollIstAbweichung', '', 132);
}
private function setInitialDefaults(): void
{
$defaults = [
'Idle' => true,
'PowerSteps' => json_encode([0]),
'Ladebereit' => true,
'Managementmodus' => PowerStepCalculator::MODE_SOLAR,
'Mindestladestrom' => 6.0,
'Konstantstrom' => 6.0,
'Max_Current_abs' => 32.0,
'SafeCurrent' => 6.0,
'SafeOff' => 0.0,
'OCPP_Version' => 'auto',
'ConnectorStatus' => 'Unknown',
'ChargingState' => 'Unknown',
'NumberPhases' => 3,
'PhaseToUse' => 0,
'AktiveFreigabequelle' => 'Symcon',
'AktiverSperrgrund' => '',
'AktiverReduktionsgrund' => '',
'MesswertQualitaet' => 'unbekannt',
'Kommunikationsstatus' => 'Scaffold',
'LetzteMeldung' => 'Initialisiert',
'Fehlerklasse' => '',
'AktuelleStoerung' => '',
'LetzterCommandStatus' => 'Noch kein OCPP-Transport verifiziert'
];
foreach ($defaults as $ident => $value) {
$this->SetValue($ident, $value);
}
}
private function buildPowerStepInput(): array
{
return [
'idle' => (bool)$this->GetValue('Idle'),
'currentPowerW' => (float)$this->GetValue('Aktuelle_Leistung'),
'ladebereit' => (bool)$this->GetValue('Ladebereit'),
'manualLock' => (bool)$this->GetValue('ManuelleSperre'),
'carDetected' => (bool)$this->GetValue('Car_detected'),
'carFull' => (bool)$this->GetValue('Car_is_full'),
'mode' => (int)$this->GetValue('Managementmodus'),
'minCurrentA' => (float)$this->GetValue('Mindestladestrom'),
'maxCurrentA' => (float)$this->GetValue('Max_Current_abs'),
'safeCurrentA' => (float)$this->GetValue('SafeCurrent'),
'constantCurrentA' => (float)$this->GetValue('Konstantstrom'),
'numberPhases' => (int)$this->GetValue('NumberPhases'),
'groupLimitA' => $this->ReadPropertyFloat('GroupLimitA'),
'groupLimitW' => $this->ReadPropertyFloat('GroupLimitW'),
'vnbLimitA' => (float)$this->GetValue('VNB_Limit_A'),
'vnbLimitW' => (float)$this->GetValue('VNB_Limit_W'),
'voltage' => $this->averageVoltage()
];
}
private function calculateEffectivePower(): float
{
$steps = json_decode($this->GetValue('PowerSteps'), true);
if (!is_array($steps) || empty($steps)) {
$steps = [0];
}
$requested = (float)$this->GetValue('Power');
$mode = (int)$this->GetValue('Managementmodus');
if ($mode === PowerStepCalculator::MODE_NEVER || !$this->GetValue('Ladebereit') || $this->GetValue('ManuelleSperre')) {
$this->SetValue('AktiverSperrgrund', 'Bedienung/Sperre');
return 0.0;
}
if ($mode === PowerStepCalculator::MODE_CONSTANT) {
$requested = PhaseManager::wattsFromCurrent((float)$this->GetValue('Konstantstrom'), (int)$this->GetValue('NumberPhases'), $this->averageVoltage());
}
$vnbMode = (int)$this->GetValue('VNB_Mode');
if ($vnbMode === 1) {
$this->SetValue('AktiverSperrgrund', 'VNB harte Sperre');
return 0.0;
}
if ($vnbMode === 2) {
$this->SetValue('AktiverReduktionsgrund', 'VNB Reduktion');
} else {
$this->SetValue('AktiverReduktionsgrund', '');
}
$best = 0.0;
foreach ($steps as $step) {
$step = (float)$step;
if ($step <= $requested && $step >= $best) {
$best = $step;
}
}
return $best;
}
private function buildEffectiveSetpoint(float $powerW): array
{
$phases = (int)$this->GetValue('NumberPhases');
return [
'effectivePowerW' => $powerW,
'effectiveCurrentA' => PhaseManager::currentFromPower($powerW, $phases, $this->averageVoltage()),
'numberPhases' => $phases,
'phaseToUse' => (int)$this->GetValue('PhaseToUse'),
'evseId' => $this->ReadPropertyInteger('EVSEId'),
'connectorId' => $this->ReadPropertyInteger('ConnectorId'),
'reason' => $this->GetValue('AktiverReduktionsgrund'),
'activeReleaseSource' => $this->GetValue('AktiveFreigabequelle'),
'activeBlockingReason' => $this->GetValue('AktiverSperrgrund'),
'activeReductionReason' => $this->GetValue('AktiverReduktionsgrund')
];
}
private function buildClearProfilePayload(): array
{
if ($this->effectiveOcppVersion() === '1.6') {
return ['connectorId' => $this->ReadPropertyInteger('ConnectorId')];
}
return ['evseId' => $this->ReadPropertyInteger('EVSEId')];
}
private function shouldSendChargingProfile(float $oldPower, float $newPower): bool
{
$delta = abs($oldPower - $newPower);
$toleranceW = max(1, $this->ReadPropertyInteger('SetpointToleranceW'));
$tolerancePercent = max(0.0, $this->ReadPropertyFloat('SetpointTolerancePercent'));
$percentLimit = max($oldPower, 1.0) * ($tolerancePercent / 100.0);
return $delta >= max($toleranceW, $percentLimit);
}
private function queueOcppCommand(string $action, array $payload): void
{
$version = $this->effectiveOcppVersion();
$message = OCPPMessage::call($action, $payload, $version, $this->ReadPropertyString('ChargePointId'));
$server = $this->ReadPropertyInteger('OCPPServerInstance');
$this->SetValue('LetzterCommandTimestamp', time());
$this->SetValue('LetzterCommandStatus', 'Queued scaffold: ' . $action);
if ($server > 0 && IPS_InstanceExists($server)) {
IPS_RequestAction($server, 'QueueOutboundFrame', json_encode(OCPPTransport::buildEnvelope($message, $this->InstanceID)));
return;
}
$this->setDiagnostic('Warnung', 'OCPP Transport', 'Kein OCPP_Server verbunden. Command nur lokal vorgemerkt: ' . $action);
}
private function runWatchdogs(): void
{
$manager = new FailSafeManager();
$result = $manager->evaluate([
'lastEmsUpdate' => $this->ReadAttributeInteger(self::ATTR_LAST_EMS_UPDATE),
'emsWatchdogSeconds' => $this->ReadPropertyInteger('EMSWatchdogSeconds'),
'lastOcppHeartbeat' => $this->ReadAttributeInteger(self::ATTR_LAST_OCPP_HEARTBEAT),
'ocppHeartbeatTimeoutSeconds' => $this->ReadPropertyInteger('OCPPHeartbeatTimeoutSeconds'),
'ocppOnline' => (bool)$this->GetValue('OCPP_Online'),
'criticalFault' => (bool)$this->GetValue('StoerungAktiv'),
'safeOffStrategy' => $this->ReadPropertyString('SafeOffStrategy'),
'safeCurrentA' => (float)$this->GetValue('SafeCurrent'),
'numberPhases' => (int)$this->GetValue('NumberPhases')
]);
if (!empty($result['warnings'])) {
$this->SetValue('WarnungAktiv', true);
$this->SetValue('Fehlerklasse', implode(', ', $result['warnings']));
}
if ($result['blockingReason'] !== '') {
$this->SetValue('AktiverSperrgrund', $result['blockingReason']);
$this->SetValue('StoerungAktiv', true);
}
}
private function ProcessIdleCounter(): void
{
$counter = (int)$this->GetValue('IdleCounter');
if ($counter > 0) {
$this->SetValue('IdleCounter', $counter - 1);
$this->SetValue('Idle', false);
return;
}
$this->SetValue('Idle', true);
}
private function handleBootNotification(OCPPMessage $message): void
{
$version = $this->effectiveOcppVersion();
$this->SetValue('OCPP_Version', $version);
$this->WriteAttributeString(self::ATTR_CAPABILITIES, json_encode(CapabilityModel::detectFromBoot($version, $message->payload)));
$this->setDiagnostic('Info', 'OCPP', 'BootNotification verarbeitet.');
}
private function handleStatusNotification(array $payload): void
{
$status = (string)($payload['status'] ?? $payload['connectorStatus'] ?? 'Unknown');
$this->SetValue('ConnectorStatus', $status);
$this->SetValue('ChargingState', (string)($payload['chargingState'] ?? $status));
$this->SetValue('Car_detected', in_array($status, ['Preparing', 'Charging', 'SuspendedEV', 'SuspendedEVSE', 'Finishing', 'Occupied'], true));
$this->SetValue('Car_is_full', in_array($status, ['Finishing', 'SuspendedEV'], true));
}
private function handleMeterValues(array $payload): void
{
$values = (new MeterValueNormalizer())->normalize($payload);
if ($values['powerImportW'] !== null) {
$this->SetValue('Ladeleistung_Effektiv', (float)$values['powerImportW']);
}
if ($values['powerExportW'] !== null) {
$this->SetValue('Entladeleistung_Effektiv', (float)$values['powerExportW']);
}
if ($values['energyImportWh'] !== null) {
$this->SetValue('Bezogene_Energie', (float)$values['energyImportWh']);
}
if ($values['energyExportWh'] !== null) {
$this->SetValue('Abgegebene_Energie', (float)$values['energyExportWh']);
}
foreach ([1, 2, 3] as $phase) {
if ($values['currentA'][$phase] !== null) {
$this->SetValue('Strom_L' . $phase, (float)$values['currentA'][$phase]);
}
if ($values['voltageV'][$phase] !== null) {
$this->SetValue('Spannung_L' . $phase, (float)$values['voltageV'][$phase]);
}
if ($values['powerPhaseW'][$phase] !== null) {
$this->SetValue('Leistung_L' . $phase, (float)$values['powerPhaseW'][$phase]);
}
}
if ($values['soc'] !== null) {
$this->SetValue('SoC', (float)$values['soc']);
}
if ($values['temperature'] !== null) {
$this->SetValue('Temperatur', (float)$values['temperature']);
}
$this->SetValue('MesswertQualitaet', $values['quality']);
$this->SetValue('LetzterMeterValueZeitpunkt', (int)$values['timestamp']);
}
private function handleTransaction(OCPPMessage $message): void
{
$state = TransactionStore::fromJson($this->ReadAttributeString(self::ATTR_TRANSACTION));
$payload = $message->payload;
$transactionId = (string)($payload['transactionId'] ?? ($payload['transactionInfo']['transactionId'] ?? $state['activeTransactionId']));
if ($transactionId !== '') {
$state['activeTransactionId'] = $transactionId;
$this->SetValue('TransactionId', $transactionId);
}
$state['lastTransactionState'] = $message->action;
$state['lastIdToken'] = $this->extractIdToken($payload);
if ($state['transactionStartTime'] === 0 && ($message->action === 'StartTransaction' || $message->action === 'TransactionEvent')) {
$state['transactionStartTime'] = time();
}
if ($message->action === 'StopTransaction') {
$state['sessionStopReason'] = (string)($payload['reason'] ?? 'Unknown');
}
$this->WriteAttributeString(self::ATTR_TRANSACTION, TransactionStore::toJson($state));
$this->SetValue('LetztesIdToken', $state['lastIdToken']);
}
private function handleDataTransfer(array $payload): void
{
$registry = new DataTransferRegistry($this->ReadPropertyBoolean('AllowDataTransfer'));
$vendorId = (string)($payload['vendorId'] ?? '');
$messageId = (string)($payload['messageId'] ?? '');
if (!$registry->isAllowed($vendorId, $messageId)) {
$this->setDiagnostic('Warnung', 'DataTransfer', 'DataTransfer blockiert: ' . $vendorId . '/' . $messageId);
return;
}
$this->setDiagnostic('Info', 'DataTransfer', 'DataTransfer empfangen: ' . $vendorId . '/' . $messageId);
}
private function aggregateEnergyFallback(): void
{
if ($this->GetValue('LetzterMeterValueZeitpunkt') > 0) {
return;
}
$energy = (float)$this->GetValue('Bezogene_Energie');
$energy += (float)$this->GetValue('Ladeleistung_Effektiv') * (max(1, $this->ReadPropertyInteger('MeterEnergyIntervalSeconds')) / 3600);
$this->SetValue('Bezogene_Energie', $energy);
}
private function extractIdToken(array $payload): string
{
if (isset($payload['idTag'])) {
return (string)$payload['idTag'];
}
if (isset($payload['idToken']['idToken'])) {
return (string)$payload['idToken']['idToken'];
}
return '';
}
private function averageVoltage(): float
{
$values = [];
foreach (['Spannung_L1', 'Spannung_L2', 'Spannung_L3'] as $ident) {
$value = (float)$this->GetValue($ident);
if ($value > 0) {
$values[] = $value;
}
}
if (empty($values)) {
return 230.0;
}
return array_sum($values) / count($values);
}
private function effectiveOcppVersion(): string
{
$version = $this->ReadPropertyString('OCPPVersionMode');
if ($version === 'auto') {
$current = $this->GetValue('OCPP_Version');
return ($current === '' || $current === 'auto') ? '1.6' : $current;
}
return $version;
}
private function markPolicyChanged(string $ident): void
{
$this->SetValue('LetzterFreigabewechsel', time());
$this->setDiagnostic('Info', 'Bedienung', 'Policy geaendert: ' . $ident);
$this->GetCurrentData($this->GetValue('Is_Peak_Shaving'));
}
private function resetDiagnostics(): void
{
$this->SetValue('WarnungAktiv', false);
$this->SetValue('FehlerAktiv', false);
$this->SetValue('StoerungAktiv', false);
$this->SetValue('Fehlerklasse', '');
$this->SetValue('AktuelleStoerung', '');
$this->SetValue('LetzterOCPPFehlercode', '');
$this->setDiagnostic('Info', 'Diagnose', 'Diagnose zurueckgesetzt.');
}
private function setDiagnostic(string $severity, string $source, string $text, string $code = ''): void
{
$this->SetValue('LetzteMeldung', $text);
$this->SetValue('LetzteMeldungZeit', time());
$this->SetValue('Kommunikationsstatus', Diagnostics::statusFromFlags((bool)$this->GetValue('OCPP_Online'), (bool)$this->GetValue('StoerungAktiv')));
if ($severity === 'Warnung') {
$this->SetValue('WarnungAktiv', true);
} elseif ($severity === 'Fehler') {
$this->SetValue('FehlerAktiv', true);
$this->SetValue('Fehlerklasse', $source);
} elseif ($severity === 'Stoerung') {
$this->SetValue('StoerungAktiv', true);
$this->SetValue('AktuelleStoerung', $text);
}
if ($code !== '') {
$this->SetValue('LetzterOCPPFehlercode', $code);
}
if ($this->ReadPropertyInteger('DebugLevel') > 0) {
$this->SendDebug($source, $severity . ': ' . $text, 0);
}
}
}
?>