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.
325 lines
12 KiB
PHP
325 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Belevo\EnelixEMS\Tests;
|
|
|
|
use Belevo\EnelixEMS\LadestationAdapter;
|
|
use Belevo\EnelixEMS\LadestationTransport;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class LadestationAdapterTest extends TestCase
|
|
{
|
|
private const JETZT = 1791392400;
|
|
/**
|
|
* @dataProvider statusFaelle
|
|
* @param array<string, string> $konfiguration
|
|
* @param array{Rohstatus: int, Istleistung_W: float, Sollstrom_A?: int, Maximalstrom_A?: int, Iststrom_A?: float, Phasenzahl?: int} $erwartet
|
|
*/
|
|
public function testStatusaufrufUndDekodierungFuerAlleGeraete(
|
|
int $geraetetyp,
|
|
array $konfiguration,
|
|
string $antwort,
|
|
string $erwarteteUrl,
|
|
array $erwartet
|
|
): void {
|
|
$aufrufe = [];
|
|
$transport = new LadestationTransport(static function (array $anfrage) use (
|
|
&$aufrufe,
|
|
$antwort
|
|
): string {
|
|
$aufrufe[] = $anfrage;
|
|
return $antwort;
|
|
});
|
|
|
|
$rohstatus = LadestationAdapter::dekodiereStatus(
|
|
$geraetetyp,
|
|
$transport->statusLesen($geraetetyp, $konfiguration),
|
|
self::JETZT
|
|
);
|
|
|
|
self::assertSame($erwarteteUrl, $aufrufe[0]['URL']);
|
|
self::assertSame('GET', $aufrufe[0]['Methode']);
|
|
self::assertSame($erwartet, $rohstatus);
|
|
}
|
|
|
|
/** @return iterable<string, array<mixed>> */
|
|
public function statusFaelle(): iterable
|
|
{
|
|
yield 'go-e alt' => [
|
|
LadestationAdapter::GO_E_ALT,
|
|
$this->lokaleKonfiguration(),
|
|
$this->goEAntwort(2, 1100, [160, 158, 159]),
|
|
'http://192.0.2.10/mqtt?payload=',
|
|
[
|
|
'Rohstatus' => 2,
|
|
'Istleistung_W' => 11000.0,
|
|
'LadeendeBestaetigt' => false,
|
|
'Sollstrom_A' => 16,
|
|
'Maximalstrom_A' => 16,
|
|
'Iststrom_A' => 16.0,
|
|
'Phasenzahl' => 3,
|
|
],
|
|
];
|
|
yield 'go-e Gemini' => [
|
|
LadestationAdapter::GO_E_GEMINI,
|
|
$this->lokaleKonfiguration(),
|
|
$this->goEAntwort(2, 2300, [9.8, 0, 0]),
|
|
'http://192.0.2.10/api/status',
|
|
[
|
|
'Rohstatus' => 2,
|
|
'Istleistung_W' => 2300.0,
|
|
'LadeendeBestaetigt' => false,
|
|
'Sollstrom_A' => 16,
|
|
'Maximalstrom_A' => 16,
|
|
'Iststrom_A' => 9.8,
|
|
'Phasenzahl' => 1,
|
|
],
|
|
];
|
|
yield 'Smart-me Pico' => [
|
|
LadestationAdapter::SMART_ME_PICO,
|
|
$this->picoKonfiguration(),
|
|
json_encode([
|
|
'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, 'LadeendeBestaetigt' => false,
|
|
'Ladebereit' => true, 'GueltigBis' => self::JETZT + 120, 'Geraetehinweis' => ''],
|
|
];
|
|
}
|
|
|
|
public function testGoEAusFreigabeWirdAlsNullSollstromDekodiert(): void
|
|
{
|
|
$daten = json_decode($this->goEAntwort(2, 0, [0, 0, 0]), true, 512, JSON_THROW_ON_ERROR);
|
|
$daten['alw'] = false;
|
|
$daten['frc'] = 1;
|
|
|
|
$status = LadestationAdapter::dekodiereStatus(
|
|
LadestationAdapter::GO_E_GEMINI,
|
|
json_encode($daten, JSON_THROW_ON_ERROR)
|
|
);
|
|
|
|
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
|
|
* @param list<array{Methode: string, URL: string}> $erwarteteAufrufe
|
|
*/
|
|
public function testSteueraufrufeWerdenUeberFakeTransportGeprueft(
|
|
int $geraetetyp,
|
|
array $konfiguration,
|
|
int $strom,
|
|
array $erwarteteAufrufe
|
|
): void {
|
|
$aufrufe = [];
|
|
$transport = new LadestationTransport(static function (array $anfrage) use (&$aufrufe): string {
|
|
unset($anfrage['Benutzername'], $anfrage['Passwort']);
|
|
$aufrufe[] = $anfrage;
|
|
return '{}';
|
|
});
|
|
|
|
$transport->ladestromSetzen($geraetetyp, $konfiguration, $strom);
|
|
|
|
self::assertSame($erwarteteAufrufe, $aufrufe);
|
|
}
|
|
|
|
/** @return iterable<string, array<mixed>> */
|
|
public function steuerFaelle(): iterable
|
|
{
|
|
yield 'go-e alt laden' => [
|
|
LadestationAdapter::GO_E_ALT,
|
|
$this->lokaleKonfiguration(),
|
|
16,
|
|
[
|
|
['Methode' => 'GET', 'URL' => 'http://192.0.2.10/mqtt?payload=alw=1'],
|
|
['Methode' => 'GET', 'URL' => 'http://192.0.2.10/mqtt?payload=amp=16'],
|
|
],
|
|
];
|
|
yield 'go-e Gemini stoppen' => [
|
|
LadestationAdapter::GO_E_GEMINI,
|
|
$this->lokaleKonfiguration(),
|
|
0,
|
|
[[
|
|
'Methode' => 'GET',
|
|
'URL' => 'http://192.0.2.10/api/set?frc=1&sua=false',
|
|
]],
|
|
];
|
|
yield 'Smart-me Pico laden' => [
|
|
LadestationAdapter::SMART_ME_PICO,
|
|
$this->picoKonfiguration(),
|
|
16,
|
|
[[
|
|
'Methode' => 'POST',
|
|
'URL' => 'https://api.smart-me.com/pico/loadmanagementgroup/current/serial-1?current=16000',
|
|
]],
|
|
];
|
|
}
|
|
|
|
public function testPicoAnfragenVerwendenBasicAuth(): void
|
|
{
|
|
$anfrage = LadestationAdapter::statusAnfrage(
|
|
LadestationAdapter::SMART_ME_PICO,
|
|
$this->picoKonfiguration()
|
|
);
|
|
|
|
self::assertSame('api-user', $anfrage['Benutzername']);
|
|
self::assertSame(hash('sha256', __FILE__), $anfrage['Passwort']);
|
|
}
|
|
|
|
/** @return array<string, string> */
|
|
private function lokaleKonfiguration(): array
|
|
{
|
|
return [
|
|
'Geraeteadresse' => '192.0.2.10',
|
|
'GeraeteID' => '',
|
|
'Seriennummer' => '',
|
|
'Benutzername' => '',
|
|
'Passwort' => '',
|
|
];
|
|
}
|
|
|
|
/** @return array<string, string> */
|
|
private function picoKonfiguration(): array
|
|
{
|
|
return [
|
|
'Geraeteadresse' => '',
|
|
'GeraeteID' => 'pico-id',
|
|
'Seriennummer' => 'serial-1',
|
|
'Benutzername' => 'api-user',
|
|
'Passwort' => hash('sha256', __FILE__),
|
|
];
|
|
}
|
|
|
|
/** @param list<int|float> $stroeme */
|
|
private function goEAntwort(int $status, float $leistung, array $stroeme): string
|
|
{
|
|
$nrg = array_fill(0, 12, 0);
|
|
foreach (array_values($stroeme) as $index => $strom) {
|
|
$nrg[4 + $index] = $strom;
|
|
}
|
|
$nrg[11] = $leistung;
|
|
|
|
return json_encode([
|
|
'car' => $status,
|
|
'err' => 0,
|
|
'nrg' => $nrg,
|
|
'amp' => 16,
|
|
'ama' => 16,
|
|
'alw' => true,
|
|
'frc' => 2,
|
|
], JSON_THROW_ON_ERROR);
|
|
}
|
|
}
|