$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> */ 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 $konfiguration * @param list $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> */ 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 */ private function lokaleKonfiguration(): array { return [ 'Geraeteadresse' => '192.0.2.10', 'GeraeteID' => '', 'Seriennummer' => '', 'Benutzername' => '', 'Passwort' => '', ]; } /** @return array */ private function picoKonfiguration(): array { return [ 'Geraeteadresse' => '', 'GeraeteID' => 'pico-id', 'Seriennummer' => 'serial-1', 'Benutzername' => 'api-user', 'Passwort' => hash('sha256', __FILE__), ]; } /** @param list $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); } }