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.
322 lines
13 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|