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
+116 -2
View File
@@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase;
final class LadestationAdapterTest extends TestCase
{
private const JETZT = 1791392400;
/**
* @dataProvider statusFaelle
* @param array<string, string> $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<string, string> $konfiguration
@@ -200,6 +313,7 @@ final class LadestationAdapterTest extends TestCase
return json_encode([
'car' => $status,
'err' => 0,
'nrg' => $nrg,
'amp' => 16,
'ama' => 16,