diff --git a/CHANGELOG.md b/CHANGELOG.md index f7dfadd..9808033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert. ## 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 Prognose / Forecast zusammengefasst. Explizite Start-/Stoppaktionen pruefen Lizenz und bestehende Testfreigaben. Kein automatischer Start, keine neue diff --git a/LadestationStandAlone/module.php b/LadestationStandAlone/module.php index 0608f75..eee3229 100644 --- a/LadestationStandAlone/module.php +++ b/LadestationStandAlone/module.php @@ -34,6 +34,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell 'AenderungMoeglich', 'Stoerung', 'Stoertext', + 'Geraetehinweis', 'Fahrzeugstatus', 'FahrzeugMaximalstrom', 'LetzterGeraetebefehl', @@ -88,6 +89,9 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell $this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false); $this->RegisterAttributeBoolean('ZustandStoerung', false); $this->RegisterAttributeString('ZustandStoertext', ''); + $this->RegisterAttributeString('ZustandGeraetehinweis', ''); + $this->RegisterAttributeInteger('GeraetestatusGueltigBis', 0); + $this->RegisterAttributeBoolean('GeraetLadebereit', false); $this->RegisterAttributeString('Leistungsangebot', '[0]'); $this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV); $this->RegisterAttributeInteger('LetzteManagerID', 0); @@ -149,12 +153,13 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell $this->WriteAttributeInteger('AusschaltenGesperrtBis', 0); $this->WriteAttributeInteger('EinschaltenGesperrtBis', 0); $this->WriteAttributeInteger('PhasenerkennungSeit', 0); - if ($this->ReadAttributeInteger('PhasenerkennungVersion') < 1) { + $this->WriteAttributeInteger('GeraetestatusGueltigBis', 0); + if ($this->ReadAttributeInteger('PhasenerkennungVersion') < 2) { $this->WriteAttributeInteger( 'ZustandPhasenzahl', LadestationRegler::PHASEN_UNBEKANNT ); - $this->WriteAttributeInteger('PhasenerkennungVersion', 1); + $this->WriteAttributeInteger('PhasenerkennungVersion', 2); } $this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false); $this->WriteAttributeInteger( @@ -273,8 +278,10 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell $this->aktualisiere(false, false, false); $sollleistung = $daten['Sollleistung_W']; if ($sollleistung === null) { - if ((bool) $this->leseZustand('SollwertGueltig') - && !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true) + if (($this->leseLeistungsangebot() === [0] + && $this->ReadAttributeInteger('LetzterGesetzterStrom') !== 0) + || ((bool) $this->leseZustand('SollwertGueltig') + && !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)) ) { $this->verwerfeSollwert(); $this->aktualisiere(false, false); @@ -308,9 +315,14 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell if ($statusLesen) { $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'); - $ladefreigabe = $this->ReadAttributeBoolean('ZustandLadefreigabe'); + $ladefreigabe = $this->ReadAttributeBoolean('ZustandLadefreigabe') + && $this->ReadAttributeBoolean('GeraetLadebereit'); $verbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden'); $geladen = $this->ReadAttributeBoolean('ZustandFahrzeugGeladen'); $phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl'); @@ -384,25 +396,33 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell $this->setzeZustand('AenderungMoeglich', $verfuegbar && count($angebot) > 1); $sollleistung = $this->bestimmeWirksameSollleistung($angebot); - $phasenerkennungAktiv = $aktiv - && $ladefreigabe - && $verbunden - && !$geladen - && $phasenzahl === LadestationRegler::PHASEN_UNBEKANNT; $regeluebergangAktiv = $aktiv && $ladefreigabe && $verbunden && !$geladen && $this->ReadAttributeBoolean('RegeluebergangAktiv'); - if ($steuerungAusfuehren && !$phasenerkennungAktiv && !$regeluebergangAktiv) { + if ($steuerungAusfuehren && (!$regeluebergangAktiv || $angebot === [0])) { $this->setzeLadestrom($sollleistung); } $this->setzeStoerung(''); $this->SetStatus(self::STATUS_AKTIV); } 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('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->protokolliere('Geraetekommunikation', $fehler->getMessage()); } @@ -447,7 +467,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell $this->WriteAttributeInteger('LeistungswechselGesperrtBis', 0); $this->WriteAttributeInteger('AusschaltenGesperrtBis', 0); $this->WriteAttributeInteger('EinschaltenGesperrtBis', 0); - } elseif ($bekanntePhasenzahl === LadestationRegler::PHASEN_UNBEKANNT) { + } else { $direktErkanntePhasen = (int) ($rohstatus['Phasenzahl'] ?? LadestationRegler::PHASEN_UNBEKANNT); if (in_array( @@ -455,30 +475,25 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell [LadestationRegler::PHASEN_EIN, LadestationRegler::PHASEN_DREI], true )) { - $bekanntePhasenzahl = $direktErkanntePhasen; + $bekanntePhasenzahl = max($bekanntePhasenzahl, $direktErkanntePhasen); $this->WriteAttributeInteger('PhasenerkennungSeit', 0); } elseif ($rohstatus['Istleistung_W'] > 7500.0) { $bekanntePhasenzahl = LadestationRegler::PHASEN_DREI; $this->WriteAttributeInteger('PhasenerkennungSeit', 0); - } elseif ((bool) $this->GetValue('Aktiv') - && $this->ReadAttributeBoolean('ZustandLadefreigabe') - ) { + } elseif ($bekanntePhasenzahl === LadestationRegler::PHASEN_UNBEKANNT) { $erkennungSeit = $this->ReadAttributeInteger('PhasenerkennungSeit'); - if ($erkennungSeit <= 0) { - $this->sendeLadestromAnGeraet(self::PHASENERKENNUNGSSTROM); - $erkennungSeit = time(); - $this->WriteAttributeInteger('PhasenerkennungSeit', $erkennungSeit); - } elseif ((time() - $erkennungSeit) - >= $this->ReadPropertyInteger('Phasenerkennungszeit') + if ($erkennungSeit > 0 + && $this->ReadAttributeInteger('LetzterGesetzterStrom') === self::PHASENERKENNUNGSSTROM + && (time() - $erkennungSeit) >= $this->ReadPropertyInteger('Phasenerkennungszeit') ) { $bekanntePhasenzahl = LadestationRegler::erkennePhasenzahlAusLeistung( (float) $rohstatus['Istleistung_W'], $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'), $bekanntePhasenzahl ); + if ($status['Phasenzahl'] !== $this->ReadAttributeInteger('ZustandPhasenzahl')) { + // Eine alte Leistungsstufe darf nach einer Phasenkorrektur nicht festgehalten werden. + $this->WriteAttributeInteger('LeistungswechselGesperrtBis', 0); + } if (!$status['FahrzeugVerbunden']) { $this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false); } elseif ($status['Ladestrom_A'] >= 3.5) { @@ -504,6 +523,13 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell foreach ($status as $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 $status */ @@ -573,6 +599,11 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell } if (!$this->ReadAttributeBoolean('ZustandSolarladen') && $angebot !== [0]) { + if ($this->ReadAttributeInteger('ZustandPhasenzahl') === LadestationRegler::PHASEN_UNBEKANNT + && $this->zugeordneteManagerIDs() !== [] + ) { + return 0; + } return $angebot[count($angebot) - 1]; } @@ -594,6 +625,12 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell if (!$stromUnveraendert) { $this->sendeLadestromAnGeraet($strom); } + if ($phasenzahl === LadestationRegler::PHASEN_UNBEKANNT + && $strom === self::PHASENERKENNUNGSSTROM + && $this->ReadAttributeInteger('PhasenerkennungSeit') === 0 + ) { + $this->WriteAttributeInteger('PhasenerkennungSeit', time()); + } if ($leistung === $bisherigeLeistung) { return; } @@ -647,6 +684,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell $strom ); $this->WriteAttributeInteger('LetzterGesetzterStrom', $strom); + $this->WriteAttributeInteger('PhasenerkennungSeit', 0); } /** @@ -729,6 +767,11 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell private function sendeVerbraucherdaten(): void { + if ($this->ReadAttributeInteger('GeraetestatusGueltigBis') <= time() + && (bool) $this->leseZustand('Verfuegbar') + ) { + $this->aktualisiere(false, false, false); + } foreach ($this->zugeordneteManagerIDs() as $managerID) { $daten = $this->baueVerbraucherdaten($managerID); Nachrichtenvertrag::pruefeVerbraucherdaten($daten); @@ -822,6 +865,12 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell 'Einheit' => '', '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->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 210); $this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 220); + $this->RegisterVariableString('Geraetehinweis', 'Geraetehinweis', '', 240); $this->RegisterVariableInteger( 'FahrzeugMaximalstrom', 'Erkannter Fahrzeug-Maximalstrom', @@ -1037,7 +1087,8 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell $this->WriteAttributeFloat('ZustandIstleistung', (float) $wert); break; case 'Stoertext': - $this->WriteAttributeString('ZustandStoertext', (string) $wert); + case 'Geraetehinweis': + $this->WriteAttributeString('Zustand' . $ident, (string) $wert); break; default: throw new LogicException('Unbekannter Zustand: ' . $ident); @@ -1069,8 +1120,8 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell if ($ident === 'Ladestrom' || $ident === 'Istleistung') { return $this->ReadAttributeFloat('Zustand' . $ident); } - if ($ident === 'Stoertext') { - return $this->ReadAttributeString('ZustandStoertext'); + if ($ident === 'Stoertext' || $ident === 'Geraetehinweis') { + return $this->ReadAttributeString('Zustand' . $ident); } if ($ident === 'Leistungsquelle') { return Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN; diff --git a/docs/module/Ladestation-Stand-Alone/README.md b/docs/module/Ladestation-Stand-Alone/README.md index 7af4b03..03edcfd 100644 --- a/docs/module/Ladestation-Stand-Alone/README.md +++ b/docs/module/Ladestation-Stand-Alone/README.md @@ -4,14 +4,14 @@ Das Modul bindet eine einzelne Ladestation direkt an Enelix EMS an. Es liest 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 **Ladestation Gateway**. ## Funktionsumfang - 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, - lokale Freigabe und Umschaltung zwischen Solar- und Normalbetrieb, - 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 -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 - wird entsprechend `State != 1` ausgewertet. +1. Nach erfolgreicher Statusvalidierung gilt das Fahrzeug bei go-e mit + `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 Gemini direkt in Watt und bei Pico von kW in Watt umgerechnet. -3. go-e meldet die drei Phasenstroeme einzeln. Sobald mindestens zwei Phasen - Strom fuehren, wird dreiphasiges Laden direkt erkannt. +3. go-e und Pico koennen die drei Phasenstroeme einzeln melden. Ab 1 A gilt + 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 - Modul zunaechst nur den Mindeststrom von 6 A als Erkennungsstrom vor. Bis zur - abgeschlossenen Erkennung bleibt `Phasenzahl=0` und das EMS-Angebot `[0]`. + Modul `[0,4104] W` an: 6 A mit dem bestehenden Dreiphasenbudget. Eine + 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 angeforderten Erkennungsstrom ausgewertet. Dadurch lassen sich eine und drei - Phasen bereits bei 6 A unterscheiden. Die erkannte Phasenzahl bleibt bis - zum Ausstecken erhalten und kippt waehrend einer Ladepause nicht zurueck. + Phasen bereits bei 6 A unterscheiden. Unter 1265 W bleibt die Anzahl + 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 `684 W/A` dreiphasig berechnet. -7. Ein Ladeende wird nur nach zuvor tatsaechlich gemessener Ladeleistung - erkannt. Eine vom EMS angeordnete Solarpause gilt deshalb nicht als - `FahrzeugGeladen`. +7. Nur ein explizites aktuelles Ladeende (go-e `car=4`) ohne relevante Last + (< 1 A und < 100 W) setzt `FahrzeugGeladen`. Ein einzelner Nullwert und + 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: @@ -82,7 +92,9 @@ stoppt die Ladestation. Die Leistungsstufen entsprechen Enelix 1: `230 W` pro Ampere einphasig und `684 W` pro Ampere dreiphasig. Ohne gueltige Manager-Vorgabe faehrt das Modul 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 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`. | | `FahrzeugVerbunden` | Boolean / Anzeige | Zeigt, ob die API ein verbundenes Fahrzeug meldet. | -| `FahrzeugGeladen` | Boolean / Anzeige | Ergebnis der kompatiblen Enelix-1-Voll-Erkennung. | -| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Ladestrom in A. | +| `FahrzeugGeladen` | Boolean / Anzeige | Aktuell bestaetigtes Ladeende; bei Pico ohne eindeutiges API-Signal immer `false`. | +| `Ladestrom` | Float / Anzeige | Groesster gemessener Phasenstrom, ersatzweise aus Leistung und bekannter Phasenzahl berechnet. | | `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig oder `3` dreiphasig. | 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. | | `Stoerung` | Sammelstatus fuer Konfigurations- und Kommunikationsfehler. | | `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. | | `LeistungsangebotDiagnose` | Aktuelles Leistungsangebot als JSON-Liste in W. | @@ -206,6 +219,7 @@ An den Manager werden neben den gemeinsamen Feldern diese Zustaende gemeldet: - `Ladefreigabe` - `Solarladen` - `Ladefehler` mit Stoertext +- `Geraetehinweis` Eine Sollleistung wird nur angenommen, wenn sie im zuletzt berechneten 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 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 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 Leistungsangebot `[0]`. - 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 - gespeichert. Bei go-e koennen die einzelnen Phasenstroeme die Erkennung - bereits vorher abschliessen. + gespeichert, sofern eine belastbare Leistung gemessen wird. Bei 0 W bleibt + die Anzahl unbekannt; einzelne Phasenstroeme koennen die Erkennung vorziehen. - 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` abgebildet. - `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. `Stoertext` enthaelt den konkreten Kommunikations- oder JSON-Fehler. - Fahrzeug wird nicht erkannt: Rohstatus der Geraete-API kontrollieren. Der - Adapter erwartet bei go-e `car` und `nrg[11]`, bei Pico `State` und - `ActiveChargingPower`. + Adapter erwartet bei go-e `err`, `car` und `nrg[11]`, bei Pico `State`, + `ActiveChargingPower` und die oben beschriebenen Zeitstempel. - Phasenzahl bleibt laenger auf `0`: Waehrend der Erkennungsphase muessen - Ladestation und Fahrzeug den angeforderten Maximalstrom tatsaechlich - freigeben. Bei Pico muss fuer eine sichere Dreiphasenerkennung waehrend der - Erkennung mehr als 7500 W anliegen; go-e kann zusaetzlich anhand der - einzelnen Phasenstroeme erkennen. + Managerbudget, Freigabe und tatsaechliche Stromaufnahme vorliegen. Nullleistung + bleibt unbekannt. Beide Adapter koennen einzelne Phasenstroeme nutzen; ohne + diese Werte wird die Leistung nach einer budgetierten 6-A-Probe ausgewertet. - Manager-Vorgabe wird abgewiesen: aktive Zuordnung im Manager sowie `LeistungsangebotDiagnose` und die Betriebsart kontrollieren. ## 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. Damit werden fuer go-e alt, go-e Gemini und smart-me Pico Statusantworten, URL, HTTP-Methode, Authentisierung und Steueraufrufe ohne reale Hardware 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 API-Antworten. Er prueft alle drei Geraetevarianten, die Mindeststrom-Probe beim Anstecken, stabile Phasen waehrend einer Ladepause, die Schaltsperren, diff --git a/libs/LadestationAdapter.php b/libs/LadestationAdapter.php index 832e78d..ec3a7aa 100644 --- a/libs/LadestationAdapter.php +++ b/libs/LadestationAdapter.php @@ -12,6 +12,7 @@ final class LadestationAdapter public const GO_E_ALT = 1; public const GO_E_GEMINI = 2; public const SMART_ME_PICO = 3; + public const STATUS_MAXIMALALTER = 120; /** * @param array $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); if (!is_array($daten)) { 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.'); } + 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; $status = [ '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'])) { $sollstrom = (int) $daten['amp']; @@ -136,9 +146,9 @@ final class LadestationAdapter ) { $stromfaktor = $geraetetyp === self::GO_E_ALT ? 0.1 : 1.0; $phasenstroeme = [ - (float) $daten['nrg'][4] * $stromfaktor, - (float) $daten['nrg'][5] * $stromfaktor, - (float) $daten['nrg'][6] * $stromfaktor, + self::messwert($daten['nrg'][4], 'go-e L1') * $stromfaktor, + self::messwert($daten['nrg'][5], 'go-e L2') * $stromfaktor, + self::messwert($daten['nrg'][6], 'go-e L3') * $stromfaktor, ]; $status['Iststrom_A'] = max($phasenstroeme); $aktivePhasen = count(array_filter( @@ -154,22 +164,90 @@ final class LadestationAdapter } if ($geraetetyp === self::SMART_ME_PICO) { - if (!isset($daten['State'], $daten['ActiveChargingPower']) - || !is_numeric($daten['State']) - || !is_numeric($daten['ActiveChargingPower']) + $daten = array_change_key_case($daten, CASE_LOWER); + if (!isset($daten['state'], $daten['activechargingpower']) + || !is_numeric($daten['state']) + || !is_numeric($daten['activechargingpower']) ) { throw new RuntimeException('Die Pico-Antwort enthaelt nicht alle Statusfelder.'); } - - return [ - 'Rohstatus' => (int) $daten['State'], - 'Istleistung_W' => round((float) $daten['ActiveChargingPower'] * 1000.0), + $zustand = (int) $daten['state']; + if (!in_array((float) $daten['state'], [1.0, 2.0, 3.0, 4.0, 6.0], true)) { + throw new RuntimeException('Pico nicht betriebsbereit (Status ' . $zustand . ').'); + } + $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.'); } + /** @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 $konfiguration * @return array diff --git a/libs/LadestationRegler.php b/libs/LadestationRegler.php index c3886fa..89afe21 100644 --- a/libs/LadestationRegler.php +++ b/libs/LadestationRegler.php @@ -11,6 +11,7 @@ final class LadestationRegler public const PHASEN_UNBEKANNT = 0; public const PHASEN_EIN = 1; public const PHASEN_DREI = 3; + public const PHASENERKENNUNGSLEISTUNG = 4104; private const LEISTUNG_PRO_AMPERE_EINPHASIG = 230; 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.'); } + if ($leistung < 5.5 * self::LEISTUNG_PRO_AMPERE_EINPHASIG) { + return self::PHASEN_UNBEKANNT; + } $trennleistung = 2 * self::LEISTUNG_PRO_AMPERE_EINPHASIG * $angeforderterStrom; return $leistung > $trennleistung ? self::PHASEN_DREI : self::PHASEN_EIN; } /** - * Die Schwelle und der Zuschlag von 2.5 A stammen aus der Enelix-1-Ladestation. - * Ein Ladeende wird erst nach zuvor gemessener Ladeleistung angenommen, damit - * die Anlaufzeit nach einer neuen Stromvorgabe nicht als volles Fahrzeug gilt. + * Ladepausen sind kein Ladeende. Nur ein expliziter aktueller Geraetestatus + * darf "geladen" melden; alte, aus Niedrigstrom abgeleitete Werte verfallen. + * 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 */ public static function normalisiereStatus( @@ -54,14 +58,17 @@ final class LadestationRegler $geladen = false; if ($verbunden) { - if (in_array($bekanntePhasenzahl, [self::PHASEN_EIN, self::PHASEN_DREI], true)) { - $phasen = $bekanntePhasenzahl; + if ($leistung > 7500.0 || ($warVerbunden && $bekanntePhasenzahl === self::PHASEN_DREI)) { + // Einzelne momentan unbelastete Phasen duerfen das Budget nicht verkleinern. + $phasen = self::PHASEN_DREI; } elseif (isset($rohstatus['Phasenzahl']) && in_array($rohstatus['Phasenzahl'], [self::PHASEN_EIN, self::PHASEN_DREI], true) ) { $phasen = (int) $rohstatus['Phasenzahl']; - } elseif ($leistung > 7500.0) { - $phasen = self::PHASEN_DREI; + } elseif ($warVerbunden + && in_array($bekanntePhasenzahl, [self::PHASEN_EIN, self::PHASEN_DREI], true) + ) { + $phasen = $bekanntePhasenzahl; } $nenner = $phasen === self::PHASEN_DREI @@ -70,12 +77,8 @@ final class LadestationRegler $ladestrom = isset($rohstatus['Iststrom_A']) ? max(0.0, (float) $rohstatus['Iststrom_A']) : ($phasen === self::PHASEN_UNBEKANNT ? 0.0 : $leistung / $nenner); - $ermittelterMaximalstrom = 2.5 + $ladestrom; - $geladen = $warGeladen - || ($warVerbunden - && $ladungErwartet - && $ladeleistungWurdeErkannt - && $ermittelterMaximalstrom < 6.0); + $geladen = ($rohstatus['LadeendeBestaetigt'] ?? false) === true + && $ladestrom < 1.0 && $leistung < 100.0; } if (!$verbunden) { @@ -137,7 +140,8 @@ final class LadestationRegler } 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); @@ -159,6 +163,9 @@ final class LadestationRegler if ($leistung <= 0) { return 0; } + if ($phasenzahl === self::PHASEN_UNBEKANNT) { + return $leistung >= self::PHASENERKENNUNGSLEISTUNG ? 6 : 0; + } $stufen = self::stufenFuerPhasen($phasenzahl); if ($leistung < $stufen[0]) { diff --git a/tests/LadestationAdapterTest.php b/tests/LadestationAdapterTest.php index 4d48235..9887829 100644 --- a/tests/LadestationAdapterTest.php +++ b/tests/LadestationAdapterTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; final class LadestationAdapterTest extends TestCase { + private const JETZT = 1791392400; /** * @dataProvider statusFaelle * @param array $konfiguration @@ -33,7 +34,8 @@ final class LadestationAdapterTest extends TestCase $rohstatus = LadestationAdapter::dekodiereStatus( $geraetetyp, - $transport->statusLesen($geraetetyp, $konfiguration) + $transport->statusLesen($geraetetyp, $konfiguration), + self::JETZT ); self::assertSame($erwarteteUrl, $aufrufe[0]['URL']); @@ -52,6 +54,7 @@ final class LadestationAdapterTest extends TestCase [ 'Rohstatus' => 2, 'Istleistung_W' => 11000.0, + 'LadeendeBestaetigt' => false, 'Sollstrom_A' => 16, 'Maximalstrom_A' => 16, 'Iststrom_A' => 16.0, @@ -66,6 +69,7 @@ final class LadestationAdapterTest extends TestCase [ 'Rohstatus' => 2, 'Istleistung_W' => 2300.0, + 'LadeendeBestaetigt' => false, 'Sollstrom_A' => 16, 'Maximalstrom_A' => 16, 'Iststrom_A' => 9.8, @@ -79,9 +83,12 @@ final class LadestationAdapterTest extends TestCase 'State' => 2, 'ActiveChargingPower' => 11.0, '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), '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']); } + /** @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 * @param array $konfiguration @@ -200,6 +313,7 @@ final class LadestationAdapterTest extends TestCase return json_encode([ 'car' => $status, + 'err' => 0, 'nrg' => $nrg, 'amp' => 16, 'ama' => 16, diff --git a/tests/LadestationReglerTest.php b/tests/LadestationReglerTest.php index 0b766b5..55c1573 100644 --- a/tests/LadestationReglerTest.php +++ b/tests/LadestationReglerTest.php @@ -71,7 +71,7 @@ final class LadestationReglerTest extends TestCase ], false); self::assertSame(LadestationRegler::PHASEN_UNBEKANNT, $status['Phasenzahl']); - self::assertSame([0], LadestationRegler::leistungsangebot( + self::assertSame([0, 4104], LadestationRegler::leistungsangebot( true, true, true, @@ -94,15 +94,15 @@ final class LadestationReglerTest extends TestCase self::assertSame(2, $status['Fahrzeugstatus']); } - public function testGeladenWirdErstNachZuvorGemessenerLadeleistungErkannt(): void + public function testNiedrigstromNachLadungIstKeinBestaetigtesLadeende(): void { $status = LadestationRegler::normalisiereStatus([ 'Rohstatus' => 2, 'Istleistung_W' => 0.0, ], true, false, true, true); - self::assertTrue($status['FahrzeugGeladen']); - self::assertSame(4, $status['Fahrzeugstatus']); + self::assertFalse($status['FahrzeugGeladen']); + self::assertSame(2, $status['Fahrzeugstatus']); } public function testAbsichtlichGestoppteLadungWirdNichtAlsGeladenErkannt(): void @@ -116,15 +116,61 @@ final class LadestationReglerTest extends TestCase self::assertSame(2, $status['Fahrzeugstatus']); } - public function testErkanntesLadeendeBleibtBisZumAbsteckenErhalten(): void + public function testAltesLadeendeOhneAktuelleBestaetigungWirdVerworfen(): void { $status = LadestationRegler::normalisiereStatus([ 'Rohstatus' => 2, 'Istleistung_W' => 0.0, ], true, true, false); - self::assertTrue($status['FahrzeugGeladen']); - self::assertSame(4, $status['Fahrzeugstatus']); + self::assertFalse($status['FahrzeugGeladen']); + 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 diff --git a/tests/LadestationStandAloneRuntimeTest.php b/tests/LadestationStandAloneRuntimeTest.php new file mode 100644 index 0000000..2dc08d0 --- /dev/null +++ b/tests/LadestationStandAloneRuntimeTest.php @@ -0,0 +1,255 @@ +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)); + } +} diff --git a/tests/Support/LadestationRuntime.php b/tests/Support/LadestationRuntime.php new file mode 100644 index 0000000..dbd3212 --- /dev/null +++ b/tests/Support/LadestationRuntime.php @@ -0,0 +1,92 @@ +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; +} diff --git a/tests/Symcon/modules/LadestationStandAlone.php b/tests/Symcon/modules/LadestationStandAlone.php index 395af5d..432ccf4 100644 --- a/tests/Symcon/modules/LadestationStandAlone.php +++ b/tests/Symcon/modules/LadestationStandAlone.php @@ -12,7 +12,7 @@ return static function (TestContext $test): void { $nrg[4 + $index] = $strom; } $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 = [ @@ -37,9 +37,11 @@ return static function (TestContext $test): void { 'Benutzername' => 'api-user', 'Passwort' => $testPasswort, 'Testantwort' => json_encode([ - 'State' => 2, + 'State' => 4, '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), 'Phasenzahl' => 3, '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 - ) use ($phasenerkennung, $anforderungSenden): void { + ) use ($phasenerkennung, $anforderungSenden, $goEAntwort): void { $stationId = $phasenerkennung; $test->assertSame( 0, @@ -471,13 +473,23 @@ return static function (TestContext $test): void { 0, 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( (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)), 'amp=6' - ), 'Nach der Freigabe muss die Mindeststrom-Phasenprobe starten.'); + ), 'Nach Freigabe und Budget muss die Mindeststrom-Phasenprobe starten.'); IPS_Sleep(2100); 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); IPS_RequestAction($stationId, 'Statuszyklus', false);