fix(charging): validate status and budget phase detection
Tests / test (push) Waiting to run

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:
dh
2026-10-07 18:24:47 +00:00
parent 372b997876
commit 5885681669
10 changed files with 836 additions and 98 deletions
+10
View File
@@ -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
+80 -29
View File
@@ -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')
); );
$this->WriteAttributeInteger('PhasenerkennungSeit', 0); if ($bekanntePhasenzahl !== LadestationRegler::PHASEN_UNBEKANNT) {
$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;
+99 -26
View File
@@ -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
View File
@@ -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
View File
@@ -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]) {
+116 -2
View File
@@ -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,
+53 -7
View File
@@ -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
+255
View File
@@ -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));
}
}
+92
View File
@@ -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;
}
+18 -6
View File
@@ -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);