$konfiguration * @return array */ public static function statusAnfrage(int $geraetetyp, array $konfiguration): array { if ($geraetetyp === self::GO_E_ALT) { return self::anfrage('GET', self::lokaleBasisUrl($konfiguration) . '/mqtt?payload='); } if ($geraetetyp === self::GO_E_GEMINI) { return self::anfrage('GET', self::lokaleBasisUrl($konfiguration) . '/api/status'); } if ($geraetetyp === self::SMART_ME_PICO) { $id = self::pflichtwert($konfiguration, 'GeraeteID'); return self::anfrage( 'GET', 'https://api.smart-me.com/pico/charging/' . rawurlencode($id), $konfiguration ); } throw new InvalidArgumentException('Unbekannter Geraetetyp.'); } /** * @param array $konfiguration * @return list> */ public static function steuerAnfragen( int $geraetetyp, array $konfiguration, int $ladestrom ): array { if ($ladestrom < 0 || $ladestrom > 32 || ($ladestrom > 0 && $ladestrom < 6)) { throw new InvalidArgumentException('Ladestrom muss 0 oder 6 bis 32 A betragen.'); } if ($geraetetyp === self::GO_E_ALT) { $basis = self::lokaleBasisUrl($konfiguration) . '/mqtt?payload='; if ($ladestrom === 0) { return [self::anfrage('GET', $basis . 'alw=0')]; } return [ self::anfrage('GET', $basis . 'alw=1'), self::anfrage('GET', $basis . 'amp=' . $ladestrom), ]; } if ($geraetetyp === self::GO_E_GEMINI) { $basis = self::lokaleBasisUrl($konfiguration) . '/api/set?'; if ($ladestrom === 0) { return [self::anfrage('GET', $basis . 'frc=1&sua=false')]; } return [ self::anfrage('GET', $basis . 'frc=2'), self::anfrage('GET', $basis . 'amp=' . $ladestrom), ]; } if ($geraetetyp === self::SMART_ME_PICO) { $seriennummer = self::pflichtwert($konfiguration, 'Seriennummer'); return [self::anfrage( 'POST', 'https://api.smart-me.com/pico/loadmanagementgroup/current/' . rawurlencode($seriennummer) . '?current=' . ($ladestrom * 1000), $konfiguration )]; } throw new InvalidArgumentException('Unbekannter Geraetetyp.'); } /** * @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, ?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.'); } if ($geraetetyp === self::GO_E_ALT || $geraetetyp === self::GO_E_GEMINI) { if (!isset($daten['car'], $daten['nrg']) || !is_numeric($daten['car']) || !is_array($daten['nrg']) || !isset($daten['nrg'][11]) || !is_numeric($daten['nrg'][11]) ) { 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' => 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']; $freigabeEntzogen = isset($daten['alw']) && $daten['alw'] === false; $erzwungenAus = $geraetetyp === self::GO_E_GEMINI && isset($daten['frc']) && (int) $daten['frc'] === 1; $status['Sollstrom_A'] = $freigabeEntzogen || $erzwungenAus ? 0 : $sollstrom; } if (isset($daten['ama']) && is_numeric($daten['ama'])) { $maximalstrom = (int) $daten['ama']; if ($maximalstrom >= 6 && $maximalstrom <= 32) { $status['Maximalstrom_A'] = $maximalstrom; } } if (isset($daten['nrg'][4], $daten['nrg'][5], $daten['nrg'][6]) && is_numeric($daten['nrg'][4]) && is_numeric($daten['nrg'][5]) && is_numeric($daten['nrg'][6]) ) { $stromfaktor = $geraetetyp === self::GO_E_ALT ? 0.1 : 1.0; $phasenstroeme = [ 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( $phasenstroeme, static fn (float $strom): bool => $strom >= 1.0 )); if ($aktivePhasen > 0) { $status['Phasenzahl'] = $aktivePhasen >= 2 ? 3 : 1; } } return $status; } if ($geraetetyp === self::SMART_ME_PICO) { $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.'); } $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 */ private static function anfrage( string $methode, string $url, array $konfiguration = [] ): array { $anfrage = ['Methode' => $methode, 'URL' => $url]; if (str_starts_with($url, 'https://api.smart-me.com/')) { $anfrage['Benutzername'] = self::pflichtwert($konfiguration, 'Benutzername'); $anfrage['Passwort'] = self::pflichtwert($konfiguration, 'Passwort'); } return $anfrage; } /** @param array $konfiguration */ private static function lokaleBasisUrl(array $konfiguration): string { $adresse = trim(self::pflichtwert($konfiguration, 'Geraeteadresse')); if (str_contains($adresse, '/') || str_contains($adresse, '?')) { throw new InvalidArgumentException('Geraeteadresse muss ein Hostname oder eine IP-Adresse sein.'); } return 'http://' . $adresse; } /** @param array $konfiguration */ private static function pflichtwert(array $konfiguration, string $name): string { $wert = trim($konfiguration[$name] ?? ''); if ($wert === '') { throw new InvalidArgumentException($name . ' darf nicht leer sein.'); } return $wert; } } final class LadestationTransport { /** @var callable(array): string */ private $anfrageAusfuehren; /** @param callable(array): string $anfrageAusfuehren */ public function __construct(callable $anfrageAusfuehren) { $this->anfrageAusfuehren = $anfrageAusfuehren; } /** @param array $konfiguration */ public function statusLesen(int $geraetetyp, array $konfiguration): string { return ($this->anfrageAusfuehren)( LadestationAdapter::statusAnfrage($geraetetyp, $konfiguration) ); } /** @param array $konfiguration */ public function ladestromSetzen( int $geraetetyp, array $konfiguration, int $ladestrom ): void { foreach (LadestationAdapter::steuerAnfragen($geraetetyp, $konfiguration, $ladestrom) as $anfrage) { ($this->anfrageAusfuehren)($anfrage); } } }