Files
Enelix-EMS/libs/LadestationAdapter.php
T
dh 5885681669
Tests / test (push) Waiting to run
fix(charging): validate status and budget phase detection
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.
2026-10-07 18:24:47 +00:00

322 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
use RuntimeException;
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
* @return array<string, mixed>
*/
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<string, string> $konfiguration
* @return list<array<string, mixed>>
*/
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<string, string> $konfiguration
* @return array<string, mixed>
*/
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<string, string> $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<string, string> $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, mixed>): string */
private $anfrageAusfuehren;
/** @param callable(array<string, mixed>): string $anfrageAusfuehren */
public function __construct(callable $anfrageAusfuehren)
{
$this->anfrageAusfuehren = $anfrageAusfuehren;
}
/** @param array<string, string> $konfiguration */
public function statusLesen(int $geraetetyp, array $konfiguration): string
{
return ($this->anfrageAusfuehren)(
LadestationAdapter::statusAnfrage($geraetetyp, $konfiguration)
);
}
/** @param array<string, string> $konfiguration */
public function ladestromSetzen(
int $geraetetyp,
array $konfiguration,
int $ladestrom
): void {
foreach (LadestationAdapter::steuerAnfragen($geraetetyp, $konfiguration, $ladestrom) as $anfrage) {
($this->anfrageAusfuehren)($anfrage);
}
}
}