From e240b17d3d9f2a6a17a2ce38d619ce928ff07c40 Mon Sep 17 00:00:00 2001 From: Manu Boro Date: Sun, 10 May 2026 12:38:49 +0200 Subject: [PATCH] integration Fronius ladestation mit ocpp. --- Ladestation_OCPP/README.md | 17 + Ladestation_OCPP/form.json | 56 +++ Ladestation_OCPP/libs/CapabilityModel.php | 3 + .../libs/MeterValueNormalizer.php | 6 +- .../libs/WattpilotGen1Profile.php | 89 ++++ Ladestation_OCPP/module.php | 422 +++++++++++++++++- OCPP_Server/README.md | 5 +- OCPP_Server/form.json | 5 + OCPP_Server/module.php | 141 +++++- docs/OCPP/README.md | 13 + docs/OCPP/WATTPILOT_GEN1.md | 125 ++++++ 11 files changed, 861 insertions(+), 21 deletions(-) create mode 100644 Ladestation_OCPP/libs/WattpilotGen1Profile.php create mode 100644 docs/OCPP/WATTPILOT_GEN1.md diff --git a/Ladestation_OCPP/README.md b/Ladestation_OCPP/README.md index c4f43b6..93218b5 100644 --- a/Ladestation_OCPP/README.md +++ b/Ladestation_OCPP/README.md @@ -10,6 +10,7 @@ Status dieser Version: **M1 Scaffold**. Das Modul ist installierbar, haelt den b - 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`. +- Geraeteprofil `fronius_wattpilot_gen1` fuer erste Fronius-Wattpilot-Gen1-Tests ueber OCPP 1.6J. ## Manager-Anbindung @@ -51,6 +52,22 @@ Die OCPP-Klassen liegen in `Ladestation_OCPP/libs/`: - Versionsadapter: `OCPP16Adapter`, `OCPP201Adapter`, `OCPP21Adapter` - Fachlogik: `PowerStepCalculator`, `ChargingProfileBuilder`, `MeterValueNormalizer`, `TransactionStore` - Sicherheit/Diagnose: `FailSafeManager`, `Diagnostics`, `CapabilityModel`, `DataTransferRegistry`, `PhaseManager` +- Geraeteprofil: `WattpilotGen1Profile` + +## Fronius Wattpilot Gen1 + +Das Wattpilot-Profil bleibt strikt OCPP-only: + +- keine lokale go-e HTTP API +- keine Fronius Solar API +- keine Fronius Cloud +- einfache `TxProfile`-Limits in Ampere bei aktiver Transaktion, sonst `TxDefaultProfile` +- genau eine Schedule-Periode +- keine Phasen-Erzwingung per OCPP +- `RemoteStartTransaction`, `RemoteStopTransaction` und `ChangeAvailability` +- `SetChargingProfile` mit begrenzten Retries und Capability-Abschaltung bei Ablehnung + +Details stehen in `docs/OCPP/WATTPILOT_GEN1.md`. ## Transport diff --git a/Ladestation_OCPP/form.json b/Ladestation_OCPP/form.json index 8c8d20f..323b270 100644 --- a/Ladestation_OCPP/form.json +++ b/Ladestation_OCPP/form.json @@ -15,11 +15,25 @@ { "caption": "OCPP 2.1 vorbereitet", "value": "2.1" } ] }, + { + "type": "Select", + "name": "DeviceProfile", + "caption": "Geraeteprofil", + "options": [ + { "caption": "Generisches OCPP", "value": "generic_ocpp" }, + { "caption": "Fronius Wattpilot Gen1 OCPP 1.6J", "value": "fronius_wattpilot_gen1" } + ] + }, { "type": "ValidationTextBox", "name": "ChargePointId", "caption": "ChargePointId" }, + { + "type": "ValidationTextBox", + "name": "AuthorizeIdTag", + "caption": "Default ID-Tag" + }, { "type": "NumberSpinner", "name": "EVSEId", @@ -92,18 +106,35 @@ "caption": "EMS Watchdog", "suffix": "Sekunden" }, + { + "type": "NumberSpinner", + "name": "OCPPHeartbeatIntervalSeconds", + "caption": "OCPP Heartbeat Intervall", + "suffix": "Sekunden" + }, { "type": "NumberSpinner", "name": "OCPPHeartbeatTimeoutSeconds", "caption": "OCPP Heartbeat Timeout", "suffix": "Sekunden" }, + { + "type": "NumberSpinner", + "name": "MeterValuesTimeoutSeconds", + "caption": "MeterValues Timeout", + "suffix": "Sekunden" + }, { "type": "NumberSpinner", "name": "CommandAckTimeoutSeconds", "caption": "Command ACK Timeout", "suffix": "Sekunden" }, + { + "type": "NumberSpinner", + "name": "SmartChargingRetryLimit", + "caption": "SmartCharging Retries" + }, { "type": "NumberSpinner", "name": "DebugLevel", @@ -120,6 +151,31 @@ "type": "Button", "caption": "Clear ChargingProfile", "onClick": "IPS_RequestAction($id, \"ClearChargingProfile\", \"\");" + }, + { + "type": "Button", + "caption": "SmartCharging testen", + "onClick": "IPS_RequestAction($id, \"ProbeSmartCharging\", \"\");" + }, + { + "type": "Button", + "caption": "Remote Start", + "onClick": "IPS_RequestAction($id, \"RemoteStartTransaction\", \"\");" + }, + { + "type": "Button", + "caption": "Remote Stop", + "onClick": "IPS_RequestAction($id, \"RemoteStopTransaction\", \"\");" + }, + { + "type": "Button", + "caption": "Availability Operative", + "onClick": "IPS_RequestAction($id, \"ChangeAvailabilityOperative\", \"\");" + }, + { + "type": "Button", + "caption": "Availability Inoperative", + "onClick": "IPS_RequestAction($id, \"ChangeAvailabilityInoperative\", \"\");" } ] } diff --git a/Ladestation_OCPP/libs/CapabilityModel.php b/Ladestation_OCPP/libs/CapabilityModel.php index 71ae807..74caf9c 100644 --- a/Ladestation_OCPP/libs/CapabilityModel.php +++ b/Ladestation_OCPP/libs/CapabilityModel.php @@ -18,6 +18,9 @@ class CapabilityModel 'phaseToUse' => false, 'phaseSwitching' => false, 'dataTransfer' => false, + 'remoteStartStop' => false, + 'availability' => false, + 'simpleAmpereProfiles' => false, 'getVariables' => false, 'setVariables' => false, 'transactionEvent' => false, diff --git a/Ladestation_OCPP/libs/MeterValueNormalizer.php b/Ladestation_OCPP/libs/MeterValueNormalizer.php index c0a8300..26a28df 100644 --- a/Ladestation_OCPP/libs/MeterValueNormalizer.php +++ b/Ladestation_OCPP/libs/MeterValueNormalizer.php @@ -104,7 +104,11 @@ class MeterValueNormalizer private function normalizeValue(array $sample): float { - $value = (float)($sample['value'] ?? 0); + $rawValue = $sample['value'] ?? 0; + if (is_array($rawValue)) { + $rawValue = $rawValue['value'] ?? 0; + } + $value = (float)$rawValue; $unit = $sample['unit'] ?? ($sample['unitOfMeasure']['unit'] ?? ''); $unit = strtolower((string)$unit); diff --git a/Ladestation_OCPP/libs/WattpilotGen1Profile.php b/Ladestation_OCPP/libs/WattpilotGen1Profile.php new file mode 100644 index 0000000..a85c0f1 --- /dev/null +++ b/Ladestation_OCPP/libs/WattpilotGen1Profile.php @@ -0,0 +1,89 @@ + true, + 'meterValues' => true, + 'phaseMetering' => false, + 'currentImportPerPhase' => false, + 'voltagePerPhase' => false, + 'powerImport' => true, + 'powerExport' => false, + 'energyImport' => true, + 'energyExport' => false, + 'numberPhases' => false, + 'phaseToUse' => false, + 'phaseSwitching' => false, + 'dataTransfer' => false, + 'remoteStartStop' => true, + 'availability' => true, + 'simpleAmpereProfiles' => true, + 'getVariables' => false, + 'setVariables' => false, + 'transactionEvent' => false, + 'soc' => false, + 'temperature' => false, + 'signedMeterValue' => false, + 'bidirectional' => false + ]; + } + + public static function isWattpilotBoot(array $payload): bool + { + $vendor = strtolower((string)($payload['chargePointVendor'] ?? '')); + $model = strtolower((string)($payload['chargePointModel'] ?? '')); + return strpos($vendor, 'fronius') !== false || strpos($model, 'wattpilot') !== false; + } + + public static function buildChargingProfile(array $setpoint, int $profileId): array + { + $currentA = max(0.0, (float)($setpoint['effectiveCurrentA'] ?? 0.0)); + $validTo = gmdate('Y-m-d\TH:i:s\Z', time() + 120); + $transactionId = (string)($setpoint['transactionId'] ?? ''); + $purpose = $transactionId !== '' ? 'TxProfile' : 'TxDefaultProfile'; + + $profile = [ + 'connectorId' => (int)($setpoint['connectorId'] ?? 1), + 'csChargingProfiles' => [ + 'chargingProfileId' => $profileId, + 'stackLevel' => 0, + 'chargingProfilePurpose' => $purpose, + 'chargingProfileKind' => 'Absolute', + 'validTo' => $validTo, + 'chargingSchedule' => [ + 'duration' => 120, + 'chargingRateUnit' => 'A', + 'chargingSchedulePeriod' => [ + [ + 'startPeriod' => 0, + 'limit' => round($currentA, 1) + ] + ] + ] + ] + ]; + + if ($transactionId !== '') { + $profile['csChargingProfiles']['transactionId'] = is_numeric($transactionId) ? (int)$transactionId : $transactionId; + } + + return $profile; + } + + public static function supportedMeasurands(): array + { + return [ + 'Power.Active.Import', + 'Energy.Active.Import.Register', + 'Voltage', + 'Current.Import' + ]; + } +} + +?> diff --git a/Ladestation_OCPP/module.php b/Ladestation_OCPP/module.php index 7a8f0c0..02ddda5 100644 --- a/Ladestation_OCPP/module.php +++ b/Ladestation_OCPP/module.php @@ -14,6 +14,7 @@ require_once __DIR__ . '/libs/PhaseManager.php'; require_once __DIR__ . '/libs/FailSafeManager.php'; require_once __DIR__ . '/libs/DataTransferRegistry.php'; require_once __DIR__ . '/libs/Diagnostics.php'; +require_once __DIR__ . '/libs/WattpilotGen1Profile.php'; class Ladestation_OCPP extends IPSModule { @@ -21,23 +22,29 @@ 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'; public function Create() { parent::Create(); $this->RegisterPropertyString('OCPPVersionMode', 'auto'); + $this->RegisterPropertyString('DeviceProfile', 'generic_ocpp'); $this->RegisterPropertyString('ChargePointId', ''); $this->RegisterPropertyInteger('EVSEId', 1); $this->RegisterPropertyInteger('ConnectorId', 1); $this->RegisterPropertyInteger('OCPPServerInstance', 0); + $this->RegisterPropertyString('AuthorizeIdTag', 'Symcon'); $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('OCPPHeartbeatIntervalSeconds', 30); $this->RegisterPropertyInteger('OCPPHeartbeatTimeoutSeconds', 90); + $this->RegisterPropertyInteger('MeterValuesTimeoutSeconds', 180); $this->RegisterPropertyInteger('CommandAckTimeoutSeconds', 30); + $this->RegisterPropertyInteger('SmartChargingRetryLimit', 3); $this->RegisterPropertyInteger('MeterPowerIntervalSeconds', 1); $this->RegisterPropertyInteger('MeterEnergyIntervalSeconds', 60); $this->RegisterPropertyInteger('IdleCounterMax', 2); @@ -58,6 +65,7 @@ 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->registerEmsVariables(); $this->registerControlVariables(); @@ -98,6 +106,7 @@ class Ladestation_OCPP extends IPSModule $this->EnableAction('VNB_Limit_W'); $this->EnableAction('Sperre_Prio'); $this->EnableAction('PV_Prio'); + $this->applyDeviceProfileCapabilities(); if ($this->GetValue('Max_Current_abs') <= 0) { $this->SetValue('Max_Current_abs', $this->ReadPropertyFloat('MaxCurrentAbs')); @@ -113,10 +122,15 @@ class Ladestation_OCPP extends IPSModule $this->SetValue('ChargePointId', $chargePointId); $this->SetValue('EVSEId', $this->ReadPropertyInteger('EVSEId')); $this->SetValue('ConnectorId', $this->ReadPropertyInteger('ConnectorId')); + $server = $this->ReadPropertyInteger('OCPPServerInstance'); + if ($server > 0) { + $this->RegisterReference($server); + } if ($chargePointId === '') { $this->SetStatus(201); $this->setDiagnostic('Warnung', 'Konfiguration', 'ChargePointId ist noch nicht gesetzt.'); + $this->SetTimerInterval('Timer_Do_UserCalc', 0); $this->SetSummary('OCPP Scaffold: ChargePointId fehlt'); return; } @@ -174,6 +188,29 @@ class Ladestation_OCPP extends IPSModule $this->queueOcppCommand('ClearChargingProfile', $this->buildClearProfilePayload()); break; + case 'RemoteStartTransaction': + $this->queueOcppCommand('RemoteStartTransaction', $this->buildRemoteStartPayload()); + break; + + case 'RemoteStopTransaction': + $payload = $this->buildRemoteStopPayload(); + if (!empty($payload)) { + $this->queueOcppCommand('RemoteStopTransaction', $payload); + } + break; + + case 'ChangeAvailabilityOperative': + $this->queueOcppCommand('ChangeAvailability', $this->buildAvailabilityPayload('Operative')); + break; + + case 'ChangeAvailabilityInoperative': + $this->queueOcppCommand('ChangeAvailability', $this->buildAvailabilityPayload('Inoperative')); + break; + + case 'ProbeSmartCharging': + $this->probeSmartCharging(); + break; + case 'HandleInboundFrame': $this->HandleInboundFrame((string)$Value); break; @@ -233,7 +270,7 @@ class Ladestation_OCPP extends IPSModule if ($this->shouldSendChargingProfile($oldPower, $targetPower)) { $setpoint = $this->buildEffectiveSetpoint($targetPower); - $profile = (new ChargingProfileBuilder())->build($setpoint, $this->effectiveOcppVersion()); + $profile = $this->buildChargingProfilePayload($setpoint); $this->queueOcppCommand('SetChargingProfile', $profile); } @@ -252,34 +289,55 @@ class Ladestation_OCPP extends IPSModule $this->SetValue('LetzteMeldungZeit', time()); $this->SetValue('LetzteMeldung', $message->action); $this->SetValue('OCPP_Online', true); + $this->SetValue('OCPP_Connected', true); + $this->SetValue('OCPP_LastSeen', time()); + $this->SetValue('OCPP_Error', ''); $this->WriteAttributeInteger(self::ATTR_LAST_OCPP_HEARTBEAT, time()); + if ($message->messageTypeId === 3) { + $this->handleCallResult($message); + return; + } + + if ($message->messageTypeId === 4) { + $this->handleCallError($message); + return; + } + switch ($message->action) { case 'BootNotification': $this->handleBootNotification($message); + $this->respondToCall($message, $this->buildBootResponse()); break; case 'Heartbeat': $this->setDiagnostic('Info', 'OCPP', 'Heartbeat empfangen.'); + $this->respondToCall($message, ['currentTime' => gmdate('Y-m-d\TH:i:s\Z')]); break; case 'StatusNotification': $this->handleStatusNotification($message->payload); + $this->respondToCall($message, []); break; case 'MeterValues': $this->handleMeterValues($message->payload); + $this->respondToCall($message, []); break; case 'StartTransaction': case 'StopTransaction': case 'TransactionEvent': $this->handleTransaction($message); + $this->respondToCall($message, $this->buildTransactionResponse($message)); break; case 'Authorize': $this->SetValue('LetztesIdToken', $this->extractIdToken($message->payload)); + $this->respondToCall($message, ['idTagInfo' => ['status' => 'Accepted']]); break; case 'DataTransfer': $this->handleDataTransfer($message->payload); + $this->respondToCall($message, $this->buildDataTransferResponse($message->payload)); break; default: $this->setDiagnostic('Info', 'OCPP', 'OCPP Aktion empfangen: ' . $message->action); + $this->respondToCall($message, []); break; } } @@ -312,6 +370,7 @@ class Ladestation_OCPP extends IPSModule $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); + $this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 42); } private function registerStatusVariables(): void @@ -336,6 +395,10 @@ class Ladestation_OCPP extends IPSModule $this->RegisterVariableString('AktiverReduktionsgrund', 'AktiverReduktionsgrund', '', 77); $this->RegisterVariableInteger('LetzterFreigabewechsel', 'LetzterFreigabewechsel', '', 78); $this->RegisterVariableString('LetztesIdToken', 'LetztesIdToken', '', 79); + $this->RegisterVariableString('DeviceProfile', 'DeviceProfile', '', 80); + $this->RegisterVariableBoolean('OCPP_Connected', 'OCPP_Connected', '~Switch', 81); + $this->RegisterVariableInteger('OCPP_LastSeen', 'OCPP_LastSeen', '', 82); + $this->RegisterVariableString('OCPP_Error', 'OCPP_Error', '', 83); } private function registerMeterVariables(): void @@ -373,6 +436,14 @@ class Ladestation_OCPP extends IPSModule $this->RegisterVariableInteger('LetzterCommandTimestamp', 'LetzterCommandTimestamp', '', 130); $this->RegisterVariableString('LetzterCommandStatus', 'LetzterCommandStatus', '', 131); $this->RegisterVariableFloat('SollIstAbweichung', 'SollIstAbweichung', '', 132); + $this->RegisterVariableBoolean('Capability_SmartCharging', 'Capability_SmartCharging', '~Switch', 133); + $this->RegisterVariableBoolean('Capability_MeterValues', 'Capability_MeterValues', '~Switch', 134); + $this->RegisterVariableBoolean('Capability_RemoteStartStop', 'Capability_RemoteStartStop', '~Switch', 135); + $this->RegisterVariableBoolean('Capability_Availability', 'Capability_Availability', '~Switch', 136); + $this->RegisterVariableBoolean('Capability_PhaseValues', 'Capability_PhaseValues', '~Switch', 137); + $this->RegisterVariableBoolean('Capability_SimpleAmpereProfiles', 'Capability_SimpleAmpereProfiles', '~Switch', 138); + $this->RegisterVariableInteger('WattpilotProfileRetries', 'WattpilotProfileRetries', '', 139); + $this->RegisterVariableBoolean('WattpilotSmartChargingBlocked', 'WattpilotSmartChargingBlocked', '~Switch', 140); } private function setInitialDefaults(): void @@ -387,7 +458,11 @@ class Ladestation_OCPP extends IPSModule 'Max_Current_abs' => 32.0, 'SafeCurrent' => 6.0, 'SafeOff' => 0.0, + 'DeviceProfile' => 'generic_ocpp', 'OCPP_Version' => 'auto', + 'OCPP_Connected' => false, + 'OCPP_LastSeen' => 0, + 'OCPP_Error' => '', 'ConnectorStatus' => 'Unknown', 'ChargingState' => 'Unknown', 'NumberPhases' => 3, @@ -400,7 +475,9 @@ class Ladestation_OCPP extends IPSModule 'LetzteMeldung' => 'Initialisiert', 'Fehlerklasse' => '', 'AktuelleStoerung' => '', - 'LetzterCommandStatus' => 'Noch kein OCPP-Transport verifiziert' + 'LetzterCommandStatus' => 'Noch kein OCPP-Transport verifiziert', + 'WattpilotProfileRetries' => 0, + 'WattpilotSmartChargingBlocked' => false ]; foreach ($defaults as $ident => $value) { @@ -476,13 +553,16 @@ class Ladestation_OCPP extends IPSModule private function buildEffectiveSetpoint(float $powerW): array { $phases = (int)$this->GetValue('NumberPhases'); + $currentA = PhaseManager::currentFromPower($powerW, $phases, $this->averageVoltage()); + $this->SetValue('Ladestrom', $currentA); return [ 'effectivePowerW' => $powerW, - 'effectiveCurrentA' => PhaseManager::currentFromPower($powerW, $phases, $this->averageVoltage()), + 'effectiveCurrentA' => $currentA, 'numberPhases' => $phases, 'phaseToUse' => (int)$this->GetValue('PhaseToUse'), 'evseId' => $this->ReadPropertyInteger('EVSEId'), 'connectorId' => $this->ReadPropertyInteger('ConnectorId'), + 'transactionId' => $this->GetValue('TransactionId'), 'reason' => $this->GetValue('AktiverReduktionsgrund'), 'activeReleaseSource' => $this->GetValue('AktiveFreigabequelle'), 'activeBlockingReason' => $this->GetValue('AktiverSperrgrund'), @@ -498,6 +578,33 @@ class Ladestation_OCPP extends IPSModule return ['evseId' => $this->ReadPropertyInteger('EVSEId')]; } + private function buildRemoteStartPayload(): array + { + return [ + 'connectorId' => $this->ReadPropertyInteger('ConnectorId'), + 'idTag' => $this->ReadPropertyString('AuthorizeIdTag') + ]; + } + + private function buildRemoteStopPayload(): array + { + $state = TransactionStore::fromJson($this->ReadAttributeString(self::ATTR_TRANSACTION)); + $transactionId = (string)($state['activeTransactionId'] ?? $this->GetValue('TransactionId')); + if ($transactionId === '') { + $this->setDiagnostic('Warnung', 'OCPP', 'RemoteStop ohne aktive Transaction angefordert.'); + return []; + } + return ['transactionId' => is_numeric($transactionId) ? (int)$transactionId : $transactionId]; + } + + private function buildAvailabilityPayload(string $type): array + { + return [ + 'connectorId' => $this->ReadPropertyInteger('ConnectorId'), + 'type' => $type + ]; + } + private function shouldSendChargingProfile(float $oldPower, float $newPower): bool { $delta = abs($oldPower - $newPower); @@ -509,18 +616,40 @@ class Ladestation_OCPP extends IPSModule 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); + $this->queueOcppCommandInternal($action, $payload, 0, false); + } + private function queueOcppCommandInternal(string $action, array $payload, int $retryCount, bool $force): void + { + $version = $this->effectiveOcppVersion(); + if ($action === 'SetChargingProfile' && !$force && !$this->canUseSmartCharging()) { + $this->SetValue('LetzterCommandStatus', 'SetChargingProfile blockiert: Capability nicht bestaetigt.'); + return; + } + + $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([ + 'uniqueId' => $message->uniqueId, + 'action' => $action, + 'payload' => $payload, + 'retryCount' => $retryCount, + 'timestamp' => time() + ])); + + $this->queueOcppMessage($message); + } + + private function queueOcppMessage(OCPPMessage $message): void + { + $server = $this->ReadPropertyInteger('OCPPServerInstance'); 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); + $this->setDiagnostic('Warnung', 'OCPP Transport', 'Kein OCPP_Server verbunden. Frame nur lokal vorgemerkt: ' . $message->action); } private function runWatchdogs(): void @@ -546,6 +675,18 @@ class Ladestation_OCPP extends IPSModule $this->SetValue('AktiverSperrgrund', $result['blockingReason']); $this->SetValue('StoerungAktiv', true); } + + $lastMeter = (int)$this->GetValue('LetzterMeterValueZeitpunkt'); + if ($lastMeter > 0 && (time() - $lastMeter) > max(30, $this->ReadPropertyInteger('MeterValuesTimeoutSeconds'))) { + $this->SetValue('WarnungAktiv', true); + $this->SetValue('OCPP_Error', 'MeterValues Timeout'); + $this->setDiagnostic('Warnung', 'OCPP MeterValues', 'MeterValues sind veraltet.'); + } + + $lastSeen = (int)$this->GetValue('OCPP_LastSeen'); + if ($lastSeen > 0 && (time() - $lastSeen) > max(60, $this->ReadPropertyInteger('OCPPHeartbeatTimeoutSeconds'))) { + $this->SetValue('OCPP_Connected', false); + } } private function ProcessIdleCounter(): void @@ -563,7 +704,12 @@ class Ladestation_OCPP extends IPSModule { $version = $this->effectiveOcppVersion(); $this->SetValue('OCPP_Version', $version); - $this->WriteAttributeString(self::ATTR_CAPABILITIES, json_encode(CapabilityModel::detectFromBoot($version, $message->payload))); + $capabilities = CapabilityModel::detectFromBoot($version, $message->payload); + if ($this->isWattpilotProfile() || WattpilotGen1Profile::isWattpilotBoot($message->payload)) { + $this->SetValue('DeviceProfile', WattpilotGen1Profile::DEVICE_PROFILE); + $capabilities = CapabilityModel::merge($capabilities, WattpilotGen1Profile::capabilities()); + } + $this->writeCapabilities($capabilities); $this->setDiagnostic('Info', 'OCPP', 'BootNotification verarbeitet.'); } @@ -574,6 +720,21 @@ class Ladestation_OCPP extends IPSModule $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)); + $statusMap = [ + 'Available' => 1, + 'Preparing' => 2, + 'Charging' => 3, + 'SuspendedEV' => 4, + 'SuspendedEVSE' => 5, + 'Finishing' => 6, + 'Reserved' => 7, + 'Unavailable' => 0, + 'Faulted' => -1 + ]; + $this->SetValue('Fahrzeugstatus', $statusMap[$status] ?? 0); + if ($status === 'Faulted') { + $this->setDiagnostic('Stoerung', 'OCPP Status', 'Ladestation meldet Faulted.', (string)($payload['errorCode'] ?? '')); + } } private function handleMeterValues(array $payload): void @@ -610,6 +771,8 @@ class Ladestation_OCPP extends IPSModule } $this->SetValue('MesswertQualitaet', $values['quality']); $this->SetValue('LetzterMeterValueZeitpunkt', (int)$values['timestamp']); + $this->updateMeterCapabilities($values); + $this->updatePhaseStateFromMeters($values); } private function handleTransaction(OCPPMessage $message): void @@ -628,6 +791,8 @@ class Ladestation_OCPP extends IPSModule } if ($message->action === 'StopTransaction') { $state['sessionStopReason'] = (string)($payload['reason'] ?? 'Unknown'); + $state['activeTransactionId'] = ''; + $this->SetValue('TransactionId', ''); } $this->WriteAttributeString(self::ATTR_TRANSACTION, TransactionStore::toJson($state)); $this->SetValue('LetztesIdToken', $state['lastIdToken']); @@ -713,6 +878,9 @@ class Ladestation_OCPP extends IPSModule { $this->SetValue('LetzteMeldung', $text); $this->SetValue('LetzteMeldungZeit', time()); + if ($severity === 'Fehler' || $severity === 'Stoerung') { + $this->SetValue('OCPP_Error', $text); + } $this->SetValue('Kommunikationsstatus', Diagnostics::statusFromFlags((bool)$this->GetValue('OCPP_Online'), (bool)$this->GetValue('StoerungAktiv'))); if ($severity === 'Warnung') { $this->SetValue('WarnungAktiv', true); @@ -730,6 +898,240 @@ class Ladestation_OCPP extends IPSModule $this->SendDebug($source, $severity . ': ' . $text, 0); } } + + private function buildChargingProfilePayload(array $setpoint): array + { + if ($this->isWattpilotProfile()) { + return WattpilotGen1Profile::buildChargingProfile($setpoint, (int)(time() % 100000)); + } + return (new ChargingProfileBuilder())->build($setpoint, $this->effectiveOcppVersion()); + } + + private function canUseSmartCharging(): bool + { + $capabilities = $this->readCapabilities(); + return (bool)($capabilities['smartCharging'] ?? false) && !$this->GetValue('WattpilotSmartChargingBlocked'); + } + + private function probeSmartCharging(): void + { + $power = PhaseManager::wattsFromCurrent((float)$this->GetValue('SafeCurrent'), (int)$this->GetValue('NumberPhases'), $this->averageVoltage()); + $setpoint = $this->buildEffectiveSetpoint($power); + $this->SetValue('WattpilotSmartChargingBlocked', false); + $this->queueOcppCommandInternal('SetChargingProfile', $this->buildChargingProfilePayload($setpoint), 0, true); + } + + private function respondToCall(OCPPMessage $request, array $payload): void + { + if ($request->messageTypeId !== 2) { + return; + } + $this->queueOcppMessage(OCPPMessage::callResult($request->uniqueId, $payload, $request->version, $request->chargePointId)); + } + + private function buildBootResponse(): array + { + return [ + 'status' => 'Accepted', + 'currentTime' => gmdate('Y-m-d\TH:i:s\Z'), + 'interval' => max(10, $this->ReadPropertyInteger('OCPPHeartbeatIntervalSeconds')) + ]; + } + + private function buildTransactionResponse(OCPPMessage $message): array + { + if ($message->action === 'StartTransaction') { + $state = TransactionStore::fromJson($this->ReadAttributeString(self::ATTR_TRANSACTION)); + $transactionId = $state['activeTransactionId'] !== '' ? $state['activeTransactionId'] : (string)time(); + $state['activeTransactionId'] = $transactionId; + $state['lastTransactionState'] = 'StartTransaction'; + $this->WriteAttributeString(self::ATTR_TRANSACTION, TransactionStore::toJson($state)); + $this->SetValue('TransactionId', $transactionId); + return [ + 'transactionId' => is_numeric($transactionId) ? (int)$transactionId : $transactionId, + 'idTagInfo' => ['status' => 'Accepted'] + ]; + } + if ($message->action === 'StopTransaction') { + return ['idTagInfo' => ['status' => 'Accepted']]; + } + return []; + } + + private function buildDataTransferResponse(array $payload): array + { + $registry = new DataTransferRegistry($this->ReadPropertyBoolean('AllowDataTransfer')); + $vendorId = (string)($payload['vendorId'] ?? ''); + $messageId = (string)($payload['messageId'] ?? ''); + return [ + 'status' => $registry->isAllowed($vendorId, $messageId) ? 'Accepted' : 'Rejected' + ]; + } + + private function handleCallResult(OCPPMessage $message): void + { + $pending = $this->readPendingCommand(); + if (($pending['uniqueId'] ?? '') !== $message->uniqueId) { + $this->SetValue('LetzterCommandStatus', 'CallResult ohne passenden Pending Command: ' . $message->uniqueId); + return; + } + + $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') { + $capabilities = $this->readCapabilities(); + $capabilities['smartCharging'] = true; + $this->writeCapabilities($capabilities); + $this->SetValue('LetztesChargingProfileAccepted', true); + $this->SetValue('WattpilotProfileRetries', 0); + $this->SetValue('WattpilotSmartChargingBlocked', false); + return; + } + $this->retryOrBlockSmartCharging($pending, 'CallResult status ' . $status); + } + } + + private function handleCallError(OCPPMessage $message): void + { + $pending = $this->readPendingCommand(); + $action = (string)($pending['action'] ?? 'Unknown'); + $code = (string)($message->payload['errorCode'] ?? $message->action); + $description = (string)($message->payload['errorDescription'] ?? ''); + $this->SetValue('LetzterOCPPFehlercode', $code); + $this->SetValue('LetzterCommandStatus', $action . ' abgelehnt: ' . $code); + $this->setDiagnostic('Warnung', 'OCPP Command', $action . ' abgelehnt: ' . $description, $code); + + if ($action === 'SetChargingProfile') { + $this->retryOrBlockSmartCharging($pending, $code); + return; + } + + $this->WriteAttributeString(self::ATTR_PENDING_COMMAND, json_encode([])); + } + + private function retryOrBlockSmartCharging(array $pending, string $reason): void + { + $retryCount = (int)($pending['retryCount'] ?? 0); + $limit = max(0, $this->ReadPropertyInteger('SmartChargingRetryLimit')); + if ($retryCount < $limit) { + $next = $retryCount + 1; + $this->SetValue('WattpilotProfileRetries', $next); + $this->SetValue('LetzterCommandStatus', 'SetChargingProfile Retry ' . $next . '/' . $limit . ': ' . $reason); + $this->queueOcppCommandInternal('SetChargingProfile', (array)($pending['payload'] ?? []), $next, true); + return; + } + + $capabilities = $this->readCapabilities(); + $capabilities['smartCharging'] = false; + $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); + } + + private function applyDeviceProfileCapabilities(): void + { + $profile = $this->ReadPropertyString('DeviceProfile'); + $this->SetValue('DeviceProfile', $profile); + if ($profile !== WattpilotGen1Profile::DEVICE_PROFILE) { + $this->setCapabilityVariables($this->readCapabilities()); + return; + } + + $capabilities = CapabilityModel::merge($this->readCapabilities(), WattpilotGen1Profile::capabilities()); + $this->writeCapabilities($capabilities); + $this->SetValue('OCPP_Version', '1.6'); + $this->SetValue('AutomatischePhasenumschaltung', false); + $this->SetValue('Capability_SimpleAmpereProfiles', true); + } + + private function isWattpilotProfile(): bool + { + return $this->ReadPropertyString('DeviceProfile') === WattpilotGen1Profile::DEVICE_PROFILE + || $this->GetValue('DeviceProfile') === WattpilotGen1Profile::DEVICE_PROFILE; + } + + private function readCapabilities(): array + { + $data = json_decode($this->ReadAttributeString(self::ATTR_CAPABILITIES), true); + if (!is_array($data)) { + return CapabilityModel::defaults(); + } + return CapabilityModel::merge(CapabilityModel::defaults(), $data); + } + + private function writeCapabilities(array $capabilities): void + { + $capabilities = CapabilityModel::merge(CapabilityModel::defaults(), $capabilities); + $this->WriteAttributeString(self::ATTR_CAPABILITIES, json_encode($capabilities)); + $this->setCapabilityVariables($capabilities); + } + + private function setCapabilityVariables(array $capabilities): void + { + $this->SetValue('Capability_SmartCharging', (bool)($capabilities['smartCharging'] ?? false)); + $this->SetValue('Capability_MeterValues', (bool)($capabilities['meterValues'] ?? false)); + $this->SetValue('Capability_RemoteStartStop', (bool)($capabilities['remoteStartStop'] ?? false)); + $this->SetValue('Capability_Availability', (bool)($capabilities['availability'] ?? false)); + $this->SetValue('Capability_PhaseValues', (bool)($capabilities['phaseMetering'] ?? false)); + $this->SetValue('Capability_SimpleAmpereProfiles', (bool)($capabilities['simpleAmpereProfiles'] ?? false)); + } + + private function readPendingCommand(): array + { + $pending = json_decode($this->ReadAttributeString(self::ATTR_PENDING_COMMAND), true); + return is_array($pending) ? $pending : []; + } + + private function updateMeterCapabilities(array $values): void + { + $capabilities = $this->readCapabilities(); + $capabilities['meterValues'] = true; + $capabilities['powerImport'] = $capabilities['powerImport'] || $values['powerImportW'] !== null; + $capabilities['powerExport'] = $capabilities['powerExport'] || $values['powerExportW'] !== null; + $capabilities['energyImport'] = $capabilities['energyImport'] || $values['energyImportWh'] !== null; + $capabilities['energyExport'] = $capabilities['energyExport'] || $values['energyExportWh'] !== null; + $capabilities['soc'] = $capabilities['soc'] || $values['soc'] !== null; + $capabilities['temperature'] = $capabilities['temperature'] || $values['temperature'] !== null; + + foreach ([1, 2, 3] as $phase) { + if ($values['currentA'][$phase] !== null) { + $capabilities['currentImportPerPhase'] = true; + $capabilities['phaseMetering'] = true; + } + if ($values['voltageV'][$phase] !== null) { + $capabilities['voltagePerPhase'] = true; + $capabilities['phaseMetering'] = true; + } + } + $this->writeCapabilities($capabilities); + } + + private function updatePhaseStateFromMeters(array $values): void + { + $activePhases = 0; + foreach ([1, 2, 3] as $phase) { + if ($values['currentA'][$phase] !== null && (float)$values['currentA'][$phase] > 0.5) { + $activePhases++; + } + } + + if ($activePhases === 1) { + $this->SetValue('Is_1_ph', true); + $this->SetValue('NumberPhases', 1); + return; + } + + if ($activePhases >= 2) { + $this->SetValue('Is_1_ph', false); + $this->SetValue('NumberPhases', 3); + } + } } ?> diff --git a/OCPP_Server/README.md b/OCPP_Server/README.md index 396c162..df5c5be 100644 --- a/OCPP_Server/README.md +++ b/OCPP_Server/README.md @@ -7,11 +7,14 @@ - 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. ## 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. +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. + +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. ## Konfiguration diff --git a/OCPP_Server/form.json b/OCPP_Server/form.json index c1cdc53..64fde43 100644 --- a/OCPP_Server/form.json +++ b/OCPP_Server/form.json @@ -79,6 +79,11 @@ "type": "Button", "caption": "Puffer loeschen", "onClick": "IPS_RequestAction($id, \"ClearBuffers\", \"\");" + }, + { + "type": "Button", + "caption": "Naechsten Outbound Frame holen", + "onClick": "IPS_RequestAction($id, \"DequeueOutboundFrame\", \"\");" } ] } diff --git a/OCPP_Server/module.php b/OCPP_Server/module.php index 1ac7a17..1ce234d 100644 --- a/OCPP_Server/module.php +++ b/OCPP_Server/module.php @@ -7,6 +7,7 @@ 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() { @@ -20,6 +21,7 @@ class OCPP_Server extends IPSModule $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); @@ -27,6 +29,7 @@ class OCPP_Server extends IPSModule $this->RegisterVariableString('LastRouteResult', 'LastRouteResult', '', 22); $this->RegisterVariableInteger('ConnectionCount', 'ConnectionCount', '', 30); $this->RegisterVariableInteger('LastMessageTime', 'LastMessageTime', '', 31); + $this->RegisterVariableInteger('OutboundQueueCount', 'OutboundQueueCount', '', 32); $this->RegisterVariableString('WebSocketSupportStatus', 'WebSocketSupportStatus', '', 40); $this->RegisterVariableString('LetzteMeldung', 'LetzteMeldung', '', 41); $this->RegisterVariableInteger('LetzteMeldungZeit', 'LetzteMeldungZeit', '', 42); @@ -51,6 +54,11 @@ class OCPP_Server extends IPSModule $this->tryRegisterHook(); } + $defaultTarget = $this->ReadPropertyInteger('DefaultTargetInstance'); + if ($defaultTarget > 0) { + $this->RegisterReference($defaultTarget); + } + $this->SetStatus(102); } @@ -69,6 +77,10 @@ class OCPP_Server extends IPSModule $this->RouteInboundFrame((string)$Value); break; + case 'DequeueOutboundFrame': + $this->DequeueOutboundFrame((string)$Value); + break; + case 'TransportWatchdog': $this->TransportWatchdog(); break; @@ -77,6 +89,8 @@ class OCPP_Server extends IPSModule $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; @@ -95,27 +109,42 @@ class OCPP_Server extends IPSModule $path = $_SERVER['REQUEST_URI'] ?? $this->ReadPropertyString('HookPath'); $chargePointId = (new OCPPFrameRouter())->extractChargePointId((string)$path); - $this->RouteInboundFrame(json_encode([ + $responseFrame = $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.' - ]); + if ($responseFrame !== '') { + echo $responseFrame; + return; + } + + echo json_encode(['status' => 'accepted']); } public function QueueOutboundFrame(string $json): void { - $this->SetValue('LastOutboundFrame', $json); + $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->setMessage('Outbound Frame vorgemerkt. Aktiver WebSocket-Sendekanal ist noch Scaffold.'); + $this->enqueueOutboundFrame($chargePointId, $frame); + $this->setMessage('Outbound Frame fuer ' . ($chargePointId === '' ? 'unbekannt' : $chargePointId) . ' gepuffert.'); } - public function RouteInboundFrame(string $json): void + public function RouteInboundFrame(string $json): string { $data = json_decode($json, true); if (!is_array($data)) { @@ -163,10 +192,58 @@ class OCPP_Server extends IPSModule if ($target > 0 && IPS_InstanceExists($target)) { IPS_RequestAction($target, 'HandleInboundFrame', $frame); $this->setMessage('Inbound Frame an Zielinstanz ' . $target . ' geroutet.'); - return; + 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 @@ -215,6 +292,52 @@ class OCPP_Server extends IPSModule $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; + } } ?> diff --git a/docs/OCPP/README.md b/docs/OCPP/README.md index 94c7d85..d48d05b 100644 --- a/docs/OCPP/README.md +++ b/docs/OCPP/README.md @@ -56,6 +56,7 @@ Phase 1 ist ein produktionsnaher Scaffold, aber noch kein fertig abgenommener OC Sichtbar und vorbereitet: - OCPP 1.6 Grundgeruest. +- Fronius Wattpilot Gen1 Profil fuer OCPP 1.6J mit einfachen Ampere-Profilen. - OCPP 2.0.1/2.1 Adapterklassen als Struktur. - Manager-Anbindung mit `PowerSteps`. - Managementmodi: Nie laden, Immer laden, Konstanter Strom, Nur Solar. @@ -127,6 +128,18 @@ Quellen: - https://www.symcon.de/de/service/dokumentation/modulreferenz/webhook-control/ - https://www.symcon.de/de/service/dokumentation/entwicklerbereich/sdk-tools/sdk-php/module/processhookdata/ +## Fronius Wattpilot Gen1 + +Der Wattpilot-Gen1-Pfad ist in `docs/OCPP/WATTPILOT_GEN1.md` beschrieben. Die Umsetzung bleibt strikt OCPP-only und verwendet keine lokale go-e API, keine Fronius Solar API und keine Cloud. + +Wichtige technische Regeln: + +- OCPP 1.6J. +- einfache `SetChargingProfile`-Limits in Ampere. +- keine erzwungenen `numberPhases` oder `phaseToUse`. +- `RemoteStartTransaction`, `RemoteStopTransaction` und `ChangeAvailability` sind vorbereitet. +- Smart-Charging wird bei Ablehnung nach begrenzten Retries capability-basiert deaktiviert. + ## Variablenkatalog Bedienung: diff --git a/docs/OCPP/WATTPILOT_GEN1.md b/docs/OCPP/WATTPILOT_GEN1.md new file mode 100644 index 0000000..8d2b56e --- /dev/null +++ b/docs/OCPP/WATTPILOT_GEN1.md @@ -0,0 +1,125 @@ +# Fronius Wattpilot Gen1 ueber OCPP 1.6J + +Stand: 2026-05-10 + +Diese Anleitung beschreibt den Testpfad fuer Fronius Wattpilot Home/Go Gen1 mit `Ladestation_OCPP` und `OCPP_Server`. + +## Grundsatz + +Die Integration ist OCPP-only: + +- keine lokale go-e HTTP API +- keine Fronius Solar API +- keine Fronius Cloud +- kein MQTT +- keine proprietaeren Fallbacks + +Der Wattpilot wird als unterstuetztes Geraet behandelt, aber nicht als Referenz fuer die gesamte OCPP-Architektur. Alle Funktionen bleiben capability-basiert. + +## 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. + +## Wattpilot-Konfiguration + +Im Wattpilot OCPP aktivieren und die lokale Symcon-CSMS-Adresse eintragen. + +Das genaue URL-Schema haengt vom aktiven Symcon-Webserver und der WebHook/WebSocket-Unterstuetzung ab. Fuer den Spike ist die Zielstruktur: + +```text +ws://:/hook/ocpp/ +``` + +Bei TLS entsprechend: + +```text +wss:///hook/ocpp/ +``` + +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. + +## Implementierter OCPP-1.6J-Pfad + +Eingehend: + +- `BootNotification` -> `Accepted`, Heartbeat-Intervall +- `Heartbeat` -> `currentTime` +- `StatusNotification` -> Fahrzeugstatus, `Car_detected`, `Car_is_full` +- `MeterValues` -> Leistung, Energie, Strom, Spannung +- `Authorize` -> `Accepted` +- `StartTransaction` -> TransactionId und `Accepted` +- `StopTransaction` -> `Accepted` +- `DataTransfer` -> nur bei erlaubter Registry + +Ausgehend: + +- `SetChargingProfile` +- `ClearChargingProfile` +- `RemoteStartTransaction` +- `RemoteStopTransaction` +- `ChangeAvailability` + +## Smart Charging fuer Wattpilot Gen1 + +Das Profil erzeugt bewusst nur einfache Ampere-Limits: + +- `chargingRateUnit = A` +- `chargingProfileKind = Absolute` +- `chargingProfilePurpose = TxProfile` bei aktiver Transaktion, sonst `TxDefaultProfile` +- genau eine `chargingSchedulePeriod` +- kurze Gueltigkeit von 120 Sekunden + +Nicht verwendet: + +- mehrere Perioden +- komplexe Schedules +- `numberPhases` +- `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. + +## Testablauf + +1. `OCPP_Server` und `Ladestation_OCPP` anlegen und konfigurieren. +2. Wattpilot mit Symcon-CSMS-Adresse verbinden. +3. `BootNotification` pruefen: + - `OCPP_Connected = true` + - `OCPP_LastSeen` aktualisiert + - `Capability_MeterValues = true` +4. Fahrzeug einstecken: + - `StatusNotification` setzt `Car_detected` + - `Fahrzeugstatus` wechselt +5. Ladevorgang starten: + - `Remote Start` testen oder lokal starten + - `StartTransaction` wird gespeichert +6. Messwerte pruefen: + - `Ladeleistung_Effektiv` + - `Bezogene_Energie` + - optional `Strom_L1/L2/L3`, `Spannung_L1/L2/L3` +7. Manager testen: + - `PowerSteps` enthalten `0` und plausible Wattwerte + - `SetAktuelle_Leistung` schreibt nur `Power` + - `Do_UserCalc` sendet `SetChargingProfile` +8. Fehlerfaelle testen: + - WLAN trennen + - MeterValues stoppen + - falsche ChargePointId + - abgelehntes ChargingProfile + +## 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. + +## Quellen + +- Fronius Wattpilot Support: https://www.fronius.com/en/help-center/solar-energy/e-mobility/support-wattpilot +- Fronius Wattpilot OCPP 1.6 J: https://www.fronius.com/en/help-center/solar-energy/products/monitoring-control/solutions/open-interfaces/wattpilot-ocpp-1-6-j