Do not latch full on transient zero current; require explicit completion. Validate go-e errors and Pico status/timestamps, keep historical events diagnostic, and preserve faults until a fresh successful poll. Budget the 6 A phase probe through the manager and correct stale phase counts conservatively. Add adapter, regulator, isolated runtime and Symcon regression cases plus migration documentation. Verified: composer check (402 tests, 1875 assertions), 92 pure-function assertions in Symcon 8.0, git diff --check. No plant deployment or hardware commands.
This commit is contained in:
@@ -4,6 +4,16 @@ Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Ladestation Stand-Alone: Ladepausen und einzelne Nullwerte verriegeln das
|
||||||
|
Fahrzeug nicht mehr als geladen. Ladeende nur mit aktuellem eindeutigen
|
||||||
|
Geraetesignal; Pico hat kein solches Voll-Signal. go-e-Fehler, Pico-Status
|
||||||
|
und Datenalter werden ausgewertet; historische Pico-Ereignisse bleiben
|
||||||
|
getrennte Diagnosehinweise. Managerdaten koennen Fehler/Cacheablauf nicht
|
||||||
|
aufheben. Nullleistung ergibt keine Einphasenerkennung; frische Phasenstroeme
|
||||||
|
korrigieren gespeicherte Werte. Die 6-A-Probe braucht bei Managerzuordnung
|
||||||
|
ein 4104-W-Budget und beachtet Solar/Peak sowie Freigaben. Migration verwirft
|
||||||
|
alte unsichere Phasenwerte einmalig; keine Aenderung an Bedienfreigaben.
|
||||||
|
Unit-, Adapter-, isolierte Modullaufzeit- und native Symcon-Testfaelle ergaenzt.
|
||||||
- Manager: V4-Planempfang und Prognosebedienung im vorhandenen Bereich
|
- Manager: V4-Planempfang und Prognosebedienung im vorhandenen Bereich
|
||||||
Prognose / Forecast zusammengefasst. Explizite Start-/Stoppaktionen pruefen
|
Prognose / Forecast zusammengefasst. Explizite Start-/Stoppaktionen pruefen
|
||||||
Lizenz und bestehende Testfreigaben. Kein automatischer Start, keine neue
|
Lizenz und bestehende Testfreigaben. Kein automatischer Start, keine neue
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
'AenderungMoeglich',
|
'AenderungMoeglich',
|
||||||
'Stoerung',
|
'Stoerung',
|
||||||
'Stoertext',
|
'Stoertext',
|
||||||
|
'Geraetehinweis',
|
||||||
'Fahrzeugstatus',
|
'Fahrzeugstatus',
|
||||||
'FahrzeugMaximalstrom',
|
'FahrzeugMaximalstrom',
|
||||||
'LetzterGeraetebefehl',
|
'LetzterGeraetebefehl',
|
||||||
@@ -88,6 +89,9 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
$this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false);
|
$this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false);
|
||||||
$this->RegisterAttributeBoolean('ZustandStoerung', false);
|
$this->RegisterAttributeBoolean('ZustandStoerung', false);
|
||||||
$this->RegisterAttributeString('ZustandStoertext', '');
|
$this->RegisterAttributeString('ZustandStoertext', '');
|
||||||
|
$this->RegisterAttributeString('ZustandGeraetehinweis', '');
|
||||||
|
$this->RegisterAttributeInteger('GeraetestatusGueltigBis', 0);
|
||||||
|
$this->RegisterAttributeBoolean('GeraetLadebereit', false);
|
||||||
$this->RegisterAttributeString('Leistungsangebot', '[0]');
|
$this->RegisterAttributeString('Leistungsangebot', '[0]');
|
||||||
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
|
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
|
||||||
$this->RegisterAttributeInteger('LetzteManagerID', 0);
|
$this->RegisterAttributeInteger('LetzteManagerID', 0);
|
||||||
@@ -149,12 +153,13 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
|
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
|
||||||
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
|
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
|
||||||
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
||||||
if ($this->ReadAttributeInteger('PhasenerkennungVersion') < 1) {
|
$this->WriteAttributeInteger('GeraetestatusGueltigBis', 0);
|
||||||
|
if ($this->ReadAttributeInteger('PhasenerkennungVersion') < 2) {
|
||||||
$this->WriteAttributeInteger(
|
$this->WriteAttributeInteger(
|
||||||
'ZustandPhasenzahl',
|
'ZustandPhasenzahl',
|
||||||
LadestationRegler::PHASEN_UNBEKANNT
|
LadestationRegler::PHASEN_UNBEKANNT
|
||||||
);
|
);
|
||||||
$this->WriteAttributeInteger('PhasenerkennungVersion', 1);
|
$this->WriteAttributeInteger('PhasenerkennungVersion', 2);
|
||||||
}
|
}
|
||||||
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
|
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
|
||||||
$this->WriteAttributeInteger(
|
$this->WriteAttributeInteger(
|
||||||
@@ -273,8 +278,10 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
$this->aktualisiere(false, false, false);
|
$this->aktualisiere(false, false, false);
|
||||||
$sollleistung = $daten['Sollleistung_W'];
|
$sollleistung = $daten['Sollleistung_W'];
|
||||||
if ($sollleistung === null) {
|
if ($sollleistung === null) {
|
||||||
if ((bool) $this->leseZustand('SollwertGueltig')
|
if (($this->leseLeistungsangebot() === [0]
|
||||||
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)
|
&& $this->ReadAttributeInteger('LetzterGesetzterStrom') !== 0)
|
||||||
|
|| ((bool) $this->leseZustand('SollwertGueltig')
|
||||||
|
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true))
|
||||||
) {
|
) {
|
||||||
$this->verwerfeSollwert();
|
$this->verwerfeSollwert();
|
||||||
$this->aktualisiere(false, false);
|
$this->aktualisiere(false, false);
|
||||||
@@ -308,9 +315,14 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
if ($statusLesen) {
|
if ($statusLesen) {
|
||||||
$this->leseGeraetestatus();
|
$this->leseGeraetestatus();
|
||||||
}
|
}
|
||||||
|
if ($this->ReadAttributeInteger('GeraetestatusGueltigBis') <= time()) {
|
||||||
|
$fehlertext = (string) $this->leseZustand('Stoertext');
|
||||||
|
throw new RuntimeException($fehlertext !== '' ? $fehlertext : 'Kein frischer gueltiger Geraetestatus.');
|
||||||
|
}
|
||||||
|
|
||||||
$aktiv = (bool) $this->GetValue('Aktiv');
|
$aktiv = (bool) $this->GetValue('Aktiv');
|
||||||
$ladefreigabe = $this->ReadAttributeBoolean('ZustandLadefreigabe');
|
$ladefreigabe = $this->ReadAttributeBoolean('ZustandLadefreigabe')
|
||||||
|
&& $this->ReadAttributeBoolean('GeraetLadebereit');
|
||||||
$verbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden');
|
$verbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden');
|
||||||
$geladen = $this->ReadAttributeBoolean('ZustandFahrzeugGeladen');
|
$geladen = $this->ReadAttributeBoolean('ZustandFahrzeugGeladen');
|
||||||
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
|
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
|
||||||
@@ -384,25 +396,33 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
$this->setzeZustand('AenderungMoeglich', $verfuegbar && count($angebot) > 1);
|
$this->setzeZustand('AenderungMoeglich', $verfuegbar && count($angebot) > 1);
|
||||||
|
|
||||||
$sollleistung = $this->bestimmeWirksameSollleistung($angebot);
|
$sollleistung = $this->bestimmeWirksameSollleistung($angebot);
|
||||||
$phasenerkennungAktiv = $aktiv
|
|
||||||
&& $ladefreigabe
|
|
||||||
&& $verbunden
|
|
||||||
&& !$geladen
|
|
||||||
&& $phasenzahl === LadestationRegler::PHASEN_UNBEKANNT;
|
|
||||||
$regeluebergangAktiv = $aktiv
|
$regeluebergangAktiv = $aktiv
|
||||||
&& $ladefreigabe
|
&& $ladefreigabe
|
||||||
&& $verbunden
|
&& $verbunden
|
||||||
&& !$geladen
|
&& !$geladen
|
||||||
&& $this->ReadAttributeBoolean('RegeluebergangAktiv');
|
&& $this->ReadAttributeBoolean('RegeluebergangAktiv');
|
||||||
if ($steuerungAusfuehren && !$phasenerkennungAktiv && !$regeluebergangAktiv) {
|
if ($steuerungAusfuehren && (!$regeluebergangAktiv || $angebot === [0])) {
|
||||||
$this->setzeLadestrom($sollleistung);
|
$this->setzeLadestrom($sollleistung);
|
||||||
}
|
}
|
||||||
$this->setzeStoerung('');
|
$this->setzeStoerung('');
|
||||||
$this->SetStatus(self::STATUS_AKTIV);
|
$this->SetStatus(self::STATUS_AKTIV);
|
||||||
} catch (Throwable $fehler) {
|
} catch (Throwable $fehler) {
|
||||||
|
$this->WriteAttributeInteger('GeraetestatusGueltigBis', 0);
|
||||||
|
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
||||||
|
$this->WriteAttributeString('Leistungsangebot', '[0]');
|
||||||
|
$this->setzeSichtbareVariable('LeistungsangebotDiagnose', '[0]');
|
||||||
|
$this->verwerfeSollwert();
|
||||||
$this->setzeZustand('Verfuegbar', false);
|
$this->setzeZustand('Verfuegbar', false);
|
||||||
$this->setzeZustand('AenderungMoeglich', false);
|
$this->setzeZustand('AenderungMoeglich', false);
|
||||||
$this->setzeStoerung($fehler->getMessage());
|
$text = $fehler->getMessage();
|
||||||
|
if ($steuerungAusfuehren && $this->ReadAttributeInteger('LetzterGesetzterStrom') !== 0) {
|
||||||
|
try {
|
||||||
|
$this->setzeLadestrom(0);
|
||||||
|
} catch (Throwable $stoppfehler) {
|
||||||
|
$text .= ' Nullvorgabe nicht bestaetigt: ' . $stoppfehler->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->setzeStoerung($text);
|
||||||
$this->SetStatus(self::STATUS_KOMMUNIKATIONSFEHLER);
|
$this->SetStatus(self::STATUS_KOMMUNIKATIONSFEHLER);
|
||||||
$this->protokolliere('Geraetekommunikation', $fehler->getMessage());
|
$this->protokolliere('Geraetekommunikation', $fehler->getMessage());
|
||||||
}
|
}
|
||||||
@@ -447,7 +467,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
$this->WriteAttributeInteger('LeistungswechselGesperrtBis', 0);
|
$this->WriteAttributeInteger('LeistungswechselGesperrtBis', 0);
|
||||||
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
|
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
|
||||||
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
|
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
|
||||||
} elseif ($bekanntePhasenzahl === LadestationRegler::PHASEN_UNBEKANNT) {
|
} else {
|
||||||
$direktErkanntePhasen = (int) ($rohstatus['Phasenzahl']
|
$direktErkanntePhasen = (int) ($rohstatus['Phasenzahl']
|
||||||
?? LadestationRegler::PHASEN_UNBEKANNT);
|
?? LadestationRegler::PHASEN_UNBEKANNT);
|
||||||
if (in_array(
|
if (in_array(
|
||||||
@@ -455,30 +475,25 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
[LadestationRegler::PHASEN_EIN, LadestationRegler::PHASEN_DREI],
|
[LadestationRegler::PHASEN_EIN, LadestationRegler::PHASEN_DREI],
|
||||||
true
|
true
|
||||||
)) {
|
)) {
|
||||||
$bekanntePhasenzahl = $direktErkanntePhasen;
|
$bekanntePhasenzahl = max($bekanntePhasenzahl, $direktErkanntePhasen);
|
||||||
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
||||||
} elseif ($rohstatus['Istleistung_W'] > 7500.0) {
|
} elseif ($rohstatus['Istleistung_W'] > 7500.0) {
|
||||||
$bekanntePhasenzahl = LadestationRegler::PHASEN_DREI;
|
$bekanntePhasenzahl = LadestationRegler::PHASEN_DREI;
|
||||||
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
||||||
} elseif ((bool) $this->GetValue('Aktiv')
|
} elseif ($bekanntePhasenzahl === LadestationRegler::PHASEN_UNBEKANNT) {
|
||||||
&& $this->ReadAttributeBoolean('ZustandLadefreigabe')
|
|
||||||
) {
|
|
||||||
$erkennungSeit = $this->ReadAttributeInteger('PhasenerkennungSeit');
|
$erkennungSeit = $this->ReadAttributeInteger('PhasenerkennungSeit');
|
||||||
if ($erkennungSeit <= 0) {
|
if ($erkennungSeit > 0
|
||||||
$this->sendeLadestromAnGeraet(self::PHASENERKENNUNGSSTROM);
|
&& $this->ReadAttributeInteger('LetzterGesetzterStrom') === self::PHASENERKENNUNGSSTROM
|
||||||
$erkennungSeit = time();
|
&& (time() - $erkennungSeit) >= $this->ReadPropertyInteger('Phasenerkennungszeit')
|
||||||
$this->WriteAttributeInteger('PhasenerkennungSeit', $erkennungSeit);
|
|
||||||
} elseif ((time() - $erkennungSeit)
|
|
||||||
>= $this->ReadPropertyInteger('Phasenerkennungszeit')
|
|
||||||
) {
|
) {
|
||||||
$bekanntePhasenzahl = LadestationRegler::erkennePhasenzahlAusLeistung(
|
$bekanntePhasenzahl = LadestationRegler::erkennePhasenzahlAusLeistung(
|
||||||
(float) $rohstatus['Istleistung_W'],
|
(float) $rohstatus['Istleistung_W'],
|
||||||
$this->ReadAttributeInteger('LetzterGesetzterStrom')
|
$this->ReadAttributeInteger('LetzterGesetzterStrom')
|
||||||
);
|
);
|
||||||
|
if ($bekanntePhasenzahl !== LadestationRegler::PHASEN_UNBEKANNT) {
|
||||||
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,6 +505,10 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
$this->ReadAttributeBoolean('LadeleistungSeitFreigabeErkannt'),
|
$this->ReadAttributeBoolean('LadeleistungSeitFreigabeErkannt'),
|
||||||
$bekanntePhasenzahl
|
$bekanntePhasenzahl
|
||||||
);
|
);
|
||||||
|
if ($status['Phasenzahl'] !== $this->ReadAttributeInteger('ZustandPhasenzahl')) {
|
||||||
|
// Eine alte Leistungsstufe darf nach einer Phasenkorrektur nicht festgehalten werden.
|
||||||
|
$this->WriteAttributeInteger('LeistungswechselGesperrtBis', 0);
|
||||||
|
}
|
||||||
if (!$status['FahrzeugVerbunden']) {
|
if (!$status['FahrzeugVerbunden']) {
|
||||||
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
|
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
|
||||||
} elseif ($status['Ladestrom_A'] >= 3.5) {
|
} elseif ($status['Ladestrom_A'] >= 3.5) {
|
||||||
@@ -504,6 +523,13 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
foreach ($status as $ident => $wert) {
|
foreach ($status as $ident => $wert) {
|
||||||
$this->setzeZustand($ident, $wert);
|
$this->setzeZustand($ident, $wert);
|
||||||
}
|
}
|
||||||
|
$this->setzeZustand('Geraetehinweis', (string) ($rohstatus['Geraetehinweis'] ?? ''));
|
||||||
|
$this->WriteAttributeBoolean('GeraetLadebereit', (bool) ($rohstatus['Ladebereit'] ?? true));
|
||||||
|
$cacheAlter = min(LadestationAdapter::STATUS_MAXIMALALTER, max(15, 3 * $this->ReadPropertyInteger('Abfrageintervall')));
|
||||||
|
$this->WriteAttributeInteger('GeraetestatusGueltigBis', min(
|
||||||
|
time() + $cacheAlter,
|
||||||
|
(int) ($rohstatus['GueltigBis'] ?? PHP_INT_MAX)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, int|float|bool> $status */
|
/** @param array<string, int|float|bool> $status */
|
||||||
@@ -573,6 +599,11 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->ReadAttributeBoolean('ZustandSolarladen') && $angebot !== [0]) {
|
if (!$this->ReadAttributeBoolean('ZustandSolarladen') && $angebot !== [0]) {
|
||||||
|
if ($this->ReadAttributeInteger('ZustandPhasenzahl') === LadestationRegler::PHASEN_UNBEKANNT
|
||||||
|
&& $this->zugeordneteManagerIDs() !== []
|
||||||
|
) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
return $angebot[count($angebot) - 1];
|
return $angebot[count($angebot) - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,6 +625,12 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
if (!$stromUnveraendert) {
|
if (!$stromUnveraendert) {
|
||||||
$this->sendeLadestromAnGeraet($strom);
|
$this->sendeLadestromAnGeraet($strom);
|
||||||
}
|
}
|
||||||
|
if ($phasenzahl === LadestationRegler::PHASEN_UNBEKANNT
|
||||||
|
&& $strom === self::PHASENERKENNUNGSSTROM
|
||||||
|
&& $this->ReadAttributeInteger('PhasenerkennungSeit') === 0
|
||||||
|
) {
|
||||||
|
$this->WriteAttributeInteger('PhasenerkennungSeit', time());
|
||||||
|
}
|
||||||
if ($leistung === $bisherigeLeistung) {
|
if ($leistung === $bisherigeLeistung) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -647,6 +684,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
$strom
|
$strom
|
||||||
);
|
);
|
||||||
$this->WriteAttributeInteger('LetzterGesetzterStrom', $strom);
|
$this->WriteAttributeInteger('LetzterGesetzterStrom', $strom);
|
||||||
|
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -729,6 +767,11 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
|
|
||||||
private function sendeVerbraucherdaten(): void
|
private function sendeVerbraucherdaten(): void
|
||||||
{
|
{
|
||||||
|
if ($this->ReadAttributeInteger('GeraetestatusGueltigBis') <= time()
|
||||||
|
&& (bool) $this->leseZustand('Verfuegbar')
|
||||||
|
) {
|
||||||
|
$this->aktualisiere(false, false, false);
|
||||||
|
}
|
||||||
foreach ($this->zugeordneteManagerIDs() as $managerID) {
|
foreach ($this->zugeordneteManagerIDs() as $managerID) {
|
||||||
$daten = $this->baueVerbraucherdaten($managerID);
|
$daten = $this->baueVerbraucherdaten($managerID);
|
||||||
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
|
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
|
||||||
@@ -822,6 +865,12 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
'Einheit' => '',
|
'Einheit' => '',
|
||||||
'Text' => (string) $this->leseZustand('Stoertext'),
|
'Text' => (string) $this->leseZustand('Stoertext'),
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'Kennung' => 'Geraetehinweis',
|
||||||
|
'Art' => 'Status',
|
||||||
|
'Wert' => (string) $this->leseZustand('Geraetehinweis'),
|
||||||
|
'Einheit' => '',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -955,6 +1004,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 200);
|
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 200);
|
||||||
$this->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 210);
|
$this->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 210);
|
||||||
$this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 220);
|
$this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 220);
|
||||||
|
$this->RegisterVariableString('Geraetehinweis', 'Geraetehinweis', '', 240);
|
||||||
$this->RegisterVariableInteger(
|
$this->RegisterVariableInteger(
|
||||||
'FahrzeugMaximalstrom',
|
'FahrzeugMaximalstrom',
|
||||||
'Erkannter Fahrzeug-Maximalstrom',
|
'Erkannter Fahrzeug-Maximalstrom',
|
||||||
@@ -1037,7 +1087,8 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
$this->WriteAttributeFloat('ZustandIstleistung', (float) $wert);
|
$this->WriteAttributeFloat('ZustandIstleistung', (float) $wert);
|
||||||
break;
|
break;
|
||||||
case 'Stoertext':
|
case 'Stoertext':
|
||||||
$this->WriteAttributeString('ZustandStoertext', (string) $wert);
|
case 'Geraetehinweis':
|
||||||
|
$this->WriteAttributeString('Zustand' . $ident, (string) $wert);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new LogicException('Unbekannter Zustand: ' . $ident);
|
throw new LogicException('Unbekannter Zustand: ' . $ident);
|
||||||
@@ -1069,8 +1120,8 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
if ($ident === 'Ladestrom' || $ident === 'Istleistung') {
|
if ($ident === 'Ladestrom' || $ident === 'Istleistung') {
|
||||||
return $this->ReadAttributeFloat('Zustand' . $ident);
|
return $this->ReadAttributeFloat('Zustand' . $ident);
|
||||||
}
|
}
|
||||||
if ($ident === 'Stoertext') {
|
if ($ident === 'Stoertext' || $ident === 'Geraetehinweis') {
|
||||||
return $this->ReadAttributeString('ZustandStoertext');
|
return $this->ReadAttributeString('Zustand' . $ident);
|
||||||
}
|
}
|
||||||
if ($ident === 'Leistungsquelle') {
|
if ($ident === 'Leistungsquelle') {
|
||||||
return Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN;
|
return Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN;
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
|
|
||||||
Das Modul bindet eine einzelne Ladestation direkt an Enelix EMS an. Es liest
|
Das Modul bindet eine einzelne Ladestation direkt an Enelix EMS an. Es liest
|
||||||
den Fahrzeug- und Ladezustand ueber die Geraete-API, ermittelt die Phasenzahl
|
den Fahrzeug- und Ladezustand ueber die Geraete-API, ermittelt die Phasenzahl
|
||||||
wie Enelix 1 und setzt den vom Manager gewaehlten Ladestrom. Es benoetigt keine
|
aus aktuellen Messwerten und setzt den vom Manager gewaehlten Ladestrom. Es benoetigt keine
|
||||||
Gateway-Instanz und hat keine technische Beziehung zum separaten Modul
|
Gateway-Instanz und hat keine technische Beziehung zum separaten Modul
|
||||||
**Ladestation Gateway**.
|
**Ladestation Gateway**.
|
||||||
|
|
||||||
## Funktionsumfang
|
## Funktionsumfang
|
||||||
|
|
||||||
- direkte Statusabfrage und Steuerung der unterstuetzten Ladestationen,
|
- direkte Statusabfrage und Steuerung der unterstuetzten Ladestationen,
|
||||||
- Fahrzeug-, Lade- und Phasenerkennung nach der Enelix-1-Logik,
|
- Fahrzeug-, Lade- und Phasenerkennung mit geprueften Geraetedaten,
|
||||||
- Leistungsangebote fuer PV- und Peakbetrieb,
|
- Leistungsangebote fuer PV- und Peakbetrieb,
|
||||||
- lokale Freigabe und Umschaltung zwischen Solar- und Normalbetrieb,
|
- lokale Freigabe und Umschaltung zwischen Solar- und Normalbetrieb,
|
||||||
- Kommunikation mit dem Enelix Manager ueber den Nachrichtenvertrag `4.0`,
|
- Kommunikation mit dem Enelix Manager ueber den Nachrichtenvertrag `4.0`,
|
||||||
@@ -34,26 +34,36 @@ Variable noch im Debug-Protokoll oder im Diagnosefeld
|
|||||||
|
|
||||||
## Fahrzeug- und Phasenerkennung
|
## Fahrzeug- und Phasenerkennung
|
||||||
|
|
||||||
Die Auswertung folgt bewusst dem Verhalten der bisherigen Enelix-1-Ladestation:
|
Die Leistungsskalen bleiben kompatibel zu Enelix 1; die Fehler- und
|
||||||
|
Ladeendeauswertung ist seit der Korrektur vom 07.10.2026 strenger:
|
||||||
|
|
||||||
1. Bei go-e gilt das Fahrzeug als verbunden, wenn `car != 1` ist. Bei Pico
|
1. Nach erfolgreicher Statusvalidierung gilt das Fahrzeug bei go-e mit
|
||||||
wird entsprechend `State != 1` ausgewertet.
|
`car != 1` und bei Pico mit `State != 1` als verbunden. Fehler- und
|
||||||
|
Offlinezustaende werden vorher abgewiesen.
|
||||||
2. Die gemessene Leistung wird bei der alten go-e-API mit Faktor 10, bei
|
2. Die gemessene Leistung wird bei der alten go-e-API mit Faktor 10, bei
|
||||||
Gemini direkt in Watt und bei Pico von kW in Watt umgerechnet.
|
Gemini direkt in Watt und bei Pico von kW in Watt umgerechnet.
|
||||||
3. go-e meldet die drei Phasenstroeme einzeln. Sobald mindestens zwei Phasen
|
3. go-e und Pico koennen die drei Phasenstroeme einzeln melden. Ab 1 A gilt
|
||||||
Strom fuehren, wird dreiphasiges Laden direkt erkannt.
|
eine Phase als belastet; mindestens zwei belastete Phasen werden konservativ
|
||||||
|
als drei gerechnet. Frische Dreiphasenmessungen korrigieren gespeicherte
|
||||||
|
Einphasigkeit. Bestaetigte Dreiphasigkeit bleibt konservativ bis zum Ausstecken
|
||||||
|
erhalten, auch wenn zeitweise nur eine Phase belastet wird.
|
||||||
4. Sind beim Anstecken noch keine belastbaren Phasenwerte vorhanden, gibt das
|
4. Sind beim Anstecken noch keine belastbaren Phasenwerte vorhanden, gibt das
|
||||||
Modul zunaechst nur den Mindeststrom von 6 A als Erkennungsstrom vor. Bis zur
|
Modul `[0,4104] W` an: 6 A mit dem bestehenden Dreiphasenbudget. Eine
|
||||||
abgeschlossenen Erkennung bleibt `Phasenzahl=0` und das EMS-Angebot `[0]`.
|
zugeordnete Station startet erst nach passender Manager-Vorgabe. Bei
|
||||||
|
Solarladen im Peakbetrieb bleibt das Angebot `[0]`; es gibt keinen direkten
|
||||||
|
Probebefehl am Manager vorbei. `Phasenzahl=0` bleibt bis zur Erkennung erhalten.
|
||||||
5. Nach `Phasenerkennungszeit` wird die gemessene Leistung relativ zum
|
5. Nach `Phasenerkennungszeit` wird die gemessene Leistung relativ zum
|
||||||
angeforderten Erkennungsstrom ausgewertet. Dadurch lassen sich eine und drei
|
angeforderten Erkennungsstrom ausgewertet. Dadurch lassen sich eine und drei
|
||||||
Phasen bereits bei 6 A unterscheiden. Die erkannte Phasenzahl bleibt bis
|
Phasen bereits bei 6 A unterscheiden. Unter 1265 W bleibt die Anzahl
|
||||||
zum Ausstecken erhalten und kippt waehrend einer Ladepause nicht zurueck.
|
unbekannt, insbesondere bei 0 W. In einer Ladepause bleibt die zuletzt
|
||||||
|
erkannte Anzahl erhalten; neue belastete Phasenmessungen koennen sie korrigieren.
|
||||||
6. Der Ladestrom wird aus der Leistung mit `230 W/A` einphasig beziehungsweise
|
6. Der Ladestrom wird aus der Leistung mit `230 W/A` einphasig beziehungsweise
|
||||||
`684 W/A` dreiphasig berechnet.
|
`684 W/A` dreiphasig berechnet.
|
||||||
7. Ein Ladeende wird nur nach zuvor tatsaechlich gemessener Ladeleistung
|
7. Nur ein explizites aktuelles Ladeende (go-e `car=4`) ohne relevante Last
|
||||||
erkannt. Eine vom EMS angeordnete Solarpause gilt deshalb nicht als
|
(< 1 A und < 100 W) setzt `FahrzeugGeladen`. Ein einzelner Nullwert und
|
||||||
`FahrzeugGeladen`.
|
Ladepausen genuegen nicht. Neues Laden oder ein anderer aktueller Status
|
||||||
|
hebt den Wert wieder auf. Die Pico-API hat keinen eindeutigen Voll-Status;
|
||||||
|
dort wird deshalb kein Ladeende aus niedrigem Strom abgeleitet.
|
||||||
|
|
||||||
Der normalisierte `Fahrzeugstatus` verwendet folgende Werte:
|
Der normalisierte `Fahrzeugstatus` verwendet folgende Werte:
|
||||||
|
|
||||||
@@ -82,7 +92,9 @@ stoppt die Ladestation.
|
|||||||
Die Leistungsstufen entsprechen Enelix 1: `230 W` pro Ampere einphasig und
|
Die Leistungsstufen entsprechen Enelix 1: `230 W` pro Ampere einphasig und
|
||||||
`684 W` pro Ampere dreiphasig. Ohne gueltige Manager-Vorgabe faehrt das Modul
|
`684 W` pro Ampere dreiphasig. Ohne gueltige Manager-Vorgabe faehrt das Modul
|
||||||
bei aktivem Solarladen mit `0 A`; bei ausgeschaltetem Solarladen verwendet es
|
bei aktivem Solarladen mit `0 A`; bei ausgeschaltetem Solarladen verwendet es
|
||||||
die maximale angebotene Leistung.
|
die maximale angebotene Leistung. Ausnahme: Bei unbekannten Phasen und
|
||||||
|
zugeordnetem Manager gibt es auch ohne Solarladen keinen Start ohne Budget.
|
||||||
|
Ohne Manager kann die eigenstaendige Station im Normalbetrieb mit 6 A pruefen.
|
||||||
|
|
||||||
Beim Aktivieren oder Umschalten von Solarladen bleibt ein bereits gesetzter
|
Beim Aktivieren oder Umschalten von Solarladen bleibt ein bereits gesetzter
|
||||||
Geraetestrom zunaechst unveraendert. Das neue Angebot wird zuerst an den
|
Geraetestrom zunaechst unveraendert. Das neue Angebot wird zuerst an den
|
||||||
@@ -157,8 +169,8 @@ Immer sichtbar:
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; der Initialwert ist `false`. |
|
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; der Initialwert ist `false`. |
|
||||||
| `FahrzeugVerbunden` | Boolean / Anzeige | Zeigt, ob die API ein verbundenes Fahrzeug meldet. |
|
| `FahrzeugVerbunden` | Boolean / Anzeige | Zeigt, ob die API ein verbundenes Fahrzeug meldet. |
|
||||||
| `FahrzeugGeladen` | Boolean / Anzeige | Ergebnis der kompatiblen Enelix-1-Voll-Erkennung. |
|
| `FahrzeugGeladen` | Boolean / Anzeige | Aktuell bestaetigtes Ladeende; bei Pico ohne eindeutiges API-Signal immer `false`. |
|
||||||
| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Ladestrom in A. |
|
| `Ladestrom` | Float / Anzeige | Groesster gemessener Phasenstrom, ersatzweise aus Leistung und bekannter Phasenzahl berechnet. |
|
||||||
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig oder `3` dreiphasig. |
|
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig oder `3` dreiphasig. |
|
||||||
|
|
||||||
Nur mit `EinstellungenInVisu=true` sichtbar; Ladefreigabe und Solarladen sind bedienbar:
|
Nur mit `EinstellungenInVisu=true` sichtbar; Ladefreigabe und Solarladen sind bedienbar:
|
||||||
@@ -179,6 +191,7 @@ Nur mit `DiagnosevariablenAnzeigen=true` sichtbar:
|
|||||||
| `AenderungMoeglich` | Zeigt, ob das aktuelle Angebot mehr als eine Leistungsstufe enthaelt. |
|
| `AenderungMoeglich` | Zeigt, ob das aktuelle Angebot mehr als eine Leistungsstufe enthaelt. |
|
||||||
| `Stoerung` | Sammelstatus fuer Konfigurations- und Kommunikationsfehler. |
|
| `Stoerung` | Sammelstatus fuer Konfigurations- und Kommunikationsfehler. |
|
||||||
| `Stoertext` | Letzte verstaendliche Fehlerbeschreibung. |
|
| `Stoertext` | Letzte verstaendliche Fehlerbeschreibung. |
|
||||||
|
| `Geraetehinweis` | Pico-Autorisierung und historisches letztes Ereignis, getrennt vom aktuellen Fehler. |
|
||||||
| `LetzterGeraetebefehl` | Letzter Steuerbefehl mit HTTP-Methode und URL ohne Zugangsdaten; Statusabfragen ueberschreiben ihn nicht. |
|
| `LetzterGeraetebefehl` | Letzter Steuerbefehl mit HTTP-Methode und URL ohne Zugangsdaten; Statusabfragen ueberschreiben ihn nicht. |
|
||||||
| `LeistungsangebotDiagnose` | Aktuelles Leistungsangebot als JSON-Liste in W. |
|
| `LeistungsangebotDiagnose` | Aktuelles Leistungsangebot als JSON-Liste in W. |
|
||||||
|
|
||||||
@@ -206,6 +219,7 @@ An den Manager werden neben den gemeinsamen Feldern diese Zustaende gemeldet:
|
|||||||
- `Ladefreigabe`
|
- `Ladefreigabe`
|
||||||
- `Solarladen`
|
- `Solarladen`
|
||||||
- `Ladefehler` mit Stoertext
|
- `Ladefehler` mit Stoertext
|
||||||
|
- `Geraetehinweis`
|
||||||
|
|
||||||
Eine Sollleistung wird nur angenommen, wenn sie im zuletzt berechneten
|
Eine Sollleistung wird nur angenommen, wenn sie im zuletzt berechneten
|
||||||
Leistungsangebot enthalten ist. Nach `VorgabeTimeout` ohne Erneuerung wird sie
|
Leistungsangebot enthalten ist. Nach `VorgabeTimeout` ohne Erneuerung wird sie
|
||||||
@@ -226,6 +240,51 @@ verwenden 5 Sekunden Verbindungs- und 10 Sekunden Gesamt-Timeout. Antworten ab
|
|||||||
HTTP-Status 400 sowie unvollstaendige oder ungueltige JSON-Antworten gelten als
|
HTTP-Status 400 sowie unvollstaendige oder ungueltige JSON-Antworten gelten als
|
||||||
Kommunikationsfehler.
|
Kommunikationsfehler.
|
||||||
|
|
||||||
|
go-e muss `err=0` und einen bekannten `car`-Wert 1 bis 4 melden. Pico muss
|
||||||
|
einen verwendbaren Zustand 1, 2, 3, 4 oder 6 sowie einen frischen `LastSeen`
|
||||||
|
(ersatzweise `ValueDate`) liefern. Bei Ladezustand 4 oder positiver Leistung
|
||||||
|
muss auch `ValueDate` frisch sein. Die Grenze liegt bei 120 Sekunden; mehr als
|
||||||
|
15 Sekunden in der Zukunft oder ein Zeitstempel ohne Zeitzone sind ungueltig.
|
||||||
|
Im Leerlauf wird das beobachtete `ValueDate=0001-01-01` toleriert, sofern
|
||||||
|
`LastSeen` aktuell ist und keine Leistung gemeldet wird. CamelCase und PascalCase
|
||||||
|
der Pico-Feldnamen werden unterstuetzt. Nicht-endliche/negative Messwerte
|
||||||
|
werden abgewiesen.
|
||||||
|
|
||||||
|
Pico-Zustand 6 wartet auf Autorisierung und bietet `[0]`, ohne allein deshalb
|
||||||
|
einen Kommunikationsfehler zu erzeugen. `LastWarningOrError` ist ein
|
||||||
|
historisches Ereignis und wird nur als `Geraetehinweis` ausgegeben, nicht als
|
||||||
|
dauerhaft aktiver Fehler interpretiert. `MaxStationCurrent` begrenzt die
|
||||||
|
Stationsleistung; dynamische Grenzen von 0 A sind kein Hardwaremaximum.
|
||||||
|
|
||||||
|
Der lokale Statuscache gilt fuer drei Abfrageintervalle, mindestens 15 und
|
||||||
|
hoechstens 120 Sekunden, bei Pico hoechstens bis zum Ablauf der API-Zeitstempel.
|
||||||
|
Managerdaten erneuern diesen Cache nicht. Bei Fehler oder Ablauf werden Angebot
|
||||||
|
und Vorgabe auf 0 gesetzt und die Station als nicht verfuegbar gemeldet. Der
|
||||||
|
steuernde Pfad versucht bei zuvor gesetztem Strom eine Nullvorgabe; deren
|
||||||
|
Scheitern wird zusaetzlich gemeldet. Eine API-Annahme ist keine Bestaetigung
|
||||||
|
eines physischen Ladestopps. Erst eine frische gueltige Statusabfrage hebt den
|
||||||
|
Fehler auf. Reine periodische Rueckmeldungen lesen nicht erneut per HTTP.
|
||||||
|
|
||||||
|
Herstellerquellen: [go-e API-Schluessel](https://github.com/goecharger/go-eCharger-API-v2/blob/main/API_KEYS_FIRMWARE/apikeys-en.md)
|
||||||
|
und [smart-me OpenAPI](https://api.smart-me.com/swagger/v1/swagger.json),
|
||||||
|
abgeglichen am 07.10.2026.
|
||||||
|
|
||||||
|
## Migration der Korrektur vom 07.10.2026
|
||||||
|
|
||||||
|
Vor Installation laufende Moduldateien und Konfiguration sichern und fremde
|
||||||
|
Anpassungen vergleichen. Beim ersten `ApplyChanges` mit Erkennungsversion 2
|
||||||
|
werden alte, moeglicherweise aus 0 W abgeleitete Phasen verworfen. Ein alter
|
||||||
|
Voll-Status wird beim erneuten Lesen nur mit aktuellem explizitem Signal
|
||||||
|
bestaetigt. Freigaben, Solarwahl, Prioritaeten, Objektkennungen und Zugangsdaten
|
||||||
|
bleiben erhalten. Es wird kein Benutzerobjekt geloescht. Neue Phasenerkennung
|
||||||
|
kann auf ausreichendes Managerbudget warten; das ist beabsichtigt.
|
||||||
|
|
||||||
|
Veroeffentlichung auf `develop`/`beta` aktualisiert die Anlage nicht. Nach einem
|
||||||
|
separat freigegebenen Update frische Statusdaten, Phasen, PV/Peak-Angebote,
|
||||||
|
Ladepause/Wiederanlauf und Abschaltung an echten Geraeten kontrollieren.
|
||||||
|
Fuer einen Rollback die gesicherten Moduldateien und passenden Instanzzustand
|
||||||
|
verwenden; keine fremden Laufzeitaenderungen ueberschreiben.
|
||||||
|
|
||||||
## Installation und Inbetriebnahme
|
## Installation und Inbetriebnahme
|
||||||
|
|
||||||
1. Im IP-Symcon Module Control den Testing-Branch `develop` der Bibliothek
|
1. Im IP-Symcon Module Control den Testing-Branch `develop` der Bibliothek
|
||||||
@@ -259,11 +318,14 @@ niemals in Repository-Dateien, Skripte oder Screenshots.
|
|||||||
- Ohne Fahrzeug sind `FahrzeugVerbunden=false`, `Phasenzahl=0` und das
|
- Ohne Fahrzeug sind `FahrzeugVerbunden=false`, `Phasenzahl=0` und das
|
||||||
Leistungsangebot `[0]`.
|
Leistungsangebot `[0]`.
|
||||||
- Nach dem Anstecken wird das Fahrzeug erkannt und bei unbekannten Phasen
|
- Nach dem Anstecken wird das Fahrzeug erkannt und bei unbekannten Phasen
|
||||||
zunaechst der Mindeststrom von 6 A zur Erkennung angefordert.
|
zunaechst `[0,4104]` angeboten. Nur mit Freigabe und Managerbudget beginnt
|
||||||
|
die 6-A-Probe; Solar/Peak verhindert sie.
|
||||||
- Nach der Erkennungszeit werden eine oder drei Phasen relativ zum Pruefstrom
|
- Nach der Erkennungszeit werden eine oder drei Phasen relativ zum Pruefstrom
|
||||||
gespeichert. Bei go-e koennen die einzelnen Phasenstroeme die Erkennung
|
gespeichert, sofern eine belastbare Leistung gemessen wird. Bei 0 W bleibt
|
||||||
bereits vorher abschliessen.
|
die Anzahl unbekannt; einzelne Phasenstroeme koennen die Erkennung vorziehen.
|
||||||
- Die erkannte Phasenzahl bleibt bei einer anschliessenden Ladepause stabil.
|
- Die erkannte Phasenzahl bleibt bei einer anschliessenden Ladepause stabil.
|
||||||
|
- Ein kurzzeitiger Nullwert setzt nicht `FahrzeugGeladen`; neues Laden bleibt moeglich.
|
||||||
|
- go-e-Fehler und alte Pico-Zeitstempel sperren positive Angebote bis zur frischen gueltigen Abfrage.
|
||||||
- Die gemessene Leistung wird plausibel in `Istleistung` und `Ladestrom`
|
- Die gemessene Leistung wird plausibel in `Istleistung` und `Ladestrom`
|
||||||
abgebildet.
|
abgebildet.
|
||||||
- `Aktiv=false` oder `Ladefreigabe=false` stoppt die Ladestation.
|
- `Aktiv=false` oder `Ladefreigabe=false` stoppt die Ladestation.
|
||||||
@@ -286,23 +348,34 @@ niemals in Repository-Dateien, Skripte oder Screenshots.
|
|||||||
- Status `202`: Erreichbarkeit, DNS, lokale Firewall und API-Antwort pruefen.
|
- Status `202`: Erreichbarkeit, DNS, lokale Firewall und API-Antwort pruefen.
|
||||||
`Stoertext` enthaelt den konkreten Kommunikations- oder JSON-Fehler.
|
`Stoertext` enthaelt den konkreten Kommunikations- oder JSON-Fehler.
|
||||||
- Fahrzeug wird nicht erkannt: Rohstatus der Geraete-API kontrollieren. Der
|
- Fahrzeug wird nicht erkannt: Rohstatus der Geraete-API kontrollieren. Der
|
||||||
Adapter erwartet bei go-e `car` und `nrg[11]`, bei Pico `State` und
|
Adapter erwartet bei go-e `err`, `car` und `nrg[11]`, bei Pico `State`,
|
||||||
`ActiveChargingPower`.
|
`ActiveChargingPower` und die oben beschriebenen Zeitstempel.
|
||||||
- Phasenzahl bleibt laenger auf `0`: Waehrend der Erkennungsphase muessen
|
- Phasenzahl bleibt laenger auf `0`: Waehrend der Erkennungsphase muessen
|
||||||
Ladestation und Fahrzeug den angeforderten Maximalstrom tatsaechlich
|
Managerbudget, Freigabe und tatsaechliche Stromaufnahme vorliegen. Nullleistung
|
||||||
freigeben. Bei Pico muss fuer eine sichere Dreiphasenerkennung waehrend der
|
bleibt unbekannt. Beide Adapter koennen einzelne Phasenstroeme nutzen; ohne
|
||||||
Erkennung mehr als 7500 W anliegen; go-e kann zusaetzlich anhand der
|
diese Werte wird die Leistung nach einer budgetierten 6-A-Probe ausgewertet.
|
||||||
einzelnen Phasenstroeme erkennen.
|
|
||||||
- Manager-Vorgabe wird abgewiesen: aktive Zuordnung im Manager sowie
|
- Manager-Vorgabe wird abgewiesen: aktive Zuordnung im Manager sowie
|
||||||
`LeistungsangebotDiagnose` und die Betriebsart kontrollieren.
|
`LeistungsangebotDiagnose` und die Betriebsart kontrollieren.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
Stand der Korrektur vom 07.10.2026: `composer check` erfolgreich mit 402 Tests
|
||||||
|
und 1875 Assertions, einschliesslich isolierter Modullaufzeit. Zusaetzlich 92
|
||||||
|
Assertions der reinen Adapter-/Reglerklassen im Symcon-8.0-Entwicklungskernel
|
||||||
|
mit eigenem Test-Namespace erfolgreich. Der vollstaendige native Modultest
|
||||||
|
unten wurde fuer diese Korrektur angepasst, aber nicht ausgefuehrt. Keine
|
||||||
|
Stellbefehle an reale Hardware, kein Anlagenupdate und keine Feldabnahme.
|
||||||
|
|
||||||
Die Unit- und Adaptertests verwenden einen injizierten Fake-HTTP-Transport.
|
Die Unit- und Adaptertests verwenden einen injizierten Fake-HTTP-Transport.
|
||||||
Damit werden fuer go-e alt, go-e Gemini und smart-me Pico Statusantworten,
|
Damit werden fuer go-e alt, go-e Gemini und smart-me Pico Statusantworten,
|
||||||
URL, HTTP-Methode, Authentisierung und Steueraufrufe ohne reale Hardware
|
URL, HTTP-Methode, Authentisierung und Steueraufrufe ohne reale Hardware
|
||||||
geprueft.
|
geprueft.
|
||||||
|
|
||||||
|
Isolierte Laufzeittests fuehren zusaetzlich das echte Stand-alone-Modul mit
|
||||||
|
simulierten Symcon-Aufrufen aus: Ladepause, Ladeende/Wiederanlauf, Budgetprobe,
|
||||||
|
Peak-Sperre, Phasenkorrektur, Cacheablauf, Fehlerbeibehaltung und Migration.
|
||||||
|
Diese Fake-IPS-Laufzeit darf niemals im echten Symcon-Kernel geladen werden.
|
||||||
|
|
||||||
Der Symcon-Funktionstest verwendet den internen `Testmodus` mit simulierten
|
Der Symcon-Funktionstest verwendet den internen `Testmodus` mit simulierten
|
||||||
API-Antworten. Er prueft alle drei Geraetevarianten, die Mindeststrom-Probe
|
API-Antworten. Er prueft alle drei Geraetevarianten, die Mindeststrom-Probe
|
||||||
beim Anstecken, stabile Phasen waehrend einer Ladepause, die Schaltsperren,
|
beim Anstecken, stabile Phasen waehrend einer Ladepause, die Schaltsperren,
|
||||||
|
|||||||
+91
-13
@@ -12,6 +12,7 @@ final class LadestationAdapter
|
|||||||
public const GO_E_ALT = 1;
|
public const GO_E_ALT = 1;
|
||||||
public const GO_E_GEMINI = 2;
|
public const GO_E_GEMINI = 2;
|
||||||
public const SMART_ME_PICO = 3;
|
public const SMART_ME_PICO = 3;
|
||||||
|
public const STATUS_MAXIMALALTER = 120;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $konfiguration
|
* @param array<string, string> $konfiguration
|
||||||
@@ -91,10 +92,11 @@ final class LadestationAdapter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{Rohstatus: int, Istleistung_W: float, Sollstrom_A?: int, Maximalstrom_A?: int, Iststrom_A?: float, Phasenzahl?: int}
|
* @return array{Rohstatus: int, Istleistung_W: float, LadeendeBestaetigt: bool, Sollstrom_A?: int, Maximalstrom_A?: int, Iststrom_A?: float, Phasenzahl?: int, Ladebereit?: bool, GueltigBis?: int, Geraetehinweis?: string}
|
||||||
*/
|
*/
|
||||||
public static function dekodiereStatus(int $geraetetyp, string $antwort): array
|
public static function dekodiereStatus(int $geraetetyp, string $antwort, ?int $jetzt = null): array
|
||||||
{
|
{
|
||||||
|
$jetzt = $jetzt ?? time();
|
||||||
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
||||||
if (!is_array($daten)) {
|
if (!is_array($daten)) {
|
||||||
throw new RuntimeException('Die Geraeteantwort ist kein JSON-Objekt.');
|
throw new RuntimeException('Die Geraeteantwort ist kein JSON-Objekt.');
|
||||||
@@ -109,11 +111,19 @@ final class LadestationAdapter
|
|||||||
) {
|
) {
|
||||||
throw new RuntimeException('Die go-e-Antwort enthaelt nicht alle Statusfelder.');
|
throw new RuntimeException('Die go-e-Antwort enthaelt nicht alle Statusfelder.');
|
||||||
}
|
}
|
||||||
|
if (!isset($daten['err']) || !is_numeric($daten['err']) || (float) $daten['err'] !== 0.0) {
|
||||||
|
$code = isset($daten['err']) && is_numeric($daten['err']) ? (int) $daten['err'] : 'unbekannt';
|
||||||
|
throw new RuntimeException('go-e meldet Geraetefehler ' . $code . '.');
|
||||||
|
}
|
||||||
|
if (!in_array((float) $daten['car'], [1.0, 2.0, 3.0, 4.0], true)) {
|
||||||
|
throw new RuntimeException('go-e meldet keinen gueltigen Fahrzeugstatus.');
|
||||||
|
}
|
||||||
|
|
||||||
$leistungsfaktor = $geraetetyp === self::GO_E_ALT ? 10.0 : 1.0;
|
$leistungsfaktor = $geraetetyp === self::GO_E_ALT ? 10.0 : 1.0;
|
||||||
$status = [
|
$status = [
|
||||||
'Rohstatus' => (int) $daten['car'],
|
'Rohstatus' => (int) $daten['car'],
|
||||||
'Istleistung_W' => (float) $daten['nrg'][11] * $leistungsfaktor,
|
'Istleistung_W' => self::messwert($daten['nrg'][11], 'go-e Leistung') * $leistungsfaktor,
|
||||||
|
'LadeendeBestaetigt' => (int) $daten['car'] === 4,
|
||||||
];
|
];
|
||||||
if (isset($daten['amp']) && is_numeric($daten['amp'])) {
|
if (isset($daten['amp']) && is_numeric($daten['amp'])) {
|
||||||
$sollstrom = (int) $daten['amp'];
|
$sollstrom = (int) $daten['amp'];
|
||||||
@@ -136,9 +146,9 @@ final class LadestationAdapter
|
|||||||
) {
|
) {
|
||||||
$stromfaktor = $geraetetyp === self::GO_E_ALT ? 0.1 : 1.0;
|
$stromfaktor = $geraetetyp === self::GO_E_ALT ? 0.1 : 1.0;
|
||||||
$phasenstroeme = [
|
$phasenstroeme = [
|
||||||
(float) $daten['nrg'][4] * $stromfaktor,
|
self::messwert($daten['nrg'][4], 'go-e L1') * $stromfaktor,
|
||||||
(float) $daten['nrg'][5] * $stromfaktor,
|
self::messwert($daten['nrg'][5], 'go-e L2') * $stromfaktor,
|
||||||
(float) $daten['nrg'][6] * $stromfaktor,
|
self::messwert($daten['nrg'][6], 'go-e L3') * $stromfaktor,
|
||||||
];
|
];
|
||||||
$status['Iststrom_A'] = max($phasenstroeme);
|
$status['Iststrom_A'] = max($phasenstroeme);
|
||||||
$aktivePhasen = count(array_filter(
|
$aktivePhasen = count(array_filter(
|
||||||
@@ -154,22 +164,90 @@ final class LadestationAdapter
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($geraetetyp === self::SMART_ME_PICO) {
|
if ($geraetetyp === self::SMART_ME_PICO) {
|
||||||
if (!isset($daten['State'], $daten['ActiveChargingPower'])
|
$daten = array_change_key_case($daten, CASE_LOWER);
|
||||||
|| !is_numeric($daten['State'])
|
if (!isset($daten['state'], $daten['activechargingpower'])
|
||||||
|| !is_numeric($daten['ActiveChargingPower'])
|
|| !is_numeric($daten['state'])
|
||||||
|
|| !is_numeric($daten['activechargingpower'])
|
||||||
) {
|
) {
|
||||||
throw new RuntimeException('Die Pico-Antwort enthaelt nicht alle Statusfelder.');
|
throw new RuntimeException('Die Pico-Antwort enthaelt nicht alle Statusfelder.');
|
||||||
}
|
}
|
||||||
|
$zustand = (int) $daten['state'];
|
||||||
return [
|
if (!in_array((float) $daten['state'], [1.0, 2.0, 3.0, 4.0, 6.0], true)) {
|
||||||
'Rohstatus' => (int) $daten['State'],
|
throw new RuntimeException('Pico nicht betriebsbereit (Status ' . $zustand . ').');
|
||||||
'Istleistung_W' => round((float) $daten['ActiveChargingPower'] * 1000.0),
|
}
|
||||||
|
$letzterKontakt = self::zeitpunkt($daten['lastseen'] ?? $daten['valuedate'] ?? null, $jetzt, 'Kontakt');
|
||||||
|
$leistung = round(self::messwert($daten['activechargingpower'], 'Pico Leistung') * 1000.0);
|
||||||
|
$gueltigBis = $letzterKontakt + self::STATUS_MAXIMALALTER;
|
||||||
|
// Im Leerlauf liefert Pico teils ValueDate=0001-01-01; LastSeen ist dann entscheidend.
|
||||||
|
if ($zustand === 4 || $leistung > 0.0) {
|
||||||
|
$messzeit = self::zeitpunkt($daten['valuedate'] ?? null, $jetzt, 'Messung');
|
||||||
|
$gueltigBis = min($gueltigBis, $messzeit + self::STATUS_MAXIMALALTER);
|
||||||
|
}
|
||||||
|
$status = [
|
||||||
|
'Rohstatus' => $zustand,
|
||||||
|
'Istleistung_W' => $leistung,
|
||||||
|
'LadeendeBestaetigt' => false,
|
||||||
|
'Ladebereit' => $zustand !== 6,
|
||||||
|
'GueltigBis' => $gueltigBis,
|
||||||
|
'Geraetehinweis' => $zustand === 6 ? 'Pico wartet auf Autorisierung.' : '',
|
||||||
];
|
];
|
||||||
|
if (isset($daten['currentl1'], $daten['currentl2'], $daten['currentl3'])) {
|
||||||
|
$stroeme = array_map(
|
||||||
|
static fn (string $key): float => self::messwert($daten[$key], 'Pico Phasenstrom'),
|
||||||
|
['currentl1', 'currentl2', 'currentl3']
|
||||||
|
);
|
||||||
|
$status['Iststrom_A'] = max($stroeme);
|
||||||
|
$aktivePhasen = count(array_filter($stroeme, static fn (float $strom): bool => $strom >= 1.0));
|
||||||
|
if ($aktivePhasen > 0) {
|
||||||
|
$status['Phasenzahl'] = $aktivePhasen >= 2 ? 3 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($daten['maxstationcurrent'])) {
|
||||||
|
$maximum = self::messwert($daten['maxstationcurrent'], 'Pico Stationsgrenze');
|
||||||
|
if ($maximum < 6 || $maximum > 32 || floor($maximum) !== $maximum) {
|
||||||
|
throw new RuntimeException('Pico meldet eine ungueltige Stationsstromgrenze.');
|
||||||
|
}
|
||||||
|
$status['Maximalstrom_A'] = (int) $maximum;
|
||||||
|
}
|
||||||
|
if (isset($daten['maxdynamiccurrent'])) {
|
||||||
|
$sollstrom = self::messwert($daten['maxdynamiccurrent'], 'Pico dynamischer Strom');
|
||||||
|
if ($sollstrom === 0.0 || ($sollstrom >= 6 && $sollstrom <= 32 && floor($sollstrom) === $sollstrom)) {
|
||||||
|
$status['Sollstrom_A'] = (int) $sollstrom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($daten['lastwarningorerror']) && is_numeric($daten['lastwarningorerror'])) {
|
||||||
|
// Das API-Feld ist ein historisches Ereignis, kein aktives Fehlerbit.
|
||||||
|
$status['Geraetehinweis'] = trim($status['Geraetehinweis'] . ' Letztes Pico-Ereignis: '
|
||||||
|
. (int) $daten['lastwarningorerror'] . ' (historisch).');
|
||||||
|
}
|
||||||
|
return $status;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new InvalidArgumentException('Unbekannter Geraetetyp.');
|
throw new InvalidArgumentException('Unbekannter Geraetetyp.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param mixed $wert */
|
||||||
|
private static function messwert($wert, string $name): float
|
||||||
|
{
|
||||||
|
if (!is_numeric($wert) || !is_finite((float) $wert) || (float) $wert < 0.0) {
|
||||||
|
throw new RuntimeException($name . ' ist ungueltig.');
|
||||||
|
}
|
||||||
|
return (float) $wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param mixed $wert */
|
||||||
|
private static function zeitpunkt($wert, int $jetzt, string $name): int
|
||||||
|
{
|
||||||
|
if (!is_string($wert) || !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/', $wert)) {
|
||||||
|
throw new RuntimeException('Pico-' . $name . ': gueltiger Zeitstempel fehlt.');
|
||||||
|
}
|
||||||
|
$zeit = strtotime($wert);
|
||||||
|
if ($zeit === false || $zeit > $jetzt + 15 || $jetzt - $zeit >= self::STATUS_MAXIMALALTER) {
|
||||||
|
throw new RuntimeException('Pico-' . $name . ': Zeitstempel veraltet oder ungueltig.');
|
||||||
|
}
|
||||||
|
return $zeit;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $konfiguration
|
* @param array<string, string> $konfiguration
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
|
|||||||
+22
-15
@@ -11,6 +11,7 @@ final class LadestationRegler
|
|||||||
public const PHASEN_UNBEKANNT = 0;
|
public const PHASEN_UNBEKANNT = 0;
|
||||||
public const PHASEN_EIN = 1;
|
public const PHASEN_EIN = 1;
|
||||||
public const PHASEN_DREI = 3;
|
public const PHASEN_DREI = 3;
|
||||||
|
public const PHASENERKENNUNGSLEISTUNG = 4104;
|
||||||
|
|
||||||
private const LEISTUNG_PRO_AMPERE_EINPHASIG = 230;
|
private const LEISTUNG_PRO_AMPERE_EINPHASIG = 230;
|
||||||
private const LEISTUNG_PRO_AMPERE_DREIPHASIG = 684;
|
private const LEISTUNG_PRO_AMPERE_DREIPHASIG = 684;
|
||||||
@@ -26,17 +27,20 @@ final class LadestationRegler
|
|||||||
throw new InvalidArgumentException('Erkennungsstrom muss zwischen 6 und 32 A liegen.');
|
throw new InvalidArgumentException('Erkennungsstrom muss zwischen 6 und 32 A liegen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($leistung < 5.5 * self::LEISTUNG_PRO_AMPERE_EINPHASIG) {
|
||||||
|
return self::PHASEN_UNBEKANNT;
|
||||||
|
}
|
||||||
$trennleistung = 2 * self::LEISTUNG_PRO_AMPERE_EINPHASIG * $angeforderterStrom;
|
$trennleistung = 2 * self::LEISTUNG_PRO_AMPERE_EINPHASIG * $angeforderterStrom;
|
||||||
|
|
||||||
return $leistung > $trennleistung ? self::PHASEN_DREI : self::PHASEN_EIN;
|
return $leistung > $trennleistung ? self::PHASEN_DREI : self::PHASEN_EIN;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Schwelle und der Zuschlag von 2.5 A stammen aus der Enelix-1-Ladestation.
|
* Ladepausen sind kein Ladeende. Nur ein expliziter aktueller Geraetestatus
|
||||||
* Ein Ladeende wird erst nach zuvor gemessener Ladeleistung angenommen, damit
|
* darf "geladen" melden; alte, aus Niedrigstrom abgeleitete Werte verfallen.
|
||||||
* die Anlaufzeit nach einer neuen Stromvorgabe nicht als volles Fahrzeug gilt.
|
* Die bisherigen Verlaufsparameter bleiben fuer Aufrufer kompatibel.
|
||||||
*
|
*
|
||||||
* @param array{Rohstatus: int, Istleistung_W: float, Iststrom_A?: float, Phasenzahl?: int} $rohstatus
|
* @param array{Rohstatus: int, Istleistung_W: float, Iststrom_A?: float, Phasenzahl?: int, LadeendeBestaetigt?: bool} $rohstatus
|
||||||
* @return array<string, int|float|bool>
|
* @return array<string, int|float|bool>
|
||||||
*/
|
*/
|
||||||
public static function normalisiereStatus(
|
public static function normalisiereStatus(
|
||||||
@@ -54,14 +58,17 @@ final class LadestationRegler
|
|||||||
$geladen = false;
|
$geladen = false;
|
||||||
|
|
||||||
if ($verbunden) {
|
if ($verbunden) {
|
||||||
if (in_array($bekanntePhasenzahl, [self::PHASEN_EIN, self::PHASEN_DREI], true)) {
|
if ($leistung > 7500.0 || ($warVerbunden && $bekanntePhasenzahl === self::PHASEN_DREI)) {
|
||||||
$phasen = $bekanntePhasenzahl;
|
// Einzelne momentan unbelastete Phasen duerfen das Budget nicht verkleinern.
|
||||||
|
$phasen = self::PHASEN_DREI;
|
||||||
} elseif (isset($rohstatus['Phasenzahl'])
|
} elseif (isset($rohstatus['Phasenzahl'])
|
||||||
&& in_array($rohstatus['Phasenzahl'], [self::PHASEN_EIN, self::PHASEN_DREI], true)
|
&& in_array($rohstatus['Phasenzahl'], [self::PHASEN_EIN, self::PHASEN_DREI], true)
|
||||||
) {
|
) {
|
||||||
$phasen = (int) $rohstatus['Phasenzahl'];
|
$phasen = (int) $rohstatus['Phasenzahl'];
|
||||||
} elseif ($leistung > 7500.0) {
|
} elseif ($warVerbunden
|
||||||
$phasen = self::PHASEN_DREI;
|
&& in_array($bekanntePhasenzahl, [self::PHASEN_EIN, self::PHASEN_DREI], true)
|
||||||
|
) {
|
||||||
|
$phasen = $bekanntePhasenzahl;
|
||||||
}
|
}
|
||||||
|
|
||||||
$nenner = $phasen === self::PHASEN_DREI
|
$nenner = $phasen === self::PHASEN_DREI
|
||||||
@@ -70,12 +77,8 @@ final class LadestationRegler
|
|||||||
$ladestrom = isset($rohstatus['Iststrom_A'])
|
$ladestrom = isset($rohstatus['Iststrom_A'])
|
||||||
? max(0.0, (float) $rohstatus['Iststrom_A'])
|
? max(0.0, (float) $rohstatus['Iststrom_A'])
|
||||||
: ($phasen === self::PHASEN_UNBEKANNT ? 0.0 : $leistung / $nenner);
|
: ($phasen === self::PHASEN_UNBEKANNT ? 0.0 : $leistung / $nenner);
|
||||||
$ermittelterMaximalstrom = 2.5 + $ladestrom;
|
$geladen = ($rohstatus['LadeendeBestaetigt'] ?? false) === true
|
||||||
$geladen = $warGeladen
|
&& $ladestrom < 1.0 && $leistung < 100.0;
|
||||||
|| ($warVerbunden
|
|
||||||
&& $ladungErwartet
|
|
||||||
&& $ladeleistungWurdeErkannt
|
|
||||||
&& $ermittelterMaximalstrom < 6.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$verbunden) {
|
if (!$verbunden) {
|
||||||
@@ -137,7 +140,8 @@ final class LadestationRegler
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($phasenzahl === self::PHASEN_UNBEKANNT) {
|
if ($phasenzahl === self::PHASEN_UNBEKANNT) {
|
||||||
return [0];
|
// 6 A mit konservativem Dreiphasenbudget, niemals ein direkter Probebefehl.
|
||||||
|
return $solarladen && $peakbetrieb ? [0] : [0, self::PHASENERKENNUNGSLEISTUNG];
|
||||||
}
|
}
|
||||||
|
|
||||||
$stufen = self::leistungsstufen($phasenzahl, $maximalerLadestrom);
|
$stufen = self::leistungsstufen($phasenzahl, $maximalerLadestrom);
|
||||||
@@ -159,6 +163,9 @@ final class LadestationRegler
|
|||||||
if ($leistung <= 0) {
|
if ($leistung <= 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if ($phasenzahl === self::PHASEN_UNBEKANNT) {
|
||||||
|
return $leistung >= self::PHASENERKENNUNGSLEISTUNG ? 6 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
$stufen = self::stufenFuerPhasen($phasenzahl);
|
$stufen = self::stufenFuerPhasen($phasenzahl);
|
||||||
if ($leistung < $stufen[0]) {
|
if ($leistung < $stufen[0]) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase;
|
|||||||
|
|
||||||
final class LadestationAdapterTest extends TestCase
|
final class LadestationAdapterTest extends TestCase
|
||||||
{
|
{
|
||||||
|
private const JETZT = 1791392400;
|
||||||
/**
|
/**
|
||||||
* @dataProvider statusFaelle
|
* @dataProvider statusFaelle
|
||||||
* @param array<string, string> $konfiguration
|
* @param array<string, string> $konfiguration
|
||||||
@@ -33,7 +34,8 @@ final class LadestationAdapterTest extends TestCase
|
|||||||
|
|
||||||
$rohstatus = LadestationAdapter::dekodiereStatus(
|
$rohstatus = LadestationAdapter::dekodiereStatus(
|
||||||
$geraetetyp,
|
$geraetetyp,
|
||||||
$transport->statusLesen($geraetetyp, $konfiguration)
|
$transport->statusLesen($geraetetyp, $konfiguration),
|
||||||
|
self::JETZT
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame($erwarteteUrl, $aufrufe[0]['URL']);
|
self::assertSame($erwarteteUrl, $aufrufe[0]['URL']);
|
||||||
@@ -52,6 +54,7 @@ final class LadestationAdapterTest extends TestCase
|
|||||||
[
|
[
|
||||||
'Rohstatus' => 2,
|
'Rohstatus' => 2,
|
||||||
'Istleistung_W' => 11000.0,
|
'Istleistung_W' => 11000.0,
|
||||||
|
'LadeendeBestaetigt' => false,
|
||||||
'Sollstrom_A' => 16,
|
'Sollstrom_A' => 16,
|
||||||
'Maximalstrom_A' => 16,
|
'Maximalstrom_A' => 16,
|
||||||
'Iststrom_A' => 16.0,
|
'Iststrom_A' => 16.0,
|
||||||
@@ -66,6 +69,7 @@ final class LadestationAdapterTest extends TestCase
|
|||||||
[
|
[
|
||||||
'Rohstatus' => 2,
|
'Rohstatus' => 2,
|
||||||
'Istleistung_W' => 2300.0,
|
'Istleistung_W' => 2300.0,
|
||||||
|
'LadeendeBestaetigt' => false,
|
||||||
'Sollstrom_A' => 16,
|
'Sollstrom_A' => 16,
|
||||||
'Maximalstrom_A' => 16,
|
'Maximalstrom_A' => 16,
|
||||||
'Iststrom_A' => 9.8,
|
'Iststrom_A' => 9.8,
|
||||||
@@ -79,9 +83,12 @@ final class LadestationAdapterTest extends TestCase
|
|||||||
'State' => 2,
|
'State' => 2,
|
||||||
'ActiveChargingPower' => 11.0,
|
'ActiveChargingPower' => 11.0,
|
||||||
'MaxAllowedChargingCurrent' => 16,
|
'MaxAllowedChargingCurrent' => 16,
|
||||||
|
'LastSeen' => gmdate('Y-m-d\TH:i:s\Z', self::JETZT),
|
||||||
|
'ValueDate' => gmdate('Y-m-d\TH:i:s\Z', self::JETZT),
|
||||||
], JSON_THROW_ON_ERROR),
|
], JSON_THROW_ON_ERROR),
|
||||||
'https://api.smart-me.com/pico/charging/pico-id',
|
'https://api.smart-me.com/pico/charging/pico-id',
|
||||||
['Rohstatus' => 2, 'Istleistung_W' => 11000.0],
|
['Rohstatus' => 2, 'Istleistung_W' => 11000.0, 'LadeendeBestaetigt' => false,
|
||||||
|
'Ladebereit' => true, 'GueltigBis' => self::JETZT + 120, 'Geraetehinweis' => ''],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +106,112 @@ final class LadestationAdapterTest extends TestCase
|
|||||||
self::assertSame(0, $status['Sollstrom_A']);
|
self::assertSame(0, $status['Sollstrom_A']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @dataProvider fehlerhafteGoEStatus */
|
||||||
|
public function testGoEFehlerUndUnbekannteZustaendeWerdenAbgewiesen($car, $err): void
|
||||||
|
{
|
||||||
|
$daten = json_decode($this->goEAntwort(2, 0, [0, 0, 0]), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$daten['car'] = $car;
|
||||||
|
$daten['err'] = $err;
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
LadestationAdapter::dekodiereStatus(2, json_encode($daten, JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fehlerhafteGoEStatus(): iterable
|
||||||
|
{
|
||||||
|
yield [2, 11];
|
||||||
|
yield [2, null];
|
||||||
|
yield [2, 'ungueltig'];
|
||||||
|
yield [0, 0];
|
||||||
|
yield [5, 0];
|
||||||
|
yield [2.5, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGoELadeendeIstExplizitUndNichtAusLeistungAbgeleitet(): void
|
||||||
|
{
|
||||||
|
foreach ([1 => false, 2 => false, 3 => false, 4 => true] as $car => $ende) {
|
||||||
|
$status = LadestationAdapter::dekodiereStatus(2, $this->goEAntwort($car, 0, [0, 0, 0]));
|
||||||
|
self::assertSame($ende, $status['LadeendeBestaetigt']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPicoVerwendetFrischePhasenstroemeUndStationsgrenze(): void
|
||||||
|
{
|
||||||
|
$status = LadestationAdapter::dekodiereStatus(3, $this->picoAntwort([
|
||||||
|
'ActiveChargingPower' => 4.104, 'CurrentL1' => 6.0, 'CurrentL2' => 6.0,
|
||||||
|
'CurrentL3' => 6.0, 'MaxStationCurrent' => 16, 'MaxDynamicCurrent' => 6,
|
||||||
|
'MaxAllowedChargingCurrent' => 0,
|
||||||
|
]), self::JETZT);
|
||||||
|
self::assertSame(3, $status['Phasenzahl']);
|
||||||
|
self::assertSame(6.0, $status['Iststrom_A']);
|
||||||
|
self::assertSame(16, $status['Maximalstrom_A']);
|
||||||
|
self::assertSame(6, $status['Sollstrom_A']);
|
||||||
|
self::assertFalse($status['LadeendeBestaetigt']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPicoIdleAkzeptiertLeeresMessdatumNurBeiFrischemKontaktUndNullleistung(): void
|
||||||
|
{
|
||||||
|
$antwort = $this->picoAntwort(['State' => 1, 'ValueDate' => '0001-01-01T00:00:00']);
|
||||||
|
$status = LadestationAdapter::dekodiereStatus(3, $antwort, self::JETZT);
|
||||||
|
self::assertSame(1, $status['Rohstatus']);
|
||||||
|
self::assertSame(0.0, $status['Istleistung_W']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPicoHistorischesEreignisIstHinweisUndKeineDauersperre(): void
|
||||||
|
{
|
||||||
|
$status = LadestationAdapter::dekodiereStatus(3, $this->picoAntwort([
|
||||||
|
'LastWarningOrError' => 19, 'LastWarningOrErrorTime' => '2020-01-01T00:00:00Z',
|
||||||
|
]), self::JETZT);
|
||||||
|
self::assertTrue($status['Ladebereit']);
|
||||||
|
self::assertStringContainsString('19 (historisch)', $status['Geraetehinweis']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPicoAutorisierungIstKeineLadefreigabe(): void
|
||||||
|
{
|
||||||
|
$status = LadestationAdapter::dekodiereStatus(3, $this->picoAntwort(['State' => 6]), self::JETZT);
|
||||||
|
self::assertFalse($status['Ladebereit']);
|
||||||
|
self::assertStringContainsString('Autorisierung', $status['Geraetehinweis']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPicoAkzeptiertDokumentierteCamelCaseAntwortUndSekundenbruchteile(): void
|
||||||
|
{
|
||||||
|
$daten = json_decode($this->picoAntwort(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$camel = [];
|
||||||
|
foreach ($daten as $name => $wert) { $camel[lcfirst($name)] = $wert; }
|
||||||
|
$camel['lastSeen'] = gmdate('Y-m-d\TH:i:s', self::JETZT) . '.1234567Z';
|
||||||
|
$status = LadestationAdapter::dekodiereStatus(3, json_encode($camel, JSON_THROW_ON_ERROR), self::JETZT);
|
||||||
|
self::assertSame(self::JETZT + 120, $status['GueltigBis']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider fehlerhaftePicoStatus */
|
||||||
|
public function testPicoVeralteteOderUngueltigeStatusdatenWerdenAbgewiesen(array $aenderungen): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
LadestationAdapter::dekodiereStatus(3, $this->picoAntwort($aenderungen), self::JETZT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fehlerhaftePicoStatus(): iterable
|
||||||
|
{
|
||||||
|
yield 'Kontakt alt' => [['LastSeen' => gmdate('Y-m-d\TH:i:s\Z', self::JETZT - 121)]];
|
||||||
|
yield 'Messung alt' => [['ValueDate' => gmdate('Y-m-d\TH:i:s\Z', self::JETZT - 121)]];
|
||||||
|
yield 'Zukunft' => [['LastSeen' => gmdate('Y-m-d\TH:i:s\Z', self::JETZT + 16)]];
|
||||||
|
yield 'Zeitzone fehlt' => [['LastSeen' => '2026-10-07T19:40:00']];
|
||||||
|
yield 'Datum fehlt' => [['LastSeen' => null, 'ValueDate' => null]];
|
||||||
|
yield 'Offline' => [['State' => 7]];
|
||||||
|
yield 'Boot' => [['State' => 0]];
|
||||||
|
yield 'Installation' => [['State' => 5]];
|
||||||
|
yield 'Negativer Strom' => [['CurrentL1' => -1, 'CurrentL2' => 0, 'CurrentL3' => 0]];
|
||||||
|
yield 'Unendliche Leistung' => [['ActiveChargingPower' => '1e999']];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function picoAntwort(array $aenderungen = []): string
|
||||||
|
{
|
||||||
|
return json_encode(array_replace([
|
||||||
|
'State' => 4, 'ActiveChargingPower' => 0.0,
|
||||||
|
'ValueDate' => gmdate('Y-m-d\TH:i:s\Z', self::JETZT),
|
||||||
|
'LastSeen' => gmdate('Y-m-d\TH:i:s\Z', self::JETZT),
|
||||||
|
], $aenderungen), JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @dataProvider steuerFaelle
|
* @dataProvider steuerFaelle
|
||||||
* @param array<string, string> $konfiguration
|
* @param array<string, string> $konfiguration
|
||||||
@@ -200,6 +313,7 @@ final class LadestationAdapterTest extends TestCase
|
|||||||
|
|
||||||
return json_encode([
|
return json_encode([
|
||||||
'car' => $status,
|
'car' => $status,
|
||||||
|
'err' => 0,
|
||||||
'nrg' => $nrg,
|
'nrg' => $nrg,
|
||||||
'amp' => 16,
|
'amp' => 16,
|
||||||
'ama' => 16,
|
'ama' => 16,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ final class LadestationReglerTest extends TestCase
|
|||||||
], false);
|
], false);
|
||||||
|
|
||||||
self::assertSame(LadestationRegler::PHASEN_UNBEKANNT, $status['Phasenzahl']);
|
self::assertSame(LadestationRegler::PHASEN_UNBEKANNT, $status['Phasenzahl']);
|
||||||
self::assertSame([0], LadestationRegler::leistungsangebot(
|
self::assertSame([0, 4104], LadestationRegler::leistungsangebot(
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
@@ -94,15 +94,15 @@ final class LadestationReglerTest extends TestCase
|
|||||||
self::assertSame(2, $status['Fahrzeugstatus']);
|
self::assertSame(2, $status['Fahrzeugstatus']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGeladenWirdErstNachZuvorGemessenerLadeleistungErkannt(): void
|
public function testNiedrigstromNachLadungIstKeinBestaetigtesLadeende(): void
|
||||||
{
|
{
|
||||||
$status = LadestationRegler::normalisiereStatus([
|
$status = LadestationRegler::normalisiereStatus([
|
||||||
'Rohstatus' => 2,
|
'Rohstatus' => 2,
|
||||||
'Istleistung_W' => 0.0,
|
'Istleistung_W' => 0.0,
|
||||||
], true, false, true, true);
|
], true, false, true, true);
|
||||||
|
|
||||||
self::assertTrue($status['FahrzeugGeladen']);
|
self::assertFalse($status['FahrzeugGeladen']);
|
||||||
self::assertSame(4, $status['Fahrzeugstatus']);
|
self::assertSame(2, $status['Fahrzeugstatus']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAbsichtlichGestoppteLadungWirdNichtAlsGeladenErkannt(): void
|
public function testAbsichtlichGestoppteLadungWirdNichtAlsGeladenErkannt(): void
|
||||||
@@ -116,15 +116,61 @@ final class LadestationReglerTest extends TestCase
|
|||||||
self::assertSame(2, $status['Fahrzeugstatus']);
|
self::assertSame(2, $status['Fahrzeugstatus']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testErkanntesLadeendeBleibtBisZumAbsteckenErhalten(): void
|
public function testAltesLadeendeOhneAktuelleBestaetigungWirdVerworfen(): void
|
||||||
{
|
{
|
||||||
$status = LadestationRegler::normalisiereStatus([
|
$status = LadestationRegler::normalisiereStatus([
|
||||||
'Rohstatus' => 2,
|
'Rohstatus' => 2,
|
||||||
'Istleistung_W' => 0.0,
|
'Istleistung_W' => 0.0,
|
||||||
], true, true, false);
|
], true, true, false);
|
||||||
|
|
||||||
self::assertTrue($status['FahrzeugGeladen']);
|
self::assertFalse($status['FahrzeugGeladen']);
|
||||||
self::assertSame(4, $status['Fahrzeugstatus']);
|
self::assertSame(2, $status['Fahrzeugstatus']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExplizitesLadeendeWirdBeiErneuterLadungZurueckgenommen(): void
|
||||||
|
{
|
||||||
|
$ende = ['Rohstatus' => 4, 'Istleistung_W' => 0.0, 'LadeendeBestaetigt' => true];
|
||||||
|
self::assertTrue(LadestationRegler::normalisiereStatus($ende, true)['FahrzeugGeladen']);
|
||||||
|
$ende['Istleistung_W'] = 1380.0;
|
||||||
|
self::assertFalse(LadestationRegler::normalisiereStatus($ende, true, true)['FahrzeugGeladen']);
|
||||||
|
$ende['Rohstatus'] = 1;
|
||||||
|
$ende['Istleistung_W'] = 0.0;
|
||||||
|
self::assertFalse(LadestationRegler::normalisiereStatus($ende, true, true)['FahrzeugGeladen']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFrischePhasenmessungKorrigiertGespeicherteEinphasigkeit(): void
|
||||||
|
{
|
||||||
|
$status = LadestationRegler::normalisiereStatus([
|
||||||
|
'Rohstatus' => 2, 'Istleistung_W' => 4104.0, 'Iststrom_A' => 6.0, 'Phasenzahl' => 3,
|
||||||
|
], true, false, true, true, 1);
|
||||||
|
self::assertSame(3, $status['Phasenzahl']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNullUndSchwacheLeistungBeweisenKeinePhase(): void
|
||||||
|
{
|
||||||
|
foreach ([0.0, 100.0, 800.0, 1264.0] as $leistung) {
|
||||||
|
self::assertSame(0, LadestationRegler::erkennePhasenzahlAusLeistung($leistung, 6));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEinzelneAktivePhaseVerkleinertKeinBestaetigtesDreiphasenbudget(): void
|
||||||
|
{
|
||||||
|
$rohstatus = ['Rohstatus' => 2, 'Istleistung_W' => 1380.0, 'Phasenzahl' => 1];
|
||||||
|
self::assertSame(3, LadestationRegler::normalisiereStatus($rohstatus, true, false, true, true, 3)['Phasenzahl']);
|
||||||
|
self::assertSame(1, LadestationRegler::normalisiereStatus($rohstatus, false, false, true, true, 3)['Phasenzahl']);
|
||||||
|
$rohstatus['Istleistung_W'] = 11000.0;
|
||||||
|
self::assertSame(3, LadestationRegler::normalisiereStatus($rohstatus, false)['Phasenzahl']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnbekanntePhasenHabenNurKonservativesStartbudget(): void
|
||||||
|
{
|
||||||
|
self::assertSame([0], LadestationRegler::leistungsangebot(true, true, true, false, 0, 16, true, true));
|
||||||
|
self::assertSame([0, 4104], LadestationRegler::leistungsangebot(true, true, true, false, 0, 16, false, true));
|
||||||
|
self::assertSame([0], LadestationRegler::leistungsangebot(false, true, true, false, 0, 16, false, false));
|
||||||
|
self::assertSame([0], LadestationRegler::leistungsangebot(true, false, true, false, 0, 16, false, false));
|
||||||
|
self::assertSame(0, LadestationRegler::stromFuerLeistung(4103, 0));
|
||||||
|
self::assertSame(6, LadestationRegler::stromFuerLeistung(4104, 0));
|
||||||
|
self::assertSame(6, LadestationRegler::stromFuerLeistung(22000, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDirekterPhasenstromHatVorrangVorNominalerLeistungsumrechnung(): void
|
public function testDirekterPhasenstromHatVorrangVorNominalerLeistungsumrechnung(): void
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixEMS\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @runTestsInSeparateProcesses
|
||||||
|
* @preserveGlobalState disabled
|
||||||
|
*/
|
||||||
|
final class LadestationStandAloneRuntimeTest extends TestCase
|
||||||
|
{
|
||||||
|
private function station(bool $solar = true, int $type = 2): \LadestationStandAlone
|
||||||
|
{
|
||||||
|
require_once __DIR__ . '/Support/LadestationRuntime.php';
|
||||||
|
require_once __DIR__ . '/../LadestationStandAlone/module.php';
|
||||||
|
$station = new \LadestationStandAlone(10001);
|
||||||
|
$station->Create();
|
||||||
|
$station->testProperties = array_replace($station->testProperties, [
|
||||||
|
'Geraetetyp' => $type, 'Geraeteadresse' => '192.0.2.10',
|
||||||
|
'GeraeteID' => 'test-pico', 'Seriennummer' => 'test-pico',
|
||||||
|
'Benutzername' => 'test', 'Passwort' => hash('sha256', __FILE__),
|
||||||
|
'EinstellungenInVisu' => true, 'DiagnosevariablenAnzeigen' => true,
|
||||||
|
'Testmodus' => true, 'Testantwort' => $type === 3 ? $this->pico() : $this->goe(0),
|
||||||
|
'Solarladen' => $solar, 'ZeitZwischenZustandswechseln' => 0,
|
||||||
|
]);
|
||||||
|
\LadestationRuntime::$managers[20001] = [
|
||||||
|
'AutomatischeSuche' => false,
|
||||||
|
'VerbraucherZuordnung' => json_encode([['InstanzID' => 10001, 'Aktiv' => true]], JSON_THROW_ON_ERROR),
|
||||||
|
];
|
||||||
|
$station->SetValue('Aktiv', true);
|
||||||
|
$station->ApplyChanges();
|
||||||
|
\LadestationRuntime::$commands = [];
|
||||||
|
return $station;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function goe(float $power, array $currents = [0, 0, 0], int $car = 2, int $error = 0): string
|
||||||
|
{
|
||||||
|
$nrg = array_fill(0, 12, 0);
|
||||||
|
foreach ($currents as $i => $value) { $nrg[$i + 4] = $value; }
|
||||||
|
$nrg[11] = $power;
|
||||||
|
return json_encode(['car' => $car, 'err' => $error, 'nrg' => $nrg], JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pico(array $changes = []): string
|
||||||
|
{
|
||||||
|
return json_encode(array_replace([
|
||||||
|
'State' => 4, 'ActiveChargingPower' => 0.0,
|
||||||
|
'LastSeen' => gmdate('Y-m-d\TH:i:s\Z'), 'ValueDate' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||||
|
], $changes), JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function poll(\LadestationStandAlone $station, string $reply): void
|
||||||
|
{
|
||||||
|
$station->testProperties['Testantwort'] = $reply;
|
||||||
|
$station->RequestAction('Statuszyklus', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function target(\LadestationStandAlone $station, ?int $power, string $mode = 'PV'): void
|
||||||
|
{
|
||||||
|
$station->ManagerdatenEmpfangen([
|
||||||
|
'Kopf' => ['Version' => '4.0', 'AbsenderID' => 20001, 'EmpfaengerID' => 10001, 'Zeitpunkt' => time()],
|
||||||
|
'Betriebsart' => $mode, 'Sollleistung_W' => $power,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function offer(\LadestationStandAlone $station): array
|
||||||
|
{
|
||||||
|
return json_decode($station->testAttributes['Leistungsangebot'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKurzerNullwertNachLadenSperrtKeinenNeustart(): void
|
||||||
|
{
|
||||||
|
$station = $this->station();
|
||||||
|
$this->poll($station, $this->goe(10944, [16, 16, 16]));
|
||||||
|
$this->target($station, 10944);
|
||||||
|
$this->poll($station, $this->goe(10944, [16, 16, 16]));
|
||||||
|
$this->poll($station, $this->goe(0));
|
||||||
|
self::assertFalse($station->GetValue('FahrzeugGeladen'));
|
||||||
|
self::assertContains(10944, $this->offer($station));
|
||||||
|
$this->poll($station, $this->goe(10944, [16, 16, 16]));
|
||||||
|
self::assertFalse($station->GetValue('FahrzeugGeladen'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExplizitesLadeendeWirdBeiNeuLadendemFahrzeugAufgehoben(): void
|
||||||
|
{
|
||||||
|
$station = $this->station();
|
||||||
|
$this->poll($station, $this->goe(0, [0, 0, 0], 4));
|
||||||
|
self::assertTrue($station->GetValue('FahrzeugGeladen'));
|
||||||
|
self::assertSame([0], $this->offer($station));
|
||||||
|
$this->poll($station, $this->goe(4104, [6, 6, 6]));
|
||||||
|
self::assertFalse($station->GetValue('FahrzeugGeladen'));
|
||||||
|
self::assertContains(4104, $this->offer($station));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPhasenprobeBrauchtManagerbudgetUndNullleistungBleibtUnbekannt(): void
|
||||||
|
{
|
||||||
|
$station = $this->station();
|
||||||
|
self::assertSame(0, $station->testAttributes['LetzterGesetzterStrom']);
|
||||||
|
self::assertSame([0, 4104], $this->offer($station));
|
||||||
|
$this->target($station, 4104);
|
||||||
|
self::assertSame(6, $station->testAttributes['LetzterGesetzterStrom']);
|
||||||
|
$station->testAttributes['PhasenerkennungSeit'] = time() - 70;
|
||||||
|
$this->poll($station, $this->goe(0));
|
||||||
|
self::assertSame(0, $station->GetValue('Phasenzahl'));
|
||||||
|
self::assertFalse($station->GetValue('FahrzeugGeladen'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPhasenprobeOhneManagerbudgetStartetAuchOhneSolarNicht(): void
|
||||||
|
{
|
||||||
|
$station = $this->station(false);
|
||||||
|
self::assertSame(0, $station->testAttributes['LetzterGesetzterStrom']);
|
||||||
|
$this->target($station, 4104, 'Peak');
|
||||||
|
self::assertSame(6, $station->testAttributes['LetzterGesetzterStrom']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSolarImPeakStartetKeineProbeUndBeendetLaufendeProbe(): void
|
||||||
|
{
|
||||||
|
$station = $this->station();
|
||||||
|
$this->target($station, null, 'Peak');
|
||||||
|
$this->poll($station, $this->goe(0));
|
||||||
|
self::assertSame([0], $this->offer($station));
|
||||||
|
self::assertSame(0, $station->testAttributes['LetzterGesetzterStrom']);
|
||||||
|
$this->target($station, 4104, 'PV');
|
||||||
|
self::assertSame(6, $station->testAttributes['LetzterGesetzterStrom']);
|
||||||
|
$this->target($station, null, 'Peak');
|
||||||
|
self::assertSame(0, $station->testAttributes['LetzterGesetzterStrom']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeaktivierungStopptAuchWaerendUnbekannterPhasen(): void
|
||||||
|
{
|
||||||
|
$station = $this->station();
|
||||||
|
$this->target($station, 4104);
|
||||||
|
$station->RequestAction('Ladefreigabe', false);
|
||||||
|
self::assertSame(0, $station->testAttributes['LetzterGesetzterStrom']);
|
||||||
|
self::assertSame([0], $this->offer($station));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFrischeDreiphasigkeitKorrigiertAltenEinphasenwertUndLeistungssperre(): void
|
||||||
|
{
|
||||||
|
$station = $this->station();
|
||||||
|
$this->poll($station, $this->goe(1380, [6, 0, 0]));
|
||||||
|
$this->target($station, 1380);
|
||||||
|
$station->testAttributes['LeistungswechselGesperrtBis'] = time() + 60;
|
||||||
|
$this->poll($station, $this->goe(4104, [6, 6, 6]));
|
||||||
|
self::assertSame(3, $station->GetValue('Phasenzahl'));
|
||||||
|
self::assertNotContains(1380, $this->offer($station));
|
||||||
|
self::assertContains(4104, $this->offer($station));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGeraetefehlerBleibtBisFrischerErfolgreicherAbfrageGesperrt(): void
|
||||||
|
{
|
||||||
|
$station = $this->station();
|
||||||
|
$this->target($station, 4104);
|
||||||
|
$this->poll($station, $this->goe(0, [0, 0, 0], 2, 11));
|
||||||
|
self::assertSame(202, $station->testStatus);
|
||||||
|
self::assertSame([0], $this->offer($station));
|
||||||
|
self::assertSame(0, $station->testAttributes['LetzterGesetzterStrom']);
|
||||||
|
$this->target($station, null, 'PV');
|
||||||
|
self::assertTrue($station->GetValue('Stoerung'));
|
||||||
|
self::assertSame([0], $this->offer($station));
|
||||||
|
try {
|
||||||
|
$this->target($station, 4104);
|
||||||
|
self::fail('Positiver Auftrag trotz Fehler angenommen');
|
||||||
|
} catch (\InvalidArgumentException $expected) {
|
||||||
|
self::assertSame(0, $station->testAttributes['LetzterGesetzterStrom']);
|
||||||
|
}
|
||||||
|
$this->poll($station, $this->goe(0));
|
||||||
|
self::assertSame(102, $station->testStatus);
|
||||||
|
self::assertFalse($station->GetValue('Stoerung'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDreiphasenbudgetBleibtBeiEinzelnerBelasteterPhaseBisZumAusstecken(): void
|
||||||
|
{
|
||||||
|
$station = $this->station();
|
||||||
|
$this->poll($station, $this->goe(4104, [6, 6, 6]));
|
||||||
|
$this->poll($station, $this->goe(1380, [6, 0, 0]));
|
||||||
|
self::assertSame(3, $station->GetValue('Phasenzahl'));
|
||||||
|
$this->poll($station, $this->goe(0, [0, 0, 0], 1));
|
||||||
|
$this->poll($station, $this->goe(1380, [6, 0, 0]));
|
||||||
|
self::assertSame(1, $station->GetValue('Phasenzahl'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAbgelaufenerStatusWirdNichtDurchManagerdatenErneuert(): void
|
||||||
|
{
|
||||||
|
$station = $this->station();
|
||||||
|
$this->target($station, 4104);
|
||||||
|
$station->testAttributes['GeraetestatusGueltigBis'] = time() - 1;
|
||||||
|
$this->target($station, 0);
|
||||||
|
self::assertSame([0], $this->offer($station));
|
||||||
|
self::assertFalse($station->GetValue('Verfuegbar'));
|
||||||
|
self::assertSame(0, $station->testAttributes['LetzterGesetzterStrom']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMeldungMitAbgelaufenemCacheMeldetNichtVerfuegbarOhneHttpAbruf(): void
|
||||||
|
{
|
||||||
|
$station = $this->station();
|
||||||
|
$station->testAttributes['GeraetestatusGueltigBis'] = time() - 1;
|
||||||
|
$station->testProperties['Testantwort'] = 'UNGUELTIG';
|
||||||
|
$station->RequestAction('Melden', false);
|
||||||
|
self::assertSame([], \LadestationRuntime::$commands);
|
||||||
|
$message = end(\LadestationRuntime::$messages);
|
||||||
|
$data = json_decode($message[2], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertFalse($data['Verfuegbar']);
|
||||||
|
self::assertSame([0], $data['Leistungswerte_W']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReineModusmeldungStopptBeiAbgelaufenemCache(): void
|
||||||
|
{
|
||||||
|
$station = $this->station();
|
||||||
|
$this->target($station, 4104);
|
||||||
|
$station->testAttributes['GeraetestatusGueltigBis'] = time() - 1;
|
||||||
|
$this->target($station, null);
|
||||||
|
self::assertTrue($station->GetValue('Stoerung'));
|
||||||
|
self::assertSame(0, $station->testAttributes['LetzterGesetzterStrom']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPicoVeralteteMessungSperrtUndFrischeMessungStelltWiederHer(): void
|
||||||
|
{
|
||||||
|
$station = $this->station(true, 3);
|
||||||
|
$this->poll($station, $this->pico(['ValueDate' => '2020-01-01T00:00:00Z']));
|
||||||
|
self::assertTrue($station->GetValue('Stoerung'));
|
||||||
|
self::assertSame([0], $this->offer($station));
|
||||||
|
$this->target($station, null);
|
||||||
|
self::assertTrue($station->GetValue('Stoerung'));
|
||||||
|
$this->poll($station, $this->pico());
|
||||||
|
self::assertFalse($station->GetValue('Stoerung'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPicoAutorisierungUndHistorieWerdenGetrenntBehandelt(): void
|
||||||
|
{
|
||||||
|
$station = $this->station(true, 3);
|
||||||
|
$this->poll($station, $this->pico(['State' => 6, 'LastWarningOrError' => 19]));
|
||||||
|
self::assertSame([0], $this->offer($station));
|
||||||
|
self::assertFalse($station->GetValue('Stoerung'));
|
||||||
|
self::assertStringContainsString('Autorisierung', $station->GetValue('Geraetehinweis'));
|
||||||
|
$this->poll($station, $this->pico(['LastWarningOrError' => 19]));
|
||||||
|
self::assertSame([0, 4104], $this->offer($station));
|
||||||
|
self::assertStringContainsString('historisch', $station->GetValue('Geraetehinweis'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateEntferntAlteUnbelegtePhasenUndVollErkennung(): void
|
||||||
|
{
|
||||||
|
$station = $this->station();
|
||||||
|
$station->testAttributes['PhasenerkennungVersion'] = 1;
|
||||||
|
$station->testAttributes['ZustandPhasenzahl'] = 1;
|
||||||
|
$station->testAttributes['ZustandFahrzeugGeladen'] = true;
|
||||||
|
$station->ApplyChanges();
|
||||||
|
self::assertSame(2, $station->testAttributes['PhasenerkennungVersion']);
|
||||||
|
self::assertSame(0, $station->GetValue('Phasenzahl'));
|
||||||
|
self::assertFalse($station->GetValue('FahrzeugGeladen'));
|
||||||
|
self::assertSame([0, 4104], $this->offer($station));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Used only by isolated PHPUnit processes; never load inside a Symcon kernel.
|
||||||
|
if (function_exists('IPS_GetKernelVersion')) {
|
||||||
|
throw new RuntimeException('Der isolierte Test darf nicht in Symcon laufen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final class LadestationRuntime
|
||||||
|
{
|
||||||
|
public static array $variables = [];
|
||||||
|
public static array $children = [];
|
||||||
|
public static array $managers = [];
|
||||||
|
public static array $messages = [];
|
||||||
|
public static array $commands = [];
|
||||||
|
public static int $nextID = 40000;
|
||||||
|
}
|
||||||
|
|
||||||
|
class IPSModule
|
||||||
|
{
|
||||||
|
public int $InstanceID;
|
||||||
|
public array $testProperties = [];
|
||||||
|
public array $testAttributes = [];
|
||||||
|
public array $testTimers = [];
|
||||||
|
public int $testStatus = 0;
|
||||||
|
|
||||||
|
public function __construct(int $id) { $this->InstanceID = $id; }
|
||||||
|
public function Create(): void {}
|
||||||
|
public function ApplyChanges(): void {}
|
||||||
|
public function EnableAction(string $ident): void {}
|
||||||
|
public function SetStatus(int $status): void { $this->testStatus = $status; }
|
||||||
|
public function SetTimerInterval(string $ident, int $interval): void { $this->testTimers[$ident] = $interval; }
|
||||||
|
public function RegisterTimer(string $ident, int $interval, string $script): void { $this->SetTimerInterval($ident, $interval); }
|
||||||
|
public function SendDebug(string $sender, string $text, int $format): void {}
|
||||||
|
|
||||||
|
public function __call(string $method, array $arguments)
|
||||||
|
{
|
||||||
|
if (preg_match('/^(Register|Read|Write)(Property|Attribute)(Boolean|Integer|Float|String)$/', $method, $parts)) {
|
||||||
|
$storage = $parts[2] === 'Property' ? 'testProperties' : 'testAttributes';
|
||||||
|
$ident = $arguments[0];
|
||||||
|
if ($parts[1] === 'Register') {
|
||||||
|
$this->{$storage}[$ident] = $this->{$storage}[$ident] ?? $arguments[1];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($parts[1] === 'Write') {
|
||||||
|
$this->{$storage}[$ident] = $arguments[1];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!array_key_exists($ident, $this->{$storage})) {
|
||||||
|
throw new RuntimeException('Unregistered test property/attribute: ' . $ident);
|
||||||
|
}
|
||||||
|
return $this->{$storage}[$ident];
|
||||||
|
}
|
||||||
|
if (preg_match('/^RegisterVariable(Boolean|Integer|Float|String)$/', $method, $parts)) {
|
||||||
|
$ident = $arguments[0];
|
||||||
|
$id = LadestationRuntime::$children[$this->InstanceID][$ident] ?? ++LadestationRuntime::$nextID;
|
||||||
|
LadestationRuntime::$children[$this->InstanceID][$ident] = $id;
|
||||||
|
LadestationRuntime::$variables[$id] = LadestationRuntime::$variables[$id]
|
||||||
|
?? ['Boolean' => false, 'Integer' => 0, 'Float' => 0.0, 'String' => ''][$parts[1]];
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
throw new BadMethodCallException('Unsupported test call: ' . $method);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function GetIDForIdent(string $ident): int
|
||||||
|
{
|
||||||
|
return LadestationRuntime::$children[$this->InstanceID][$ident] ?? 0;
|
||||||
|
}
|
||||||
|
public function SetValue(string $ident, $value): void
|
||||||
|
{
|
||||||
|
$id = $this->GetIDForIdent($ident);
|
||||||
|
if (!$id) { throw new RuntimeException('Unregistered test variable: ' . $ident); }
|
||||||
|
LadestationRuntime::$variables[$id] = $value;
|
||||||
|
if ($ident === 'LetzterGeraetebefehl') {
|
||||||
|
LadestationRuntime::$commands[] = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public function GetValue(string $ident) { return LadestationRuntime::$variables[$this->GetIDForIdent($ident)]; }
|
||||||
|
public function UnregisterVariable(string $ident): void
|
||||||
|
{
|
||||||
|
unset(LadestationRuntime::$variables[$this->GetIDForIdent($ident)], LadestationRuntime::$children[$this->InstanceID][$ident]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function IPS_VariableExists(int $id): bool { return array_key_exists($id, LadestationRuntime::$variables); }
|
||||||
|
function IPS_GetInstanceListByModuleID(string $id): array { return array_keys(LadestationRuntime::$managers); }
|
||||||
|
function IPS_GetProperty(int $id, string $property) { return LadestationRuntime::$managers[$id][$property]; }
|
||||||
|
function IPS_RequestAction(int $id, string $ident, $value): bool
|
||||||
|
{
|
||||||
|
LadestationRuntime::$messages[] = [$id, $ident, $value];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ return static function (TestContext $test): void {
|
|||||||
$nrg[4 + $index] = $strom;
|
$nrg[4 + $index] = $strom;
|
||||||
}
|
}
|
||||||
$nrg[11] = $leistung;
|
$nrg[11] = $leistung;
|
||||||
return json_encode(['car' => $status, 'nrg' => $nrg], JSON_THROW_ON_ERROR);
|
return json_encode(['car' => $status, 'err' => 0, 'nrg' => $nrg], JSON_THROW_ON_ERROR);
|
||||||
};
|
};
|
||||||
|
|
||||||
$definitionen = [
|
$definitionen = [
|
||||||
@@ -37,9 +37,11 @@ return static function (TestContext $test): void {
|
|||||||
'Benutzername' => 'api-user',
|
'Benutzername' => 'api-user',
|
||||||
'Passwort' => $testPasswort,
|
'Passwort' => $testPasswort,
|
||||||
'Testantwort' => json_encode([
|
'Testantwort' => json_encode([
|
||||||
'State' => 2,
|
'State' => 4,
|
||||||
'ActiveChargingPower' => 11.0,
|
'ActiveChargingPower' => 11.0,
|
||||||
'MaxAllowedChargingCurrent' => 16,
|
'MaxStationCurrent' => 16,
|
||||||
|
'LastSeen' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||||
|
'ValueDate' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||||
], JSON_THROW_ON_ERROR),
|
], JSON_THROW_ON_ERROR),
|
||||||
'Phasenzahl' => 3,
|
'Phasenzahl' => 3,
|
||||||
'BefehlUrlTeil' => 'current=16000',
|
'BefehlUrlTeil' => 'current=16000',
|
||||||
@@ -452,9 +454,9 @@ return static function (TestContext $test): void {
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
$test->runCase('Neue Ladestation wird erst nach Freigabe mit Mindeststrom erkannt', static function (
|
$test->runCase('Phasenprobe braucht Managerbudget und deutet Nullleistung nicht als einphasig', static function (
|
||||||
TestContext $test
|
TestContext $test
|
||||||
) use ($phasenerkennung, $anforderungSenden): void {
|
) use ($phasenerkennung, $anforderungSenden, $goEAntwort): void {
|
||||||
$stationId = $phasenerkennung;
|
$stationId = $phasenerkennung;
|
||||||
$test->assertSame(
|
$test->assertSame(
|
||||||
0,
|
0,
|
||||||
@@ -471,13 +473,23 @@ return static function (TestContext $test): void {
|
|||||||
0,
|
0,
|
||||||
GetValue($test->objectByIdent('Phasenzahl', $stationId))
|
GetValue($test->objectByIdent('Phasenzahl', $stationId))
|
||||||
);
|
);
|
||||||
|
$test->assertSame('[0,4104]', GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId)));
|
||||||
|
$test->assertTrue(!str_contains(
|
||||||
|
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)),
|
||||||
|
'amp=6'
|
||||||
|
), 'Die Freigabe allein ersetzt kein Managerbudget.');
|
||||||
|
$anforderungSenden($stationId, 4104);
|
||||||
$test->assertTrue(str_contains(
|
$test->assertTrue(str_contains(
|
||||||
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)),
|
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)),
|
||||||
'amp=6'
|
'amp=6'
|
||||||
), 'Nach der Freigabe muss die Mindeststrom-Phasenprobe starten.');
|
), 'Nach Freigabe und Budget muss die Mindeststrom-Phasenprobe starten.');
|
||||||
|
|
||||||
IPS_Sleep(2100);
|
IPS_Sleep(2100);
|
||||||
IPS_RequestAction($stationId, 'Statuszyklus', false);
|
IPS_RequestAction($stationId, 'Statuszyklus', false);
|
||||||
|
$test->assertSame(0, GetValue($test->objectByIdent('Phasenzahl', $stationId)));
|
||||||
|
$test->assertSame(false, GetValue($test->objectByIdent('FahrzeugGeladen', $stationId)));
|
||||||
|
IPS_SetProperty($stationId, 'Testantwort', $goEAntwort(2, 1380, [6, 0, 0]));
|
||||||
|
IPS_ApplyChanges($stationId);
|
||||||
$anforderungSenden($stationId, 3680);
|
$anforderungSenden($stationId, 3680);
|
||||||
IPS_RequestAction($stationId, 'Statuszyklus', false);
|
IPS_RequestAction($stationId, 'Statuszyklus', false);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user