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
+91 -13
View File
@@ -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<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);
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<string, string> $konfiguration
* @return array<string, mixed>
+22 -15
View File
@@ -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<string, int|float|bool>
*/
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]) {