1241 lines
55 KiB
PHP
1241 lines
55 KiB
PHP
<?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';
|
|
require_once __DIR__ . '/libs/WattpilotGen1Profile.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';
|
|
private const ATTR_PENDING_COMMANDS = 'PendingCommands';
|
|
private const ATTR_LAST_ENERGY_AGGREGATION = 'LastEnergyAggregation';
|
|
|
|
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);
|
|
$this->RegisterPropertyInteger('Interval', 2);
|
|
$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->RegisterAttributeString(self::ATTR_PENDING_COMMANDS, json_encode([]));
|
|
$this->RegisterAttributeInteger(self::ATTR_LAST_ENERGY_AGGREGATION, 0);
|
|
|
|
$this->registerEmsVariables();
|
|
$this->registerControlVariables();
|
|
$this->registerStatusVariables();
|
|
$this->registerMeterVariables();
|
|
$this->registerDiagnosticVariables();
|
|
|
|
$this->RegisterTimer('Timer_Do_UserCalc', 2000, '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(2, $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');
|
|
$this->applyDeviceProfileCapabilities();
|
|
|
|
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'));
|
|
$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;
|
|
}
|
|
|
|
$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 '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;
|
|
|
|
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 = $this->buildChargingProfilePayload($setpoint);
|
|
$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->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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
$this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 42);
|
|
}
|
|
|
|
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);
|
|
$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
|
|
{
|
|
$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);
|
|
$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
|
|
{
|
|
$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,
|
|
'DeviceProfile' => 'generic_ocpp',
|
|
'OCPP_Version' => 'auto',
|
|
'OCPP_Connected' => false,
|
|
'OCPP_LastSeen' => 0,
|
|
'OCPP_Error' => '',
|
|
'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',
|
|
'WattpilotProfileRetries' => 0,
|
|
'WattpilotSmartChargingBlocked' => false
|
|
];
|
|
|
|
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');
|
|
$currentA = PhaseManager::currentFromPower($powerW, $phases, $this->averageVoltage());
|
|
$this->SetValue('Ladestrom', $currentA);
|
|
return [
|
|
'effectivePowerW' => $powerW,
|
|
'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'),
|
|
'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 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);
|
|
$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
|
|
{
|
|
$this->queueOcppCommandInternal($action, $payload, 0, false);
|
|
}
|
|
|
|
private function queueOcppCommandInternal(string $action, array $payload, int $retryCount, bool $force): void
|
|
{
|
|
$this->processPendingCommands();
|
|
$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);
|
|
$pendingCommands = $this->readPendingCommands();
|
|
$pendingCommands[$message->uniqueId] = [
|
|
'uniqueId' => $message->uniqueId,
|
|
'action' => $action,
|
|
'payload' => $payload,
|
|
'retryCount' => $retryCount,
|
|
'timestamp' => time(),
|
|
'ackDeadline' => time() + max(5, $this->ReadPropertyInteger('CommandAckTimeoutSeconds')),
|
|
'status' => 'pending'
|
|
];
|
|
$this->writePendingCommands($pendingCommands);
|
|
|
|
$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. Frame nur lokal vorgemerkt: ' . $message->action);
|
|
}
|
|
|
|
private function runWatchdogs(): void
|
|
{
|
|
$this->processPendingCommands();
|
|
|
|
$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);
|
|
}
|
|
|
|
$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
|
|
{
|
|
$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);
|
|
$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.');
|
|
}
|
|
|
|
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));
|
|
$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
|
|
{
|
|
$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']);
|
|
$this->updateMeterCapabilities($values);
|
|
$this->updatePhaseStateFromMeters($values);
|
|
}
|
|
|
|
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');
|
|
$state['activeTransactionId'] = '';
|
|
$this->SetValue('TransactionId', '');
|
|
}
|
|
$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;
|
|
}
|
|
$now = time();
|
|
$lastAggregation = $this->ReadAttributeInteger(self::ATTR_LAST_ENERGY_AGGREGATION);
|
|
if ($lastAggregation <= 0) {
|
|
$this->WriteAttributeInteger(self::ATTR_LAST_ENERGY_AGGREGATION, $now);
|
|
return;
|
|
}
|
|
$seconds = max(0, $now - $lastAggregation);
|
|
if ($seconds <= 0) {
|
|
return;
|
|
}
|
|
|
|
$energy = (float)$this->GetValue('Bezogene_Energie');
|
|
$energy += ((float)$this->GetValue('Ladeleistung_Effektiv') * $seconds) / 3600.0;
|
|
$this->SetValue('Bezogene_Energie', $energy);
|
|
$this->WriteAttributeInteger(self::ATTR_LAST_ENERGY_AGGREGATION, $now);
|
|
}
|
|
|
|
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());
|
|
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);
|
|
} 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);
|
|
}
|
|
}
|
|
|
|
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
|
|
{
|
|
$pendingCommands = $this->readPendingCommands();
|
|
if (!isset($pendingCommands[$message->uniqueId])) {
|
|
$this->SetValue('LetzterCommandStatus', 'CallResult ohne passenden Pending Command: ' . $message->uniqueId);
|
|
return;
|
|
}
|
|
|
|
$pending = $pendingCommands[$message->uniqueId];
|
|
unset($pendingCommands[$message->uniqueId]);
|
|
$this->writePendingCommands($pendingCommands);
|
|
|
|
$action = (string)($pending['action'] ?? '');
|
|
$status = (string)($message->payload['status'] ?? 'Accepted');
|
|
$this->SetValue('LetzterCommandStatus', $action . ' bestaetigt: ' . $status);
|
|
|
|
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
|
|
{
|
|
$pendingCommands = $this->readPendingCommands();
|
|
$pending = $pendingCommands[$message->uniqueId] ?? [];
|
|
if (isset($pendingCommands[$message->uniqueId])) {
|
|
unset($pendingCommands[$message->uniqueId]);
|
|
$this->writePendingCommands($pendingCommands);
|
|
}
|
|
|
|
$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->writePendingCommands($pendingCommands);
|
|
}
|
|
|
|
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;
|
|
$delay = min(300, (int)pow(2, $next) * 5);
|
|
$retryKey = 'retry-' . str_replace('.', '', uniqid('', true));
|
|
$pendingCommands = $this->readPendingCommands();
|
|
$pendingCommands[$retryKey] = [
|
|
'uniqueId' => $retryKey,
|
|
'action' => 'SetChargingProfile',
|
|
'payload' => (array)($pending['payload'] ?? []),
|
|
'retryCount' => $next,
|
|
'timestamp' => time(),
|
|
'nextRetryAt' => time() + $delay,
|
|
'status' => 'retry_wait',
|
|
'reason' => $reason
|
|
];
|
|
$this->writePendingCommands($pendingCommands);
|
|
$this->SetValue('WattpilotProfileRetries', $next);
|
|
$this->SetValue('LetzterCommandStatus', 'SetChargingProfile Retry ' . $next . '/' . $limit . ' in ' . $delay . 's: ' . $reason);
|
|
return;
|
|
}
|
|
|
|
$capabilities = $this->readCapabilities();
|
|
$capabilities['smartCharging'] = false;
|
|
$this->writeCapabilities($capabilities);
|
|
$this->SetValue('LetztesChargingProfileAccepted', false);
|
|
$this->SetValue('WattpilotSmartChargingBlocked', true);
|
|
$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 readPendingCommands(): array
|
|
{
|
|
$pending = json_decode($this->ReadAttributeString(self::ATTR_PENDING_COMMANDS), true);
|
|
if (!is_array($pending)) {
|
|
return [];
|
|
}
|
|
if (isset($pending['uniqueId'])) {
|
|
return [(string)$pending['uniqueId'] => $pending];
|
|
}
|
|
return $pending;
|
|
}
|
|
|
|
private function writePendingCommands(array $pendingCommands): void
|
|
{
|
|
$this->WriteAttributeString(self::ATTR_PENDING_COMMANDS, json_encode($pendingCommands));
|
|
}
|
|
|
|
private function processPendingCommands(): void
|
|
{
|
|
$pendingCommands = $this->readPendingCommands();
|
|
if (empty($pendingCommands)) {
|
|
return;
|
|
}
|
|
|
|
$now = time();
|
|
$changed = false;
|
|
foreach ($pendingCommands as $uniqueId => $pending) {
|
|
$status = (string)($pending['status'] ?? 'pending');
|
|
if ($status === 'retry_wait') {
|
|
if ($now >= (int)($pending['nextRetryAt'] ?? 0)) {
|
|
unset($pendingCommands[$uniqueId]);
|
|
$this->writePendingCommands($pendingCommands);
|
|
$this->queueOcppCommandInternal(
|
|
(string)($pending['action'] ?? 'SetChargingProfile'),
|
|
(array)($pending['payload'] ?? []),
|
|
(int)($pending['retryCount'] ?? 0),
|
|
true
|
|
);
|
|
$pendingCommands = $this->readPendingCommands();
|
|
$changed = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($status === 'pending' && $now > (int)($pending['ackDeadline'] ?? 0)) {
|
|
unset($pendingCommands[$uniqueId]);
|
|
$changed = true;
|
|
if ((string)($pending['action'] ?? '') === 'SetChargingProfile') {
|
|
$this->writePendingCommands($pendingCommands);
|
|
$this->retryOrBlockSmartCharging($pending, 'ACK timeout');
|
|
$pendingCommands = $this->readPendingCommands();
|
|
} else {
|
|
$this->SetValue('LetzterCommandStatus', (string)($pending['action'] ?? 'Command') . ' ACK timeout');
|
|
}
|
|
}
|
|
|
|
if ($now - (int)($pending['timestamp'] ?? $now) > 3600) {
|
|
unset($pendingCommands[$uniqueId]);
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
if ($changed) {
|
|
$this->writePendingCommands($pendingCommands);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
?>
|