Easee-Gateway und Ladestationsmodul implementieren
Tests / test (push) Successful in 44s

This commit is contained in:
dh
2026-09-22 07:09:59 +00:00
parent d804ba4bc1
commit d03a7fd8fb
25 changed files with 3074 additions and 61 deletions
+11
View File
@@ -0,0 +1,11 @@
# Easee Gateway
Gemeinsames IP-Symcon-Splittermodul fuer ein Easee-Nutzerkonto. Es verwaltet
Anmeldung, Token, Ereignisverbindung und REST-Stromvorgaben fuer mehrere
Ladestationen.
Konfiguration, Betrieb und Fehlerbehandlung:
[Moduldokumentation](../docs/module/Easee-Gateway/README.md)
Kind-Gateway-Vertrag:
[Schnittstellenbeschreibung](../docs/Schnittstelle-Easee-Gateway.md)
+52
View File
@@ -0,0 +1,52 @@
{
"elements": [
{
"type": "CheckBox",
"name": "Active",
"caption": "Gateway aktiv"
},
{
"type": "ValidationTextBox",
"name": "Username",
"caption": "Easee Benutzername"
},
{
"type": "PasswordTextBox",
"name": "Password",
"caption": "Easee Passwort"
},
{
"type": "CheckBox",
"name": "VerifyCertificate",
"caption": "TLS-Zertifikat pruefen"
},
{
"type": "Label",
"caption": "Ein Gateway wird von allen Easee-Ladestationen desselben Kontos gemeinsam verwendet."
}
],
"actions": [
{
"type": "Button",
"caption": "Verbindung neu aufbauen",
"onClick": "IPS_RequestAction($id, \"Reconnect\", false);"
}
],
"status": [
{
"code": 201,
"icon": "error",
"caption": "Easee-Zugangsdaten fehlen"
},
{
"code": 202,
"icon": "error",
"caption": "Easee-Anmeldung fehlgeschlagen"
},
{
"code": 203,
"icon": "error",
"caption": "Easee-Ereignisverbindung fehlgeschlagen"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"id": "{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}",
"name": "EaseeGateway",
"type": 2,
"vendor": "Enelix",
"aliases": [
"Easee Gateway"
],
"parentRequirements": [
"{79827379-F36E-4ADA-8A95-5F8D1DC92FA9}"
],
"childRequirements": [
"{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}"
],
"implemented": [
"{018EF6B5-AB94-40C6-AA53-46943E824ACF}",
"{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}"
],
"prefix": "ENELIXEASEE",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/EaseeGateway"
}
+791
View File
@@ -0,0 +1,791 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/EaseeGatewayProtokoll.php';
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
class EaseeGateway extends IPSModule
{
private const WEBSOCKET_MODULE_ID = '{D68FD31F-0E90-7019-F16C-1949BD3079EF}';
private const SIMPLE_RX_DATA_ID = '{018EF6B5-AB94-40C6-AA53-46943E824ACF}';
private const SIMPLE_TX_DATA_ID = '{79827379-F36E-4ADA-8A95-5F8D1DC92FA9}';
private const CHILD_REQUEST_DATA_ID = '{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}';
private const CHILD_EVENT_DATA_ID = '{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}';
private const SIGNALR_BASE_URL = 'https://streams.easee.com/hubs/chargers';
private const API_BASE_URL = 'https://api.easee.com';
private const RECORD_SEPARATOR = "\x1e";
public function Create(): void
{
parent::Create();
$this->RegisterPropertyBoolean('Active', true);
$this->RegisterPropertyString('Username', '');
$this->RegisterPropertyString('Password', '');
$this->RegisterPropertyBoolean('VerifyCertificate', true);
// Nur fuer den automatisierten IP-Symcon-Funktionstest.
$this->RegisterPropertyBoolean('Testmodus', false);
$this->RegisterAttributeString('ObservationCache', '{}');
$this->RegisterVariableBoolean('Connected', 'Easee verbunden', '~Switch', 10);
$this->RegisterVariableInteger(
'SubscriptionCount',
'Angemeldete Ladestationen',
'',
20
);
$this->RegisterVariableString('LastError', 'Letzter Fehler', '', 30);
$this->RegisterTimer(
'MaintainConnectionTimer',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'MaintainConnection', false);"
);
$this->RegisterTimer(
'TokenRefreshTimer',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'RefreshToken', false);"
);
$this->RequireParent(self::WEBSOCKET_MODULE_ID);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->SetTimerInterval('MaintainConnectionTimer', 0);
$this->SetTimerInterval('TokenRefreshTimer', 0);
$this->SetBuffer('SignalRReady', '0');
$this->SetBuffer('ReceiveBuffer', '');
$this->SetBuffer('Subscriptions', '{}');
$this->SetBuffer('SubscribedThisConnection', '{}');
$this->SetBuffer('PendingInvocations', '{}');
$this->SetValue('SubscriptionCount', 0);
$this->setzeVerbunden(false);
if (!$this->ReadPropertyBoolean('Active')) {
$this->SetStatus(104);
return;
}
if ($this->ReadPropertyBoolean('Testmodus')) {
$this->SetStatus(102);
$this->setzeLetztenFehler('');
$this->setzeVerbunden(true);
return;
}
if (
trim($this->ReadPropertyString('Username')) === ''
|| $this->ReadPropertyString('Password') === ''
) {
$this->SetStatus(201);
$this->setzeLetztenFehler('Easee-Benutzername oder Passwort fehlt.');
return;
}
if (!$this->stelleWebSocketParentSicher()) {
$this->SetStatus(203);
return;
}
$this->SetTimerInterval('MaintainConnectionTimer', 10000);
$this->SetTimerInterval('TokenRefreshTimer', 1800000);
if (!$this->erneuereZugangsdaten(false)) {
$this->SetStatus(202);
return;
}
$this->verbindeSignalR();
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'MaintainConnection':
$this->pflegeVerbindung();
return;
case 'RefreshToken':
if ($this->erneuereZugangsdaten(false)) {
$this->verbindeSignalR();
}
return;
case 'Reconnect':
$this->SetBuffer('AccessToken', '');
$this->SetBuffer('RefreshToken', '');
if (
$this->ReadPropertyBoolean('Testmodus')
|| $this->erneuereZugangsdaten(false)
) {
$this->verbindeSignalR();
}
return;
case 'TestObservation':
if (!$this->ReadPropertyBoolean('Testmodus') || !is_string($wert)) {
throw new InvalidArgumentException('TestObservation ist nur im Testmodus zulaessig.');
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException('TestObservation erwartet ein JSON-Objekt.');
}
$this->veroeffentlicheBeobachtung(
EaseeGatewayProtokoll::seriennummer((string) ($daten['serialNumber'] ?? '')),
(int) ($daten['id'] ?? 0),
$daten['value'] ?? null
);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
public function GetConfigurationForParent(): string
{
return json_encode([
'Active' => $this->ReadPropertyBoolean('Active')
&& !$this->ReadPropertyBoolean('Testmodus'),
'URL' => $this->GetBuffer('WebSocketURL'),
'VerifyCertificate' => $this->ReadPropertyBoolean('VerifyCertificate'),
'Headers' => '[]',
], JSON_THROW_ON_ERROR);
}
public function ReceiveData($jsonString): void
{
$paket = json_decode((string) $jsonString, true);
if (!is_array($paket) || !isset($paket['Buffer'])) {
return;
}
$this->SetBuffer('LastReceive', (string) time());
$puffer = $this->GetBuffer('ReceiveBuffer') . (string) $paket['Buffer'];
$rahmen = explode(self::RECORD_SEPARATOR, $puffer);
$this->SetBuffer('ReceiveBuffer', (string) array_pop($rahmen));
foreach ($rahmen as $eintrag) {
if ($eintrag !== '') {
$this->verarbeiteSignalRRahmen($eintrag);
}
}
}
public function ForwardData($jsonString): string
{
$paket = json_decode((string) $jsonString, true);
if (!is_array($paket) || !isset($paket['Buffer'])) {
return json_encode([
'success' => false,
'error' => 'Ungueltiges Datenpaket.',
], JSON_THROW_ON_ERROR);
}
return $this->ProcessStationRequest((string) $paket['Buffer']);
}
public function ProcessStationRequest($jsonString): string
{
$anfrage = json_decode((string) $jsonString, true);
if (!is_array($anfrage) || !isset($anfrage['action'])) {
return json_encode([
'success' => false,
'error' => 'Ungueltige Gateway-Anfrage.',
], JSON_THROW_ON_ERROR);
}
$seriennummer = EaseeGatewayProtokoll::seriennummer(
(string) ($anfrage['serialNumber'] ?? '')
);
switch ($anfrage['action']) {
case 'Subscribe':
case 'GetState':
if ($seriennummer === '') {
return json_encode([
'success' => false,
'error' => 'Seriennummer fehlt.',
], JSON_THROW_ON_ERROR);
}
$this->registriereAbonnement($seriennummer);
return $this->erstelleStatusantwort($seriennummer);
case 'SetDynamicChargerCurrent':
return json_encode($this->setzeDynamischenLadestrom(
$seriennummer,
(float) ($anfrage['amps'] ?? -1)
), JSON_THROW_ON_ERROR);
}
return json_encode([
'success' => false,
'error' => 'Unbekannte Gateway-Aktion.',
], JSON_THROW_ON_ERROR);
}
private function stelleWebSocketParentSicher(): bool
{
$instanz = IPS_GetInstance($this->InstanceID);
$parentID = (int) $instanz['ConnectionID'];
if ($parentID <= 0 && !$this->RequireParent(self::WEBSOCKET_MODULE_ID)) {
$this->setzeLetztenFehler('WebSocket-Client konnte nicht erstellt werden.');
return false;
}
$instanz = IPS_GetInstance($this->InstanceID);
$parentID = (int) $instanz['ConnectionID'];
if ($parentID <= 0 || !IPS_InstanceExists($parentID)) {
$this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.');
return false;
}
$parent = IPS_GetInstance($parentID);
if ($parent['ModuleInfo']['ModuleID'] !== self::WEBSOCKET_MODULE_ID) {
$this->setzeLetztenFehler('Ungueltige WebSocket-Schnittstelle.');
return false;
}
return true;
}
private function pflegeVerbindung(): void
{
if (!$this->ReadPropertyBoolean('Active') || $this->ReadPropertyBoolean('Testmodus')) {
return;
}
$jetzt = time();
$letzteAushandlung = (int) $this->GetBuffer('LastNegotiation');
$letzterEmpfang = (int) $this->GetBuffer('LastReceive');
$bereit = $this->GetBuffer('SignalRReady') === '1';
if ($bereit) {
$this->sendeSignalRRahmen(['type' => 6]);
if ($letzterEmpfang > 0 && ($jetzt - $letzterEmpfang) <= 90) {
return;
}
$this->SetBuffer('SignalRReady', '0');
$this->setzeVerbunden(false);
}
if (($jetzt - $letzteAushandlung) >= 45) {
$this->verbindeSignalR();
return;
}
$this->sendeHandshake();
}
private function verbindeSignalR(): bool
{
if ($this->ReadPropertyBoolean('Testmodus')) {
$this->SetStatus(102);
$this->setzeVerbunden(true);
return true;
}
if (!$this->stelleAccessTokenSicher()) {
$this->SetStatus(202);
return false;
}
$this->SetBuffer('LastNegotiation', (string) time());
$token = $this->GetBuffer('AccessToken');
$antwort = $this->httpAnfrage(
'POST',
self::SIGNALR_BASE_URL . '/negotiate?negotiateVersion=1',
'',
$token
);
if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) {
$token = $this->GetBuffer('AccessToken');
$antwort = $this->httpAnfrage(
'POST',
self::SIGNALR_BASE_URL . '/negotiate?negotiateVersion=1',
'',
$token
);
}
if (!$antwort['success']) {
$this->SetStatus(203);
$this->setzeLetztenFehler(
'Easee-Ereignisverbindung fehlgeschlagen: HTTP '
. $antwort['httpCode']
. ($antwort['error'] !== '' ? ' / ' . $antwort['error'] : '')
);
return false;
}
$daten = json_decode($antwort['body'], true);
$verbindungstoken = is_array($daten)
? (string) ($daten['connectionToken'] ?? $daten['connectionId'] ?? '')
: '';
if ($verbindungstoken === '') {
$this->SetStatus(203);
$this->setzeLetztenFehler('Easee liefert kein Verbindungstoken.');
return false;
}
$url = 'wss://streams.easee.com/hubs/chargers?id='
. rawurlencode($verbindungstoken)
. '&access_token=' . rawurlencode($token);
$this->SetBuffer('WebSocketURL', $url);
$this->SetBuffer('SignalRReady', '0');
$this->SetBuffer('ReceiveBuffer', '');
$this->SetBuffer('SubscribedThisConnection', '{}');
$this->SetBuffer('PendingInvocations', '{}');
$this->WriteAttributeString('ObservationCache', '{}');
$this->setzeVerbunden(false);
if (!$this->konfiguriereWebSocketParent($url)) {
$this->SetStatus(203);
return false;
}
$this->sendeHandshake();
return true;
}
private function konfiguriereWebSocketParent(string $url): bool
{
$parentID = (int) IPS_GetInstance($this->InstanceID)['ConnectionID'];
if ($parentID <= 0) {
$this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.');
return false;
}
IPS_SetProperty($parentID, 'Active', true);
IPS_SetProperty($parentID, 'URL', $url);
IPS_SetProperty(
$parentID,
'VerifyCertificate',
$this->ReadPropertyBoolean('VerifyCertificate')
);
IPS_SetProperty($parentID, 'Headers', '[]');
IPS_ApplyChanges($parentID);
return true;
}
private function sendeHandshake(): void
{
$this->sendeRohdaten(
json_encode(['protocol' => 'json', 'version' => 1], JSON_THROW_ON_ERROR)
. self::RECORD_SEPARATOR
);
}
/** @param array<string, mixed> $rahmen */
private function sendeSignalRRahmen(array $rahmen): void
{
$this->sendeRohdaten(
json_encode($rahmen, JSON_THROW_ON_ERROR) . self::RECORD_SEPARATOR
);
}
private function sendeRohdaten(string $nutzdaten): void
{
if ($this->ReadPropertyBoolean('Testmodus')) {
return;
}
$this->SendDataToParent(json_encode([
'DataID' => self::SIMPLE_TX_DATA_ID,
'Buffer' => $nutzdaten,
], JSON_THROW_ON_ERROR));
}
private function verarbeiteSignalRRahmen(string $rahmen): void
{
if ($rahmen === '{}') {
$this->SetBuffer('SignalRReady', '1');
$this->SetStatus(102);
$this->setzeLetztenFehler('');
$this->setzeVerbunden(true);
$this->sendeAlleAbonnements();
return;
}
$nachricht = json_decode($rahmen, true);
if (!is_array($nachricht)) {
$this->setzeLetztenFehler('Ungueltige Easee-Ereignisnachricht.');
return;
}
if (isset($nachricht['error'])) {
$this->SetStatus(203);
$this->setzeLetztenFehler('Easee-Ereignisfehler: ' . $nachricht['error']);
return;
}
$typ = (int) ($nachricht['type'] ?? 0);
if ($typ === 6) {
return;
}
if ($typ === 7) {
$this->SetBuffer('SignalRReady', '0');
$this->setzeVerbunden(false);
$this->setzeLetztenFehler('Easee-Ereignisverbindung wurde beendet.');
return;
}
$seriennummer = '';
if ($typ === 3 && isset($nachricht['invocationId'])) {
$offen = $this->lesePufferArray('PendingInvocations');
$aufrufID = (string) $nachricht['invocationId'];
$seriennummer = (string) ($offen[$aufrufID] ?? '');
unset($offen[$aufrufID]);
$this->SetBuffer(
'PendingInvocations',
json_encode($offen, JSON_THROW_ON_ERROR)
);
}
foreach (['arguments', 'result'] as $feld) {
if (!isset($nachricht[$feld])) {
continue;
}
$abonnements = array_keys($this->lesePufferArray('Subscriptions'));
foreach (EaseeGatewayProtokoll::extrahiereBeobachtungen(
$nachricht[$feld],
$abonnements,
$seriennummer
) as $beobachtung) {
$this->veroeffentlicheBeobachtung(
$beobachtung['Seriennummer'],
$beobachtung['ID'],
$beobachtung['Wert']
);
}
}
}
private function registriereAbonnement(string $seriennummer): void
{
$abonnements = $this->lesePufferArray('Subscriptions');
if (!isset($abonnements[$seriennummer])) {
$abonnements[$seriennummer] = true;
$this->SetBuffer(
'Subscriptions',
json_encode($abonnements, JSON_THROW_ON_ERROR)
);
$this->SetValue('SubscriptionCount', count($abonnements));
}
if ($this->GetBuffer('SignalRReady') === '1') {
$this->sendeAbonnement($seriennummer);
}
}
private function sendeAlleAbonnements(): void
{
foreach (array_keys($this->lesePufferArray('Subscriptions')) as $seriennummer) {
$this->sendeAbonnement((string) $seriennummer);
}
}
private function sendeAbonnement(string $seriennummer): void
{
$gesendet = $this->lesePufferArray('SubscribedThisConnection');
if (isset($gesendet[$seriennummer])) {
return;
}
$aufrufID = (string) (((int) $this->GetBuffer('InvocationID')) + 1);
$this->SetBuffer('InvocationID', $aufrufID);
$offen = $this->lesePufferArray('PendingInvocations');
$offen[$aufrufID] = $seriennummer;
$this->SetBuffer(
'PendingInvocations',
json_encode($offen, JSON_THROW_ON_ERROR)
);
$this->sendeSignalRRahmen([
'type' => 1,
'invocationId' => $aufrufID,
'target' => 'SubscribeWithCurrentState',
'arguments' => [$seriennummer, true],
]);
$gesendet[$seriennummer] = true;
$this->SetBuffer(
'SubscribedThisConnection',
json_encode($gesendet, JSON_THROW_ON_ERROR)
);
}
private function veroeffentlicheBeobachtung(
string $seriennummer,
int $id,
$wert
): void {
if (
$seriennummer === ''
|| !in_array($id, EaseeGatewayProtokoll::BEOBACHTUNGEN, true)
) {
return;
}
$cache = $this->leseAttributArray('ObservationCache');
if (!isset($cache[$seriennummer]) || !is_array($cache[$seriennummer])) {
$cache[$seriennummer] = [];
}
if ($id === 109 && (int) $wert === 1) {
foreach ([110, 120, 182, 183, 184, 185] as $sessionID) {
$cache[$seriennummer][(string) $sessionID] = 0;
}
}
$cache[$seriennummer][(string) $id] = $wert;
$cache[$seriennummer]['updated'] = time();
$this->WriteAttributeString(
'ObservationCache',
json_encode($cache, JSON_THROW_ON_ERROR)
);
$this->SendDataToChildren(json_encode([
'DataID' => self::CHILD_EVENT_DATA_ID,
'Buffer' => json_encode([
'type' => 'Observation',
'serialNumber' => $seriennummer,
'id' => $id,
'value' => $wert,
'timestamp' => time(),
], JSON_THROW_ON_ERROR),
], JSON_THROW_ON_ERROR));
}
private function erstelleStatusantwort(string $seriennummer): string
{
$cache = $this->leseAttributArray('ObservationCache');
return json_encode([
'success' => true,
'connected' => $this->GetBuffer('SignalRReady') === '1'
|| $this->ReadPropertyBoolean('Testmodus'),
'state' => $cache[$seriennummer] ?? [],
], JSON_THROW_ON_ERROR);
}
/** @return array<string, mixed> */
private function setzeDynamischenLadestrom(string $seriennummer, float $ampere): array
{
if ($seriennummer === '') {
return ['success' => false, 'error' => 'Seriennummer fehlt.'];
}
if (
!is_finite($ampere)
|| floor($ampere) !== $ampere
|| $ampere < 0
|| $ampere > 32
|| ($ampere > 0 && $ampere < 6)
) {
return [
'success' => false,
'error' => 'Strom muss 0 A oder eine ganze Zahl zwischen 6 und 32 A sein.',
];
}
if ($this->ReadPropertyBoolean('Testmodus')) {
$this->SetBuffer('LetzterTestbefehl', json_encode([
'serialNumber' => $seriennummer,
'amps' => $ampere,
], JSON_THROW_ON_ERROR));
return ['success' => true, 'httpCode' => 200, 'body' => '{}'];
}
return $this->autorisierteApiAnfrage(
'POST',
'/api/chargers/' . rawurlencode($seriennummer)
. '/commands/set_dynamic_charger_current',
json_encode([
'amps' => (int) round($ampere),
'minutes' => 0,
], JSON_THROW_ON_ERROR)
);
}
/** @return array<string, mixed> */
private function autorisierteApiAnfrage(
string $methode,
string $pfad,
string $inhalt
): array {
if (!$this->stelleAccessTokenSicher()) {
return ['success' => false, 'error' => 'Kein Easee-Access-Token.'];
}
$antwort = $this->httpAnfrage(
$methode,
self::API_BASE_URL . $pfad,
$inhalt,
$this->GetBuffer('AccessToken')
);
if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) {
$antwort = $this->httpAnfrage(
$methode,
self::API_BASE_URL . $pfad,
$inhalt,
$this->GetBuffer('AccessToken')
);
}
if (!$antwort['success']) {
return [
'success' => false,
'error' => $antwort['error'] !== ''
? $antwort['error']
: 'Easee-HTTP-Fehler ' . $antwort['httpCode'] . '.',
'httpCode' => $antwort['httpCode'],
];
}
return [
'success' => true,
'httpCode' => $antwort['httpCode'],
'body' => $antwort['body'],
];
}
private function erneuereZugangsdaten(bool $neuVerbinden = true): bool
{
$tokenpaar = null;
$refreshToken = $this->GetBuffer('RefreshToken');
if ($refreshToken !== '') {
$tokenpaar = $this->fordereTokenpaarAn(
self::API_BASE_URL . '/api/accounts/refresh_token',
['refreshToken' => $refreshToken]
);
}
if ($tokenpaar === null) {
$tokenpaar = $this->fordereTokenpaarAn(
self::API_BASE_URL . '/api/accounts/login',
[
'userName' => $this->ReadPropertyString('Username'),
'password' => $this->ReadPropertyString('Password'),
]
);
}
if ($tokenpaar === null || !isset($tokenpaar['accessToken'])) {
$this->setzeLetztenFehler('Easee-Anmeldung oder Token-Erneuerung fehlgeschlagen.');
$this->SetStatus(202);
return false;
}
$this->SetBuffer('AccessToken', (string) $tokenpaar['accessToken']);
if (isset($tokenpaar['refreshToken'])) {
$this->SetBuffer('RefreshToken', (string) $tokenpaar['refreshToken']);
}
if ($neuVerbinden) {
return $this->verbindeSignalR();
}
return true;
}
private function stelleAccessTokenSicher(): bool
{
return $this->GetBuffer('AccessToken') !== ''
|| $this->erneuereZugangsdaten(false);
}
/** @param array<string, string> $nutzdaten
* @return array<string, mixed>|null
*/
private function fordereTokenpaarAn(string $url, array $nutzdaten): ?array
{
$antwort = $this->httpAnfrage(
'POST',
$url,
json_encode($nutzdaten, JSON_THROW_ON_ERROR)
);
if (!$antwort['success']) {
return null;
}
$daten = json_decode($antwort['body'], true);
return is_array($daten) ? $daten : null;
}
/**
* @return array{success: bool, body: string, error: string, httpCode: int}
*/
private function httpAnfrage(
string $methode,
string $url,
string $inhalt = '',
string $bearerToken = ''
): array {
$kopf = ['Accept: application/json', 'Content-Type: application/json'];
if ($bearerToken !== '') {
$kopf[] = 'Authorization: Bearer ' . $bearerToken;
}
$curl = curl_init($url);
if ($curl === false) {
return [
'success' => false,
'body' => '',
'error' => 'HTTP-Anfrage konnte nicht initialisiert werden.',
'httpCode' => 0,
];
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $methode,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 30,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HTTPHEADER => $kopf,
CURLOPT_POSTFIELDS => $inhalt,
CURLOPT_SSL_VERIFYPEER => $this->ReadPropertyBoolean('VerifyCertificate'),
CURLOPT_SSL_VERIFYHOST => $this->ReadPropertyBoolean('VerifyCertificate') ? 2 : 0,
]);
$antwort = curl_exec($curl);
$fehler = curl_error($curl);
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
return [
'success' => $antwort !== false
&& $fehler === ''
&& $httpStatus >= 200
&& $httpStatus < 300,
'body' => $antwort === false ? '' : (string) $antwort,
'error' => $fehler,
'httpCode' => $httpStatus,
];
}
private function setzeVerbunden(bool $verbunden): void
{
$this->SetValue('Connected', $verbunden);
$this->SendDataToChildren(json_encode([
'DataID' => self::CHILD_EVENT_DATA_ID,
'Buffer' => json_encode([
'type' => 'GatewayStatus',
'connected' => $verbunden,
], JSON_THROW_ON_ERROR),
], JSON_THROW_ON_ERROR));
}
private function setzeLetztenFehler(string $nachricht): void
{
$this->SetValue('LastError', $nachricht);
}
/** @return array<string, mixed> */
private function leseAttributArray(string $name): array
{
$wert = json_decode($this->ReadAttributeString($name), true);
return is_array($wert) ? $wert : [];
}
/** @return array<string, mixed> */
private function lesePufferArray(string $name): array
{
$wert = json_decode($this->GetBuffer($name), true);
return is_array($wert) ? $wert : [];
}
}
+11
View File
@@ -0,0 +1,11 @@
# Ladestation Gateway
Eventbasiertes Enelix-EMS-Verbrauchermodul fuer eine Easee-Ladestation. Das
Modul liest Fahrzeugstatus und aktive 1-/3-Phasenladung direkt aus Easee
Observations und sendet Stromvorgaben ueber das verbundene Easee Gateway.
Konfiguration, Betriebsarten und Tests:
[Moduldokumentation](../docs/module/Ladestation-Gateway/README.md)
Gateway-Vertrag:
[Schnittstellenbeschreibung](../docs/Schnittstelle-Easee-Gateway.md)
+116
View File
@@ -0,0 +1,116 @@
{
"elements": [
{
"type": "Select",
"name": "Betriebsmodus",
"caption": "Variante",
"options": [
{
"caption": "Easee",
"value": 0
},
{
"caption": "Easee - Nur Solarladen",
"value": 1
}
]
},
{
"type": "ValidationTextBox",
"name": "Ladestationskennung",
"caption": "Easee Seriennummer"
},
{
"type": "NumberSpinner",
"name": "MaximalerLadestrom",
"caption": "Maximaler Ladestrom",
"minimum": 6,
"maximum": 32,
"suffix": " A"
},
{
"type": "CheckBox",
"name": "Ladefreigabe",
"caption": "Ladefreigabe beim Start"
},
{
"type": "CheckBox",
"name": "Solarladen",
"caption": "Solarladen beim Start"
},
{
"type": "NumberSpinner",
"name": "PrioritaetPV",
"caption": "Prioritaet PV",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "PrioritaetPeak",
"caption": "Prioritaet Peak",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Meldeintervall",
"caption": "Meldeintervall",
"minimum": 1,
"suffix": " s"
},
{
"type": "NumberSpinner",
"name": "VorgabeTimeout",
"caption": "Vorgabe-Timeout",
"minimum": 1,
"suffix": " s"
},
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Einstellungen in Visualisierung"
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Diagnoseprotokoll aktivieren"
},
{
"type": "Label",
"caption": "Easee-Zugangsdaten werden ausschliesslich im verbundenen Easee Gateway gespeichert."
}
],
"actions": [
{
"type": "Button",
"caption": "Status neu anfordern",
"onClick": "IPS_RequestAction($id, \"StatusAnfordern\", false);"
}
],
"status": [
{
"code": 201,
"icon": "error",
"caption": "Konfiguration ungueltig"
},
{
"code": 202,
"icon": "error",
"caption": "Easee Gateway nicht verbunden"
},
{
"code": 203,
"icon": "inactive",
"caption": "Warte auf Easee-Status"
},
{
"code": 204,
"icon": "error",
"caption": "Easee Ladestation meldet Fehler"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"id": "{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}",
"name": "LadestationGateway",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Ladestation Gateway"
],
"parentRequirements": [
"{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}"
],
"childRequirements": [],
"implemented": [
"{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}"
],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/LadestationGateway"
}
+985
View File
@@ -0,0 +1,985 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/LadestationRegler.php';
require_once __DIR__ . '/../libs/EaseeLadestatus.php';
require_once __DIR__ . '/../libs/EaseeGatewayProtokoll.php';
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
use Belevo\EnelixEMS\EaseeLadestatus;
use Belevo\EnelixEMS\LadestationRegler;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
{
use VerbraucherBasisTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const EASEE_GATEWAY_MODULE_ID = '{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}';
private const GATEWAY_REQUEST_DATA_ID = '{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}';
private const STATUS_AKTIV = 102;
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
private const STATUS_GATEWAY_GETRENNT = 202;
private const STATUS_WARTET_AUF_DATEN = 203;
private const STATUS_LADEFEHLER = 204;
/** @var list<string> */
private const DIAGNOSE_VARIABLEN = [
'Istleistung',
'Leistungsquelle',
'Sollleistung',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
'Stoertext',
'GatewayVerbunden',
'ApiMaximalstrom',
'LetzterGeraetebefehl',
'LeistungsangebotDiagnose',
];
public function Create(): void
{
parent::Create();
$this->registriereVerbraucherBasis();
$this->RegisterPropertyInteger('Betriebsmodus', EaseeLadestatus::BETRIEBSMODUS_EASEE);
$this->RegisterPropertyString('Ladestationskennung', '');
$this->RegisterPropertyInteger('MaximalerLadestrom', 16);
$this->RegisterPropertyBoolean('Ladefreigabe', false);
$this->RegisterPropertyBoolean('Solarladen', true);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
$this->RegisterVariableBoolean(
'FahrzeugVerbunden',
'Fahrzeug verbunden',
'~Switch',
100
);
$this->RegisterVariableBoolean(
'FahrzeugGeladen',
'Fahrzeug geladen',
'~Switch',
110
);
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 120);
$this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 130);
$this->RegisterVariableInteger('Phasenzahl', 'Phasenzahl', '', 140);
$this->RegisterAttributeBoolean('InitialwerteGesetzt', false);
$this->RegisterAttributeBoolean('KonfigurationLadefreigabe', false);
$this->RegisterAttributeBoolean('KonfigurationSolarladen', true);
$this->RegisterAttributeBoolean('ZustandLadefreigabe', false);
$this->RegisterAttributeBoolean('ZustandSolarladen', true);
$this->RegisterAttributeBoolean('GatewayVerbunden', false);
$this->RegisterAttributeString('EaseeBeobachtungen', '{}');
$this->RegisterAttributeInteger('LetzteBeobachtung', 0);
$this->RegisterAttributeInteger('ApiMaximalstrom', 16);
$this->RegisterAttributeBoolean('ZustandFahrzeugVerbunden', false);
$this->RegisterAttributeBoolean('ZustandFahrzeugGeladen', false);
$this->RegisterAttributeInteger('ZustandFahrzeugstatus', 0);
$this->RegisterAttributeFloat('ZustandLadestrom', 0.0);
$this->RegisterAttributeInteger('ZustandPhasenzahl', 0);
$this->RegisterAttributeFloat('ZustandIstleistung', 0.0);
$this->RegisterAttributeInteger('ZustandSollleistung', 0);
$this->RegisterAttributeBoolean('ZustandSollwertGueltig', false);
$this->RegisterAttributeBoolean('ZustandVerfuegbar', false);
$this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false);
$this->RegisterAttributeBoolean('ZustandStoerung', false);
$this->RegisterAttributeString('ZustandStoertext', '');
$this->RegisterAttributeString('Leistungsangebot', '[0]');
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
$this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzterGesetzterStrom', -1);
$this->RegisterAttributeString('LetzterGeraetebefehl', '');
$this->RegisterTimer(
'Meldezyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
);
$this->RegisterTimer(
'RueckmeldungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
);
$this->RegisterTimer(
'VorgabeTimeout',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', false);"
);
$this->ConnectParent(self::EASEE_GATEWAY_MODULE_ID);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->initialisiereLokaleEinstellungen();
$this->aktualisiereVariablen();
$this->WriteAttributeBoolean('GatewayVerbunden', false);
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->deaktiviereTimer();
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->SetTimerInterval(
'Meldezyklus',
$this->ReadPropertyInteger('Meldeintervall') * 1000
);
$this->statusAnfordern();
$this->aktualisiere(true);
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false);
}
$this->aktualisiere(true);
return;
case 'Ladefreigabe':
$this->WriteAttributeBoolean('ZustandLadefreigabe', (bool) $wert);
$this->setzeSichtbareVariable('Ladefreigabe', (bool) $wert);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'Solarladen':
$solarladen = EaseeLadestatus::solarladenErzwungen(
$this->ReadPropertyInteger('Betriebsmodus')
) ? true : (bool) $wert;
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
$this->setzeSichtbareVariable('Solarladen', $solarladen);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'StatusAnfordern':
$this->statusAnfordern();
return;
case 'VorgabeTimeout':
$this->SetTimerInterval('VorgabeTimeout', 0);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'Melden':
if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
}
$this->sendeVerbraucherdaten();
return;
case 'ManagerdatenEmpfangen':
if (!is_string($wert)) {
throw new InvalidArgumentException(
'Managerdaten muessen als JSON uebergeben werden.'
);
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException(
'Managerdaten muessen ein JSON-Objekt sein.'
);
}
$this->ManagerdatenEmpfangen($daten);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
public function ReceiveData($jsonString): void
{
$paket = json_decode((string) $jsonString, true);
if (!is_array($paket) || !isset($paket['Buffer'])) {
return;
}
$ereignis = json_decode((string) $paket['Buffer'], true);
if (!is_array($ereignis) || !isset($ereignis['type'])) {
return;
}
if ($ereignis['type'] === 'GatewayStatus') {
$verbunden = (bool) ($ereignis['connected'] ?? false);
$this->WriteAttributeBoolean('GatewayVerbunden', $verbunden);
$this->setzeSichtbareVariable('GatewayVerbunden', $verbunden);
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
if ($verbunden) {
$this->statusAnfordern();
} else {
$this->verwerfeBeobachtungen();
$this->aktualisiere(true);
}
return;
}
$seriennummer = EaseeGatewayProtokoll::seriennummer(
(string) ($ereignis['serialNumber'] ?? '')
);
if (
$ereignis['type'] !== 'Observation'
|| $seriennummer !== $this->seriennummer()
) {
return;
}
$this->speichereBeobachtung(
(int) ($ereignis['id'] ?? 0),
$ereignis['value'] ?? null,
(int) ($ereignis['timestamp'] ?? time())
);
$this->aktualisiere(true);
}
/** @param array<string, mixed> $daten */
public function ManagerdatenEmpfangen(array $daten): void
{
Nachrichtenvertrag::pruefeManagerdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
throw new InvalidArgumentException(
'Managerdaten sind an eine andere Instanz adressiert.'
);
}
$managerID = $daten['Kopf']['AbsenderID'];
if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) {
throw new InvalidArgumentException(
'Der Manager hat diese Ladestation nicht aktiv zugeordnet.'
);
}
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->aktualisiere(false);
$sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) {
if (
(bool) $this->leseZustand('SollwertGueltig')
&& !in_array(
(int) $this->leseZustand('Sollleistung'),
$this->leseLeistungsangebot(),
true
)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
return;
}
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) {
throw new InvalidArgumentException(
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
);
}
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
$this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true);
$this->SetTimerInterval(
'VorgabeTimeout',
$this->ReadPropertyInteger('VorgabeTimeout') * 1000
);
$this->aktualisiere(false);
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
private function statusAnfordern(): void
{
try {
$antwort = $this->gatewayAnfrage([
'action' => 'Subscribe',
'serialNumber' => $this->seriennummer(),
]);
if (!($antwort['success'] ?? false)) {
throw new RuntimeException(
(string) ($antwort['error'] ?? 'Easee Gateway nicht erreichbar.')
);
}
$this->WriteAttributeBoolean(
'GatewayVerbunden',
(bool) ($antwort['connected'] ?? false)
);
$this->setzeSichtbareVariable(
'GatewayVerbunden',
$this->ReadAttributeBoolean('GatewayVerbunden')
);
$this->verwerfeBeobachtungen();
if (is_array($antwort['state'] ?? null)) {
$zustand = $antwort['state'];
$zeitpunkt = (int) ($zustand['updated'] ?? time());
foreach (EaseeGatewayProtokoll::BEOBACHTUNGEN as $id) {
if (array_key_exists((string) $id, $zustand)) {
$this->speichereBeobachtung(
$id,
$zustand[(string) $id],
$zeitpunkt
);
}
}
}
$this->aktualisiere(true);
} catch (Throwable $fehler) {
$this->WriteAttributeBoolean('GatewayVerbunden', false);
$this->aktualisiere(true);
$this->protokolliere('Gateway', $fehler->getMessage());
}
}
/** @param array<string, mixed> $anfrage
* @return array<string, mixed>
*/
private function gatewayAnfrage(array $anfrage): array
{
$antwort = $this->SendDataToParent(json_encode([
'DataID' => self::GATEWAY_REQUEST_DATA_ID,
'Buffer' => json_encode($anfrage, JSON_THROW_ON_ERROR),
], JSON_THROW_ON_ERROR));
if (!is_string($antwort) || $antwort === '') {
throw new RuntimeException('Easee Gateway liefert keine Antwort.');
}
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new RuntimeException('Easee Gateway liefert ungueltige Daten.');
}
return $daten;
}
private function speichereBeobachtung(int $id, $wert, int $zeitpunkt): void
{
if (!in_array($id, EaseeGatewayProtokoll::BEOBACHTUNGEN, true)) {
return;
}
$beobachtungen = $this->leseBeobachtungen();
if ($id === 109 && (int) $wert === 1) {
foreach ([110, 120, 182, 183, 184, 185] as $sessionID) {
$beobachtungen[(string) $sessionID] = 0;
}
}
$beobachtungen[(string) $id] = $wert;
$this->WriteAttributeString(
'EaseeBeobachtungen',
json_encode($beobachtungen, JSON_THROW_ON_ERROR)
);
$this->WriteAttributeInteger('LetzteBeobachtung', $zeitpunkt);
}
private function aktualisiere(bool $meldungPlanen): void
{
try {
$status = EaseeLadestatus::ausBeobachtungen(
$this->leseBeobachtungen(),
$this->ReadPropertyInteger('MaximalerLadestrom')
);
foreach ([
'FahrzeugVerbunden',
'FahrzeugGeladen',
'Fahrzeugstatus',
'Phasenzahl',
'Istleistung_W',
'Ladestrom_A',
] as $feld) {
$this->setzeZustand($feld, $status[$feld]);
}
$this->WriteAttributeInteger(
'ApiMaximalstrom',
$status['ApiMaximalstrom_A']
);
$this->setzeSichtbareVariable(
'ApiMaximalstrom',
$status['ApiMaximalstrom_A']
);
$gatewayVerbunden = $this->ReadAttributeBoolean('GatewayVerbunden');
$phaseGueltig = in_array($status['Phasenzahl'], [1, 3], true);
$steuerbar = $gatewayVerbunden
&& $status['StatusGueltig']
&& !$status['Stoerung']
&& (!$status['FahrzeugVerbunden'] || $phaseGueltig);
$solarladen = $this->ReadAttributeBoolean('ZustandSolarladen');
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
=== Nachrichtenvertrag::BETRIEBSART_PEAK;
$angebot = LadestationRegler::leistungsangebot(
(bool) $this->GetValue('Aktiv'),
$this->ReadAttributeBoolean('ZustandLadefreigabe'),
$steuerbar && $status['FahrzeugVerbunden'],
$status['FahrzeugGeladen'],
$phaseGueltig ? $status['Phasenzahl'] : 1,
$status['ApiMaximalstrom_A'],
$solarladen,
$peakbetrieb
);
$verfuegbar = $steuerbar
&& $status['FahrzeugVerbunden']
&& !$status['FahrzeugGeladen']
&& $angebot !== [0];
$angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR);
$this->WriteAttributeString('Leistungsangebot', $angebotJson);
$this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson);
$this->setzeZustand('Verfuegbar', $verfuegbar);
$this->setzeZustand(
'AenderungMoeglich',
$verfuegbar && count($angebot) > 1
);
$stoertext = '';
if (!$gatewayVerbunden) {
$stoertext = 'Easee Gateway ist nicht verbunden.';
$this->SetStatus(self::STATUS_GATEWAY_GETRENNT);
} elseif (!$status['StatusGueltig']) {
$stoertext = 'Warte auf den aktuellen Easee-Ladestatus.';
$this->SetStatus(self::STATUS_WARTET_AUF_DATEN);
} elseif ($status['Stoerung']) {
$stoertext = $status['Stoertext'];
$this->SetStatus(self::STATUS_LADEFEHLER);
} elseif ($status['FahrzeugVerbunden'] && !$phaseGueltig) {
$stoertext = 'Easee meldet keine unterstuetzte aktive Ausgangsphase.';
$this->SetStatus(self::STATUS_WARTET_AUF_DATEN);
} else {
$this->SetStatus(self::STATUS_AKTIV);
}
$this->setzeStoerung($stoertext);
if ($gatewayVerbunden && $status['StatusGueltig']) {
$this->setzeLadestrom(
$this->bestimmeWirksameSollleistung($angebot),
$status['Phasenzahl']
);
} else {
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
}
} catch (Throwable $fehler) {
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage());
$this->SetStatus(self::STATUS_GATEWAY_GETRENNT);
$this->protokolliere('Easee', $fehler->getMessage());
}
if ($meldungPlanen) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
}
/** @param list<int> $angebot */
private function bestimmeWirksameSollleistung(array $angebot): int
{
if ((bool) $this->leseZustand('SollwertGueltig')) {
$sollleistung = (int) $this->leseZustand('Sollleistung');
if (in_array($sollleistung, $angebot, true)) {
return $sollleistung;
}
$this->setzeZustand('SollwertGueltig', false);
}
if (
!$this->ReadAttributeBoolean('ZustandSolarladen')
&& $angebot !== [0]
) {
return $angebot[count($angebot) - 1];
}
return 0;
}
private function setzeLadestrom(int $leistung, int $phasenzahl): void
{
$strom = $leistung === 0
? 0
: LadestationRegler::stromFuerLeistung($leistung, $phasenzahl);
if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) {
return;
}
$antwort = $this->gatewayAnfrage([
'action' => 'SetDynamicChargerCurrent',
'serialNumber' => $this->seriennummer(),
'amps' => $strom,
]);
if (!($antwort['success'] ?? false)) {
throw new RuntimeException(
(string) ($antwort['error'] ?? 'Easee-Stromvorgabe fehlgeschlagen.')
);
}
$befehl = json_encode([
'Aktion' => 'SetDynamicChargerCurrent',
'Seriennummer' => $this->seriennummer(),
'Ampere' => $strom,
], JSON_THROW_ON_ERROR);
$this->WriteAttributeString('LetzterGeraetebefehl', $befehl);
$this->setzeSichtbareVariable('LetzterGeraetebefehl', $befehl);
$this->WriteAttributeInteger('LetzterGesetzterStrom', $strom);
}
private function seriennummer(): string
{
return EaseeGatewayProtokoll::seriennummer(
$this->ReadPropertyString('Ladestationskennung')
);
}
private function verwerfeBeobachtungen(): void
{
$this->WriteAttributeString('EaseeBeobachtungen', '{}');
$this->WriteAttributeInteger('LetzteBeobachtung', 0);
}
/** @return array<string, mixed> */
private function leseBeobachtungen(): array
{
$daten = json_decode($this->ReadAttributeString('EaseeBeobachtungen'), true);
return is_array($daten) ? $daten : [];
}
private function sendeVerbraucherdaten(): void
{
foreach ($this->zugeordneteManagerIDs() as $managerID) {
$daten = $this->baueVerbraucherdaten($managerID);
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
try {
IPS_RequestAction(
$managerID,
'VerbraucherdatenEmpfangen',
json_encode($daten, JSON_THROW_ON_ERROR)
);
} catch (Throwable $fehler) {
$this->protokolliere('Managerkommunikation', $fehler->getMessage());
}
}
}
/** @return array<string, mixed> */
private function baueVerbraucherdaten(int $managerID): array
{
$stoerung = (bool) $this->leseZustand('Stoerung');
return [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $this->InstanceID,
'EmpfaengerID' => $managerID,
'Zeitpunkt' => time(),
],
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN,
'Zustand' => [
[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung')
: null,
'Einheit' => 'W',
],
[
'Kennung' => 'FahrzeugVerbunden',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('FahrzeugVerbunden'),
'Einheit' => '',
],
[
'Kennung' => 'FahrzeugGeladen',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('FahrzeugGeladen'),
'Einheit' => '',
],
[
'Kennung' => 'Fahrzeugstatus',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('Fahrzeugstatus'),
'Einheit' => '',
],
[
'Kennung' => 'Phasenzahl',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('Phasenzahl'),
'Einheit' => '',
],
[
'Kennung' => 'Ladestrom_A',
'Art' => 'Istwert',
'Wert' => (float) $this->leseZustand('Ladestrom'),
'Einheit' => 'A',
],
[
'Kennung' => 'Ladefreigabe',
'Art' => 'Status',
'Wert' => $this->ReadAttributeBoolean('ZustandLadefreigabe'),
'Einheit' => '',
],
[
'Kennung' => 'Solarladen',
'Art' => 'Status',
'Wert' => $this->ReadAttributeBoolean('ZustandSolarladen'),
'Einheit' => '',
],
[
'Kennung' => 'Ladefehler',
'Art' => 'Stoerung',
'Wert' => $stoerung,
'Einheit' => '',
'Text' => (string) $this->leseZustand('Stoertext'),
],
],
];
}
/** @return list<int> */
private function zugeordneteManagerIDs(): array
{
$ergebnis = [];
foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) {
try {
$automatisch = (bool) IPS_GetProperty(
$managerID,
'AutomatischeSuche'
);
$property = $automatisch
? 'AutomatischeVerbraucherZuordnung'
: 'VerbraucherZuordnung';
$zuordnung = $this->leseManagerZuordnung($managerID, $property);
if ($automatisch && $zuordnung === []) {
$zuordnung = $this->leseManagerZuordnung(
$managerID,
'VerbraucherZuordnung'
);
}
} catch (Throwable $fehler) {
continue;
}
foreach ($zuordnung as $eintrag) {
if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) {
continue;
}
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
if ($instanzID === $this->InstanceID) {
$ergebnis[] = (int) $managerID;
break;
}
}
}
return array_values(array_unique($ergebnis));
}
/** @return array<mixed> */
private function leseManagerZuordnung(int $managerID, string $property): array
{
$zuordnung = json_decode(
(string) IPS_GetProperty($managerID, $property),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($zuordnung) ? $zuordnung : [];
}
private function pruefeKonfiguration(): void
{
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException(
$property . ' muss mindestens 0 sein.'
);
}
}
foreach (['Meldeintervall', 'VorgabeTimeout'] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException(
$property . ' muss groesser als 0 sein.'
);
}
}
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
if ($maximalstrom < 6 || $maximalstrom > 32) {
throw new InvalidArgumentException(
'MaximalerLadestrom muss zwischen 6 und 32 A liegen.'
);
}
EaseeLadestatus::solarladenErzwungen(
$this->ReadPropertyInteger('Betriebsmodus')
);
if ($this->seriennummer() === '') {
throw new InvalidArgumentException('Easee Seriennummer fehlt.');
}
}
private function initialisiereLokaleEinstellungen(): void
{
$initialisiert = $this->ReadAttributeBoolean('InitialwerteGesetzt');
$ladefreigabe = $this->ReadPropertyBoolean('Ladefreigabe');
$solarladen = EaseeLadestatus::solarladenErzwungen(
$this->ReadPropertyInteger('Betriebsmodus')
) ? true : $this->ReadPropertyBoolean('Solarladen');
if (
!$initialisiert
|| $ladefreigabe
!== $this->ReadAttributeBoolean('KonfigurationLadefreigabe')
) {
$this->WriteAttributeBoolean('ZustandLadefreigabe', $ladefreigabe);
}
if (
!$initialisiert
|| $solarladen
!== $this->ReadAttributeBoolean('KonfigurationSolarladen')
) {
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
}
$this->WriteAttributeBoolean('KonfigurationLadefreigabe', $ladefreigabe);
$this->WriteAttributeBoolean('KonfigurationSolarladen', $solarladen);
$this->WriteAttributeBoolean('InitialwerteGesetzt', true);
}
private function aktualisiereVariablen(): void
{
if ($this->ReadPropertyBoolean('EinstellungenInVisu')) {
$this->RegisterVariableBoolean(
'Ladefreigabe',
'Ladefreigabe',
'~Switch',
150
);
$this->RegisterVariableBoolean(
'Solarladen',
'Solarladen',
'~Switch',
160
);
$this->EnableAction('Ladefreigabe');
$this->EnableAction('Solarladen');
$this->SetValue(
'Ladefreigabe',
$this->ReadAttributeBoolean('ZustandLadefreigabe')
);
$this->SetValue(
'Solarladen',
$this->ReadAttributeBoolean('ZustandSolarladen')
);
} else {
$this->entferneVariable('Ladefreigabe');
$this->entferneVariable('Solarladen');
}
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->registriereVerbraucherDiagnose();
$this->RegisterVariableBoolean(
'GatewayVerbunden',
'Easee Gateway verbunden',
'~Switch',
200
);
$this->RegisterVariableInteger(
'ApiMaximalstrom',
'API-Maximalstrom',
'',
210
);
$this->RegisterVariableString(
'LetzterGeraetebefehl',
'Letzter Geraetebefehl',
'',
220
);
$this->RegisterVariableString(
'LeistungsangebotDiagnose',
'Leistungsangebot',
'',
230
);
$this->SetValue(
'LeistungsangebotDiagnose',
$this->ReadAttributeString('Leistungsangebot')
);
$this->SetValue(
'LetzterGeraetebefehl',
$this->ReadAttributeString('LetzterGeraetebefehl')
);
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if (!in_array($ident, [
'GatewayVerbunden',
'ApiMaximalstrom',
'LetzterGeraetebefehl',
'LeistungsangebotDiagnose',
], true)) {
$this->setzeSichtbareVariable($ident, $this->leseZustand($ident));
}
}
$this->setzeSichtbareVariable(
'GatewayVerbunden',
$this->ReadAttributeBoolean('GatewayVerbunden')
);
$this->setzeSichtbareVariable(
'ApiMaximalstrom',
$this->ReadAttributeInteger('ApiMaximalstrom')
);
} else {
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$this->entferneVariable($ident);
}
}
}
private function entferneVariable(string $ident): void
{
$id = @$this->GetIDForIdent($ident);
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
$this->UnregisterVariable($ident);
}
}
/** @param mixed $wert */
private function setzeZustand(string $ident, $wert): void
{
switch ($ident) {
case 'FahrzeugVerbunden':
case 'FahrzeugGeladen':
case 'SollwertGueltig':
case 'Verfuegbar':
case 'AenderungMoeglich':
case 'Stoerung':
$this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert);
break;
case 'Fahrzeugstatus':
case 'Phasenzahl':
case 'Sollleistung':
$this->WriteAttributeInteger('Zustand' . $ident, (int) $wert);
break;
case 'Ladestrom_A':
$ident = 'Ladestrom';
$this->WriteAttributeFloat('ZustandLadestrom', (float) $wert);
break;
case 'Istleistung_W':
$ident = 'Istleistung';
$this->WriteAttributeFloat('ZustandIstleistung', (float) $wert);
break;
case 'Stoertext':
$this->WriteAttributeString('ZustandStoertext', (string) $wert);
break;
default:
throw new LogicException('Unbekannter Zustand: ' . $ident);
}
$this->setzeSichtbareVariable($ident, $wert);
if ($ident === 'Istleistung') {
$this->setzeSichtbareVariable(
'Leistungsquelle',
Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN
);
}
}
/** @return mixed */
private function leseZustand(string $ident)
{
if ($ident === 'Ladefreigabe' || $ident === 'Solarladen') {
return $this->ReadAttributeBoolean('Zustand' . $ident);
}
if (in_array($ident, [
'FahrzeugVerbunden',
'FahrzeugGeladen',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
], true)) {
return $this->ReadAttributeBoolean('Zustand' . $ident);
}
if (in_array($ident, [
'Fahrzeugstatus',
'Phasenzahl',
'Sollleistung',
], true)) {
return $this->ReadAttributeInteger('Zustand' . $ident);
}
if ($ident === 'Ladestrom' || $ident === 'Istleistung') {
return $this->ReadAttributeFloat('Zustand' . $ident);
}
if ($ident === 'Stoertext') {
return $this->ReadAttributeString('ZustandStoertext');
}
if ($ident === 'Leistungsquelle') {
return Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN;
}
throw new LogicException('Unbekannter Zustand: ' . $ident);
}
/** @param mixed $wert */
private function setzeSichtbareVariable(string $ident, $wert): void
{
$id = @$this->GetIDForIdent($ident);
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
$this->SetValue($ident, $wert);
}
}
/** @return list<int> */
private function leseLeistungsangebot(): array
{
$angebot = json_decode(
$this->ReadAttributeString('Leistungsangebot'),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($angebot) ? array_map('intval', $angebot) : [0];
}
private function setzeStoerung(string $text): void
{
$this->setzeZustand('Stoerung', $text !== '');
$this->setzeZustand('Stoertext', $text);
}
private function deaktiviereTimer(): void
{
foreach (['Meldezyklus', 'RueckmeldungVerzoegert', 'VorgabeTimeout'] as $timer) {
$this->SetTimerInterval($timer, 0);
}
}
private function protokolliere(string $bereich, string $nachricht): void
{
if ($this->ReadPropertyBoolean('LoggingEin')) {
$this->SendDebug($bereich, $nachricht, 0);
}
}
}
+1
View File
@@ -25,6 +25,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
'{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}',
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}',
];
private const MONATSNAMEN = [
+103
View File
@@ -0,0 +1,103 @@
# Easee-Gateway-Schnittstelle
Stand: 2026-09-22
Die Schnittstelle verbindet das kontobezogene Splittermodul `EaseeGateway`
mit beliebig vielen Kindinstanzen `LadestationGateway`.
## IP-Symcon-Daten-IDs
| Richtung | DataID |
| --- | --- |
| Ladestation an Gateway | `{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}` |
| Gateway an Ladestation | `{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}` |
Die aeussere IP-Symcon-Nachricht enthaelt `DataID` und `Buffer`. `Buffer`
ist wiederum ein JSON-Objekt.
## Anfragen an das Gateway
### Subscribe und GetState
```json
{
"action": "Subscribe",
"serialNumber": "EH123456"
}
```
`GetState` besitzt dasselbe Format. Beide Aktionen registrieren die
Seriennummer und liefern den Cache:
```json
{
"success": true,
"connected": true,
"state": {
"109": 3,
"110": 30,
"120": 11.0,
"updated": 1790053200
}
}
```
### SetDynamicChargerCurrent
```json
{
"action": "SetDynamicChargerCurrent",
"serialNumber": "EH123456",
"amps": 13
}
```
Zulaessig sind `0 A` oder ganzzahlige Werte von `6 A` bis `32 A`.
Das Gateway ruft
`POST /api/chargers/{serialNumber}/commands/set_dynamic_charger_current`
mit `{"amps":13,"minutes":0}` auf.
## Ereignisse an Kindinstanzen
### Observation
```json
{
"type": "Observation",
"serialNumber": "EH123456",
"id": 110,
"value": 30,
"timestamp": 1790053200
}
```
Verteilt werden die IDs `47`, `48`, `100`, `104`, `109`, `110`,
`119`, `120`, `182` bis `185` und `250`. Jede Kindinstanz verwirft
Ereignisse anderer Seriennummern.
### GatewayStatus
```json
{
"type": "GatewayStatus",
"connected": false
}
```
Bei `false` setzt die Ladestation Verfuegbarkeit und Aenderbarkeit sofort
zurueck. Bei `true` fordert sie den aktuellen Zustand erneut an.
## Zustandsabbildung
Observation `109` wird gemaess Easee OpMode abgebildet: `0` offline,
`1` getrennt, `2/6/7/8` bereit, `3` laedt, `4` geladen und `5` Fehler.
Unbekannte Werte geben die Regelung nicht frei. Observation `110` liefert die
aktive Ausgangsphase: `10..15` einphasig und `30` dreiphasig. Es gibt keine
leistungsbasierte Phasenschaetzung.
## Fehlervertrag
Gateway-Antworten enthalten immer `success`. Bei `false` folgt ein
menschenlesbares Feld `error`; optional wird `httpCode` ergaenzt.
Zugangsdaten, Access Token und Refresh Token duerfen weder in Antworten noch
in Ereignissen oder Diagnosevariablen vorkommen.
+8
View File
@@ -132,6 +132,14 @@ Symcon-Datenpunkte registriert `VerbraucherBasisTrait`.
`Leistungswerte_W`, `Betriebsart` und `Zustand` werden intern gehalten
und direkt in die Nachricht geschrieben.
## Easee-Gateway-Transport
Der technische JSON-Vertrag zwischen `EaseeGateway` und
`LadestationGateway` ist getrennt vom fachlichen Managervertrag dokumentiert:
[Easee-Gateway-Schnittstelle](Schnittstelle-Easee-Gateway.md). Die
Ladestation uebersetzt Gateway-Ereignisse in den hier beschriebenen
Verbrauchervertrag `4.0`.
## Zeitverhalten
- Rueckmeldung nach Start, relevanten Aenderungen und alle `Meldeintervall`
+64 -22
View File
@@ -1,35 +1,77 @@
# Easee Gateway
> Status: Diskussionsentwurf. Kommunikationsmodul, kein Verbraucher und keine
> Verwendung der Verbraucherbasis.
> Status: implementiert fuer IP-Symcon 8 und die Easee Cloud API.
Das bestehende Gateway wird übernommen. Anzeigen werden deutsch; technische
Alt-Idents bleiben zur Kompatibilität erhalten. Eine passende vorhandene
Verbindung soll bei der Instanziierung wiederverwendet werden.
Das Modul stellt pro Easee-Nutzerkonto genau eine gemeinsame Verbindung bereit.
Benutzername, Passwort, Access Token und Refresh Token verbleiben im Gateway.
Mehrere Instanzen von **Ladestation Gateway** koennen denselben Elternknoten
verwenden.
## Variablen
## Funktionsumfang
| Technischer Ident / Anzeige | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Connected` / Verbunden | Boolean / Anzeige | SignalR-Verbindungsstatus. |
| `SubscriptionCount` / Angemeldete Ladestationen | Integer / Anzeige | Anzahl registrierter Geräte. |
| `LastError` / Letzter Fehler | String / Anzeige | Diagnose ohne Zugangsdaten. |
- Anmeldung mit Easee-Benutzerkonto und automatische Token-Erneuerung,
- gemeinsame Ereignisverbindung zu `streams.easee.com`,
- Abonnement mehrerer Ladestationen mit aktuellem Zustand,
- Verteilung der Easee-Observations an die passenden Kindinstanzen,
- Stromvorgabe ueber `set_dynamic_charger_current`,
- Wiederverbindung und erneute Anmeldung aller Stationen nach Unterbrechungen,
- TLS-Zertifikatspruefung standardmaessig aktiv.
## Properties
| Technischer Ident / Anzeige | Typ | Standard / Beschreibung |
| Ident | Typ | Standard | Beschreibung |
| --- | --- | --- | --- |
| `Active` | Boolean | `true` | Aktiviert Anmeldung und Ereignisverbindung. |
| `Username` | String | leer | E-Mail-Adresse oder Telefonnummer des Easee-Kontos. |
| `Password` | Passwort | leer | Passwort, nur im Gateway gespeichert. |
| `VerifyCertificate` | Boolean | `true` | Prueft TLS-Zertifikate fuer REST und WebSocket. |
Die interne Property `Testmodus` ist nicht im Formular sichtbar und wird nur
vom automatisierten IP-Symcon-Test verwendet.
## Variablen
| Ident | Typ | Beschreibung |
| --- | --- | --- |
| `Active` / Aktiv | Boolean | `true`; Verbindungsbetrieb, keine Ladefreigabe. |
| `Username` / Benutzername | String | leer; Easee-Konto. |
| `Password` / Passwort | String | leer; vertraulich. |
| `VerifyCertificate` / Zertifikat prüfen | Boolean | `true`; TLS-Prüfung. |
| `Connected` | Boolean | Ereignisverbindung ist betriebsbereit. |
| `SubscriptionCount` | Integer | Anzahl angemeldeter Seriennummern. |
| `LastError` | String | Letzter Fehler ohne Zugangsdaten oder Tokens. |
## Verhalten
## API und Ereignisse
Nach einer Wiederverbindung werden Gerätezustände erst nach neuer gültiger
Rückmeldung verwendet. Das Gateway sendet keine EMS-Verbrauchermeldung.
Der Gateway-Transport ist in
[Easee-Gateway-Schnittstelle](../../Schnittstelle-Easee-Gateway.md)
vollstaendig beschrieben. Fuer Fahrzeug- und Phasenstatus werden insbesondere
Observation `109` und `110` verteilt. Nach jeder Wiederverbindung wird der
Cache verworfen und durch `SubscribeWithCurrentState` neu aufgebaut.
## Offene Punkte
## Fehlerbehandlung
- Instanziierung und Wiederverwendung bestehender Verbindungen testen.
- Aktuelle Easee-Endpunkte und Ereignisfelder vor Übernahme verifizieren.
| Status | Bedeutung |
| ---: | --- |
| `102` | Verbindung aktiv. |
| `104` | Gateway deaktiviert. |
| `201` | Benutzername oder Passwort fehlt. |
| `202` | Anmeldung oder Token-Erneuerung fehlgeschlagen. |
| `203` | Ereignisverbindung fehlgeschlagen. |
REST-Aufrufe verwenden 5 Sekunden Verbindungs- und 30 Sekunden
Gesamt-Timeout. HTTP-401 fuehrt einmalig zu einer Token-Erneuerung und
Wiederholung. Tokens werden nie als Variable oder Debugtext ausgegeben.
## Inbetriebnahme
1. Eine Instanz **Easee Gateway** erstellen.
2. Benutzername und Passwort des Easee-Kontos eintragen.
3. TLS-Pruefung aktiviert lassen.
4. Speichern und Status `102` sowie `Connected=true` abwarten.
5. Fuer jede Station eine Kindinstanz **Ladestation Gateway** anlegen.
6. Bei mehreren Konten je Konto eine eigene Gateway-Instanz verwenden.
## Tests
`composer check` prueft Syntax und Unit-Tests. Der Funktionstest laeuft mit:
```bash
tests/Symcon/bin/run-symcon-tests.sh single EaseeGateway
```
+100 -29
View File
@@ -1,41 +1,112 @@
# Ladestation Gateway
> Status: Diskussionsentwurf. Eigenständiges Verbrauchermodul mit zugeordnetem
> Easee Gateway; keine gemeinsame Ladestations-Basisklasse.
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
## Zusätzliche Variablen
Das Modul bindet genau eine Easee-Ladestation als EMS-Verbraucher an. Es ist
Kind eines **Easee Gateway** und besitzt keine Zugangsdaten.
| Ident | Typ / Zugriff | Beschreibung |
## Varianten
| Betriebsmodus | Verhalten |
| --- | --- |
| `Easee` | Solarladen kann als Startwert konfiguriert und lokal umgeschaltet werden. |
| `Easee - Nur Solarladen` | Solarladen ist fest aktiv und kann nicht abgeschaltet werden. |
Die alte eCarUp-Zusatzabhaengigkeit wird nicht uebernommen. Konto,
Ladestatus und Steuerung laufen ausschliesslich ueber Easee.
## Ereignisbasierter Status
Das Modul pollt die Ladestation nicht. Es verarbeitet unmittelbar die vom
Gateway gelieferten Easee-Observations:
| Observation | Verwendung |
| ---: | --- |
| `47` | Maximalstrom der Ladestation. |
| `104` | Kabelstromgrenze. |
| `109` | Fahrzeugerkennung und Ladezustand. |
| `110` | Aktive Ausgangsphase: Werte 10 bis 15 ergeben 1 Phase, Wert 30 ergibt 3 Phasen. |
| `119` | Easee-Fehlercode. |
| `120` | Gemessene Gesamtleistung in kW. |
| `182..185` | Gemessene Leiterstroeme; der groesste Betrag ist der angezeigte Ladestrom. |
Damit entfallen die alte 60-Sekunden-Erkennung, die 7500-W-Schwelle und die
Voll-Erkennung aus einem unterschaetzten Strom. Fahrzeug, Ladeende und
Phasenzahl stammen direkt aus der Easee API. Zweiphasige oder noch nicht
zugewiesene Ausgangsphasen sowie unbekannte Betriebszustaende werden sicher
als ungueltig behandelt und ergeben bis zur gueltigen 1-/3-Phasenmeldung nur
`[0]`.
## Properties
| Ident | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `Betriebsmodus` | Integer | `0` | `0` Easee, `1` Easee - Nur Solarladen. |
| `Ladestationskennung` | String | leer | Easee-Seriennummer. |
| `MaximalerLadestrom` | Integer | `16 A` | Lokale Obergrenze von 6 bis 32 A. API- und Kabelgrenze wirken zusaetzlich. |
| `Ladefreigabe` | Boolean | `false` | Startwert der lokalen Ladefreigabe. |
| `Solarladen` | Boolean | `true` | Startwert im normalen Easee-Modus. |
| `PrioritaetPV` | Integer | `0` | Prioritaet im PV-Betrieb. |
| `PrioritaetPeak` | Integer | `0` | Prioritaet im Peakbetrieb. |
| `Meldeintervall` | Integer | `10 s` | Periodische Vollmeldung an den Manager. |
| `VorgabeTimeout` | Integer | `120 s` | Ablauf einer nicht erneuerten Manager-Vorgabe. |
| `EinstellungenInVisu` | Boolean | `false` | Zeigt Ladefreigabe und Solarladen. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Zeigt technische Diagnosewerte. |
| `LoggingEin` | Boolean | `false` | Aktiviert Debugmeldungen ohne Geheimnisse. |
## Variablen
Immer sichtbar sind `Aktiv`, `FahrzeugVerbunden`, `FahrzeugGeladen`,
`Fahrzeugstatus`, `Ladestrom` und `Phasenzahl`. Der normalisierte
Fahrzeugstatus ist `0` unbekannt, `1` getrennt, `2` bereit, `3` laedt,
`4` geladen oder `5` Fehler.
Optional sichtbar sind die gemeinsamen EMS-Diagnosewerte sowie
`GatewayVerbunden`, `ApiMaximalstrom`, `LetzterGeraetebefehl` und
`LeistungsangebotDiagnose`.
## Regelung
Die Leistungsstufen und das PV-/Peak-Verhalten entsprechen der
Ladestation Stand-Alone:
| Betriebsart | Solarladen | Angebot |
| --- | --- | --- |
| `FahrzeugVerbunden` | Boolean / Anzeige | Nur bei gültigem Gatewaystatus aussagekräftig. |
| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` nicht verbunden, `2` bereit, `3` lädt, `4` voll, `5` Fehler. |
| `Ladestrom` | Float / Anzeige | Aktueller Ladestrom in A. |
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. |
| `Ladefreigabe` | Boolean / lokal bedienbar | Lokale Ladeerlaubnis zusätzlich zu `Aktiv`. |
| `Solarladen` | Boolean / lokal bedienbar | Überschussorientiertes Laden ein/aus. |
| PV | ein | `[0, ...Ladestufen]` |
| PV | aus | nur maximale Ladestufe |
| Peak | ein | `[0]` |
| Peak | aus | `[0, ...Ladestufen]` |
## Zusätzliche Properties
Die niedrigste Grenze aus Property, Observation `47` und Observation `104`
bestimmt den angebotenen Maximalstrom. Eine neue Observation berechnet das
Angebot sofort neu und meldet die Aenderung kurz gebuendelt an den Manager.
Bei einer Gateway-Unterbrechung werden Fahrzeug- und Phasenstatus sofort
verworfen; erst ein neuer aktueller Gateway-Zustand gibt die Regelung wieder
frei. Die eigentliche Vorgabe wird als dynamischer Ladestrom mit `minutes=0`
an Easee gesendet.
| Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `Ladefreigabe` | Boolean | `false`. |
| `Solarladen` | Boolean | `true`. |
| `MaximalerLadestrom` | Float | `0*` A; Installations- und Gerätegrenze. |
| `Ladestromschritt` | Float | `1` A; muss von Easee unterstützt sein. |
| `MindestEinzeit` | Integer | `0` s. |
| `MindestAuszeit` | Integer | `0` s. |
| `GatewayID` | Integer | `0`; kompatibles Easee Gateway, erforderlich. |
| `Ladestationskennung` | String | leer; eindeutige Station/Seriennummer im Gateway. |
## Managerkommunikation
Es gibt hier keine Zugangsdaten; sie liegen ausschliesslich im Easee Gateway.
Ladefreigabe und Solarladen werden über `EinstellungenInVisu` eingeblendet.
Das Modul implementiert den
[EMS-Nachrichtenvertrag `4.0`](../../Schnittstelle.md). Es wird im Manager
wie die Stand-Alone-Ladestation unter dem Lizenzkatalog `ev_charger`
gefuehrt. Es gibt keine Manager-ID-Property; die Zuordnung erfolgt nur im
Manager.
## Zustand
## Inbetriebnahme
`Fahrzeugstatus`, `FahrzeugVerbunden`, `Ladefreigabe`, `Solarladen`,
`Ladestrom_A`, `Ladefehler` und `Gatewayfehler`.
1. Ein konfiguriertes Easee Gateway mit Status `102` bereitstellen.
2. Darunter **Ladestation Gateway** erstellen.
3. Variante, Seriennummer und elektrische Maximalgrenze konfigurieren.
4. Ladefreigabe und Solarladen festlegen.
5. Die Instanz im Enelix Manager aktiv zuordnen.
6. Ohne Fahrzeug Status `1` und Angebot `[0]` pruefen.
7. Fahrzeug verbinden und die API-Werte fuer Status und Phasenzahl kontrollieren.
8. Erst danach `Aktiv` einschalten und eine kleine Vorgabe testen.
## Offene Punkte
## Tests
- Reaktion aller zugeordneten Stationen bei Gatewayausfall im Praxistest festlegen.
- Lokalen Mindestladebedarf bei deaktiviertem Solarladen genau festlegen.
```bash
composer check
tests/Symcon/bin/run-symcon-tests.sh single LadestationGateway
```
+5 -5
View File
@@ -1,8 +1,8 @@
# EMS-Module und Modulentwürfe
> Manager, Warmwassererwaermer, Verbraucher 1-Stufig und Ladestation
> Stand-Alone sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren
> Ordner enthalten Besprechungsgrundlagen.
> Manager, Warmwassererwaermer, Verbraucher 1-Stufig, Ladestation Stand-Alone,
> Easee Gateway und Ladestation Gateway sind als installierbare IP-Symcon-Module
> umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen.
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
@@ -17,8 +17,8 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte.
| [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) |
| [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready |
| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung (implementiert) |
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Ladestation am Easee Gateway |
| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation |
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Eventbasierte Ladestation am Easee Gateway (implementiert) |
| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation (implementiert) |
## Review-Regel
+3 -1
View File
@@ -42,7 +42,9 @@ werden, wenn sie vor dem Lauf nicht existierten.
- `single`: genau die als Auswahl übergebenen Module
- `affected`: die durch geänderte Pfade ermittelten Module
Verfügbare Module: `LadestationStandAlone`, `Manager`, `VerbraucherEinStufig`, `Warmwassererwaermer`.
Verfuegbare Module: `EaseeGateway`, `LadestationGateway`,
`LadestationStandAlone`, `Manager`, `Pufferspeicher`, `VerbraucherEinStufig`
und `Warmwassererwaermer`.
Der Manager-Test enthält Manager ohne Verbraucher, jeden Verbrauchertyp einzeln und alle aktuell implementierten Verbrauchertypen gemeinsam.
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
final class EaseeGatewayProtokoll
{
/** @var list<int> */
public const BEOBACHTUNGEN = [
47, 48, 100, 104, 109, 110, 119, 120, 182, 183, 184, 185, 250,
];
public static function seriennummer(string $wert): string
{
return strtoupper(trim($wert));
}
/**
* @param mixed $knoten
* @param list<string> $abonnements
* @return list<array{Seriennummer: string, ID: int, Wert: mixed}>
*/
public static function extrahiereBeobachtungen(
$knoten,
array $abonnements,
string $seriennummer = '',
int $tiefe = 0
): array {
if (!is_array($knoten) || $tiefe > 12) {
return [];
}
$abonnements = array_values(array_unique(array_map(
[self::class, 'seriennummer'],
$abonnements
)));
foreach (['serialNumber', 'SerialNumber', 'chargerId', 'ChargerId', 'mid', 'Mid'] as $feld) {
if (!isset($knoten[$feld]) || !is_scalar($knoten[$feld])) {
continue;
}
$kandidat = self::seriennummer((string) $knoten[$feld]);
if (in_array($kandidat, $abonnements, true)) {
$seriennummer = $kandidat;
break;
}
}
if (self::istListe($knoten)) {
foreach ($knoten as $wert) {
if (!is_string($wert)) {
continue;
}
$kandidat = self::seriennummer($wert);
if (in_array($kandidat, $abonnements, true)) {
$seriennummer = $kandidat;
}
}
}
$id = null;
foreach (['id', 'Id', 'observationId', 'ObservationId'] as $feld) {
if (array_key_exists($feld, $knoten) && is_numeric($knoten[$feld])) {
$id = (int) $knoten[$feld];
break;
}
}
$wertVorhanden = false;
$wert = null;
foreach (['value', 'Value'] as $feld) {
if (array_key_exists($feld, $knoten)) {
$wertVorhanden = true;
$wert = $knoten[$feld];
break;
}
}
$ergebnis = [];
if (
$seriennummer !== ''
&& $wertVorhanden
&& in_array($id, self::BEOBACHTUNGEN, true)
) {
$ergebnis[] = [
'Seriennummer' => $seriennummer,
'ID' => $id,
'Wert' => $wert,
];
}
foreach ($knoten as $kind) {
if (is_array($kind)) {
$ergebnis = array_merge(
$ergebnis,
self::extrahiereBeobachtungen(
$kind,
$abonnements,
$seriennummer,
$tiefe + 1
)
);
}
}
return $ergebnis;
}
/** @param array<mixed> $wert */
private static function istListe(array $wert): bool
{
$index = 0;
foreach ($wert as $schluessel => $_) {
if ($schluessel !== $index++) {
return false;
}
}
return true;
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class EaseeLadestatus
{
public const BETRIEBSMODUS_EASEE = 0;
public const BETRIEBSMODUS_NUR_SOLAR = 1;
/**
* @param array<string, mixed> $beobachtungen
* @return array{
* StatusGueltig: bool,
* FahrzeugVerbunden: bool,
* FahrzeugGeladen: bool,
* Fahrzeugstatus: int,
* Phasenzahl: int,
* Istleistung_W: float,
* Ladestrom_A: float,
* ApiMaximalstrom_A: int,
* Stoerung: bool,
* Stoertext: string
* }
*/
public static function ausBeobachtungen(array $beobachtungen, int $konfigurierterMaximalstrom): array
{
if ($konfigurierterMaximalstrom < 6 || $konfigurierterMaximalstrom > 32) {
throw new InvalidArgumentException(
'MaximalerLadestrom muss zwischen 6 und 32 A liegen.'
);
}
$rohBetriebszustand = $beobachtungen['109'] ?? null;
$statusGueltig = is_numeric($rohBetriebszustand)
&& floor((float) $rohBetriebszustand) === (float) $rohBetriebszustand
&& in_array((int) $rohBetriebszustand, [0, 1, 2, 3, 4, 5, 6, 7, 8], true);
$betriebszustand = $statusGueltig ? (int) $rohBetriebszustand : 0;
$verbunden = $statusGueltig
&& in_array($betriebszustand, [2, 3, 4, 5, 6, 7, 8], true);
$geladen = $betriebszustand === 4;
$fahrzeugstatus = self::fahrzeugstatus($betriebszustand);
$phasenzahl = $verbunden
? self::phasenzahl((int) ($beobachtungen['110'] ?? 0))
: 0;
$leistung = max(0.0, (float) ($beobachtungen['120'] ?? 0.0) * 1000.0);
$stroeme = [];
foreach ([182, 183, 184, 185] as $id) {
if (array_key_exists((string) $id, $beobachtungen)) {
$stroeme[] = abs((float) $beobachtungen[(string) $id]);
}
}
$ladestrom = $stroeme === [] ? 0.0 : max($stroeme);
$grenzen = [$konfigurierterMaximalstrom];
foreach ([47, 104] as $id) {
$grenze = (float) ($beobachtungen[(string) $id] ?? 0.0);
if ($grenze >= 6.0) {
$grenzen[] = (int) floor($grenze);
}
}
$apiMaximalstrom = max(6, min($grenzen));
$fehlercode = (int) ($beobachtungen['119'] ?? 0);
$stoerung = $betriebszustand === 5 || $fehlercode !== 0;
$stoertext = '';
if ($betriebszustand === 5) {
$stoertext = 'Easee meldet einen Fehlerzustand.';
}
if ($fehlercode !== 0) {
$stoertext .= ($stoertext === '' ? '' : ' ') . 'Easee-Fehlercode: ' . $fehlercode . '.';
}
return [
'StatusGueltig' => $statusGueltig,
'FahrzeugVerbunden' => $verbunden,
'FahrzeugGeladen' => $geladen,
'Fahrzeugstatus' => $fahrzeugstatus,
'Phasenzahl' => $phasenzahl,
'Istleistung_W' => $leistung,
'Ladestrom_A' => $ladestrom,
'ApiMaximalstrom_A' => $apiMaximalstrom,
'Stoerung' => $stoerung,
'Stoertext' => trim($stoertext),
];
}
public static function solarladenErzwungen(int $betriebsmodus): bool
{
if (!in_array($betriebsmodus, [
self::BETRIEBSMODUS_EASEE,
self::BETRIEBSMODUS_NUR_SOLAR,
], true)) {
throw new InvalidArgumentException('Unbekannter Easee-Betriebsmodus.');
}
return $betriebsmodus === self::BETRIEBSMODUS_NUR_SOLAR;
}
private static function fahrzeugstatus(int $betriebszustand): int
{
if ($betriebszustand === 1) {
return 1;
}
if (in_array($betriebszustand, [2, 6, 7, 8], true)) {
return 2;
}
if ($betriebszustand === 3) {
return 3;
}
if ($betriebszustand === 4) {
return 4;
}
if ($betriebszustand === 5) {
return 5;
}
return 0;
}
private static function phasenzahl(int $ausgangsphase): int
{
if ($ausgangsphase >= 10 && $ausgangsphase <= 15) {
return 1;
}
if ($ausgangsphase === 30) {
return 3;
}
return 0;
}
}
+14 -2
View File
@@ -14,8 +14,6 @@ final class DokumentationsstrukturTest extends TestCase
return [
['Batterie'],
['Waermepumpe'],
['Ladestation-Gateway'],
['Easee-Gateway'],
];
}
@@ -48,9 +46,23 @@ final class DokumentationsstrukturTest extends TestCase
self::assertStringContainsString('Fake-HTTP-Transport', $inhalt);
}
public function testEaseeModuleDokumentierenDenImplementiertenStand(): void
{
foreach (['Easee-Gateway', 'Ladestation-Gateway'] as $modul) {
$inhalt = file_get_contents(
__DIR__ . '/../docs/module/' . $modul . '/README.md'
);
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: implementiert', $inhalt);
self::assertStringContainsString('Properties', $inhalt);
self::assertStringContainsString('Tests', $inhalt);
}
}
public function testSchnittstellenDokumentiertSind(): void
{
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle.md');
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle-Easee-Gateway.md');
self::assertFileExists(__DIR__ . '/../docs/Obere-Anschluesse.md');
self::assertFileExists(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
use PHPUnit\Framework\TestCase;
final class EaseeGatewayProtokollTest extends TestCase
{
public function testVerschachtelteSignalRBeobachtungenWerdenExtrahiert(): void
{
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
'arguments' => [
'EH123456',
[
['id' => 109, 'value' => 3],
['ObservationId' => 110, 'Value' => 30],
],
],
], ['EH123456']);
self::assertSame([
['Seriennummer' => 'EH123456', 'ID' => 109, 'Wert' => 3],
['Seriennummer' => 'EH123456', 'ID' => 110, 'Wert' => 30],
], $beobachtungen);
}
public function testNichtAbonnierteStationenUndUnbekannteIdsWerdenIgnoriert(): void
{
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
[
'serialNumber' => 'EH999999',
'id' => 109,
'value' => 3,
],
[
'serialNumber' => 'EH123456',
'id' => 999,
'value' => 1,
],
], ['EH123456']);
self::assertSame([], $beobachtungen);
}
public function testSeriennummerWirdNormalisiert(): void
{
self::assertSame(
'EH123456',
EaseeGatewayProtokoll::seriennummer(' eh123456 ')
);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\EaseeLadestatus;
use PHPUnit\Framework\TestCase;
final class EaseeLadestatusTest extends TestCase
{
public function testFahrzeugUndEinphasigesLadenKommenDirektAusApiWerten(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 3,
'110' => 10,
'120' => 2.3,
'182' => 10.1,
], 16);
self::assertTrue($status['FahrzeugVerbunden']);
self::assertFalse($status['FahrzeugGeladen']);
self::assertSame(3, $status['Fahrzeugstatus']);
self::assertSame(1, $status['Phasenzahl']);
self::assertEqualsWithDelta(2300.0, $status['Istleistung_W'], 0.01);
self::assertEqualsWithDelta(10.1, $status['Ladestrom_A'], 0.01);
}
public function testDreiphasigesLadenWirdNichtAusLeistungGeschaetzt(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 3,
'110' => 30,
'120' => 1.0,
'182' => 2.0,
'183' => 2.2,
'184' => 2.1,
], 32);
self::assertSame(3, $status['Phasenzahl']);
self::assertEqualsWithDelta(2.2, $status['Ladestrom_A'], 0.01);
}
public function testZweiphasigerApiWertWirdNichtAlsEinOderDreiGeschaetzt(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 3,
'110' => 20,
'120' => 4.6,
], 16);
self::assertSame(0, $status['Phasenzahl']);
}
public function testGetrenntUndGeladenFolgenDemEaseeBetriebszustand(): void
{
$getrennt = EaseeLadestatus::ausBeobachtungen(['109' => 1, '110' => 30], 16);
$geladen = EaseeLadestatus::ausBeobachtungen(['109' => 4, '110' => 30], 16);
self::assertFalse($getrennt['FahrzeugVerbunden']);
self::assertSame(0, $getrennt['Phasenzahl']);
self::assertTrue($geladen['FahrzeugVerbunden']);
self::assertTrue($geladen['FahrzeugGeladen']);
self::assertSame(4, $geladen['Fahrzeugstatus']);
}
public function testUnbekannterApiBetriebszustandGibtRegelungNichtFrei(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 99,
'110' => 30,
], 16);
self::assertFalse($status['StatusGueltig']);
self::assertFalse($status['FahrzeugVerbunden']);
self::assertSame(0, $status['Fahrzeugstatus']);
self::assertSame(0, $status['Phasenzahl']);
}
public function testApiUndKabelgrenzenBegrenzenDenKonfiguriertenStrom(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 2,
'110' => 10,
'47' => 25.0,
'104' => 13.7,
], 32);
self::assertSame(13, $status['ApiMaximalstrom_A']);
}
public function testNurSolarladenIstFestErzwungen(): void
{
self::assertFalse(EaseeLadestatus::solarladenErzwungen(0));
self::assertTrue(EaseeLadestatus::solarladenErzwungen(1));
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class EaseeModuleStrukturTest extends TestCase
{
public function testGatewayUndLadestationSindVollstaendigeModule(): void
{
foreach (['EaseeGateway', 'LadestationGateway'] as $modul) {
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
self::assertFileExists(__DIR__ . '/../' . $modul . '/' . $datei);
}
}
}
public function testDatenSchnittstellenPassenZusammen(): void
{
$gateway = $this->module('EaseeGateway');
$ladestation = $this->module('LadestationGateway');
self::assertContains(
'{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}',
$gateway['implemented']
);
self::assertContains(
'{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}',
$gateway['childRequirements']
);
self::assertContains(
'{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}',
$ladestation['parentRequirements']
);
self::assertContains(
'{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}',
$ladestation['implemented']
);
}
public function testZugangsdatenLiegenNurImGateway(): void
{
$gatewayForm = (string) file_get_contents(
__DIR__ . '/../EaseeGateway/form.json'
);
$ladestationForm = (string) file_get_contents(
__DIR__ . '/../LadestationGateway/form.json'
);
self::assertStringContainsString('PasswordTextBox', $gatewayForm);
self::assertStringNotContainsString('PasswordTextBox', $ladestationForm);
self::assertStringNotContainsString('Username', $ladestationForm);
}
public function testLadestationVerwendetApiStatusUndGemeinsamenVertrag(): void
{
$modul = (string) file_get_contents(
__DIR__ . '/../LadestationGateway/module.php'
);
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
self::assertStringContainsString('EaseeLadestatus::ausBeobachtungen', $modul);
self::assertStringContainsString("'FahrzeugVerbunden'", $modul);
self::assertStringContainsString("'Phasenzahl'", $modul);
self::assertStringNotContainsString('7500', $modul);
self::assertStringNotContainsString('Abfrageintervall', $modul);
}
/** @return array<string, mixed> */
private function module(string $name): array
{
return json_decode(
(string) file_get_contents(__DIR__ . '/../' . $name . '/module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
}
}
+19
View File
@@ -3,6 +3,25 @@
declare(strict_types=1);
return [
'EaseeGateway' => [
'file' => __DIR__ . '/modules/EaseeGateway.php',
'paths' => [
'EaseeGateway/*',
'libs/EaseeGatewayProtokoll.php',
],
],
'LadestationGateway' => [
'file' => __DIR__ . '/modules/LadestationGateway.php',
'paths' => [
'LadestationGateway/*',
'libs/EaseeGatewayProtokoll.php',
'libs/EaseeLadestatus.php',
'libs/LadestationRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'LadestationStandAlone' => [
'file' => __DIR__ . '/modules/LadestationStandAlone.php',
'paths' => [
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use Belevo\EnelixEMS\SymconTest\TestContext;
return static function (TestContext $test): void {
$gateway = $test->createInstance(
'{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}',
'Easee Gateway Test',
[
'Active' => true,
'Testmodus' => true,
]
);
$test->runCase('Testgateway ist ohne Zugangsdaten aktiv', static function (
TestContext $test
) use ($gateway): void {
$test->assertInstanceStatus($gateway);
$test->assertSame(
true,
GetValue($test->objectByIdent('Connected', $gateway))
);
});
$test->runCase('Abonnement liefert injizierten API-Zustand', static function (
TestContext $test
) use ($gateway): void {
ENELIXEASEE_ProcessStationRequest($gateway, json_encode([
'action' => 'Subscribe',
'serialNumber' => 'EH123456',
], JSON_THROW_ON_ERROR));
foreach ([109 => 3, 110 => 30, 120 => 1.0] as $id => $value) {
IPS_RequestAction($gateway, 'TestObservation', json_encode([
'serialNumber' => 'EH123456',
'id' => $id,
'value' => $value,
], JSON_THROW_ON_ERROR));
}
$antwort = json_decode(ENELIXEASEE_ProcessStationRequest(
$gateway,
json_encode([
'action' => 'GetState',
'serialNumber' => 'EH123456',
], JSON_THROW_ON_ERROR)
), true, 512, JSON_THROW_ON_ERROR);
$test->assertSame(true, $antwort['success']);
$test->assertSame(3, $antwort['state']['109']);
$test->assertSame(30, $antwort['state']['110']);
$test->assertSame(
1,
GetValue($test->objectByIdent('SubscriptionCount', $gateway))
);
});
$test->runCase('Stromvorgabe wird ueber Gateway angenommen', static function (
TestContext $test
) use ($gateway): void {
$antwort = json_decode(ENELIXEASEE_ProcessStationRequest(
$gateway,
json_encode([
'action' => 'SetDynamicChargerCurrent',
'serialNumber' => 'EH123456',
'amps' => 13,
], JSON_THROW_ON_ERROR)
), true, 512, JSON_THROW_ON_ERROR);
$test->assertSame(true, $antwort['success']);
$test->assertSame(200, $antwort['httpCode']);
});
$test->runCase('Gebrochene Stromvorgabe wird abgelehnt', static function (
TestContext $test
) use ($gateway): void {
$antwort = json_decode(ENELIXEASEE_ProcessStationRequest(
$gateway,
json_encode([
'action' => 'SetDynamicChargerCurrent',
'serialNumber' => 'EH123456',
'amps' => 6.5,
], JSON_THROW_ON_ERROR)
), true, 512, JSON_THROW_ON_ERROR);
$test->assertSame(false, $antwort['success']);
});
};
+155
View File
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
use Belevo\EnelixEMS\SymconTest\TestContext;
return static function (TestContext $test): void {
$gateway = $test->createInstance(
'{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}',
'Easee Gateway fuer Ladestation',
[
'Active' => true,
'Testmodus' => true,
]
);
$station = $test->createInstance(
'{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}',
'Easee Ladestation',
[
'Betriebsmodus' => 0,
'Ladestationskennung' => 'EH123456',
'MaximalerLadestrom' => 16,
'Ladefreigabe' => true,
'Solarladen' => true,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
]
);
IPS_ConnectInstance($station, $gateway);
IPS_ApplyChanges($station);
$nurSolar = $test->createInstance(
'{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}',
'Easee nur Solarladen',
[
'Betriebsmodus' => 1,
'Ladestationskennung' => 'EH654321',
'MaximalerLadestrom' => 16,
'Ladefreigabe' => true,
'Solarladen' => false,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
]
);
IPS_ConnectInstance($nurSolar, $gateway);
IPS_ApplyChanges($nurSolar);
$sendeBeobachtung = static function (
string $seriennummer,
int $id,
$value
) use ($gateway): void {
IPS_RequestAction($gateway, 'TestObservation', json_encode([
'serialNumber' => $seriennummer,
'id' => $id,
'value' => $value,
], JSON_THROW_ON_ERROR));
};
foreach (['EH123456', 'EH654321'] as $seriennummer) {
$sendeBeobachtung($seriennummer, 47, 16.0);
$sendeBeobachtung($seriennummer, 104, 32.0);
$sendeBeobachtung($seriennummer, 109, 3);
$sendeBeobachtung($seriennummer, 110, 30);
$sendeBeobachtung($seriennummer, 120, 1.0);
$sendeBeobachtung($seriennummer, 182, 2.1);
$sendeBeobachtung($seriennummer, 183, 2.0);
$sendeBeobachtung($seriennummer, 184, 2.2);
}
$test->runCase('Fahrzeug und drei Phasen folgen direkt den API-Ereignissen', static function (
TestContext $test
) use ($station): void {
$test->assertSame(
true,
GetValue($test->objectByIdent('FahrzeugVerbunden', $station))
);
$test->assertSame(
3,
GetValue($test->objectByIdent('Phasenzahl', $station))
);
$test->assertSame(
3,
GetValue($test->objectByIdent('Fahrzeugstatus', $station))
);
$test->assertEquals(
2.2,
(float) GetValue($test->objectByIdent('Ladestrom', $station)),
0.01
);
});
$test->runCase('Event erzeugt ohne Polling ein dreiphasiges Leistungsangebot', static function (
TestContext $test
) use ($station): void {
IPS_RequestAction($station, 'Aktiv', true);
$test->assertSame(
'[0,4150,4850,5550,6250,6950,7600,8300,9000,9700,10300,11000]',
(string) GetValue(
$test->objectByIdent('LeistungsangebotDiagnose', $station)
)
);
$test->assertTrue(str_contains(
(string) GetValue(
$test->objectByIdent('LetzterGeraetebefehl', $station)
),
'"Ampere":0'
));
});
$test->runCase('Nur-Solar-Variante laesst Solarladen nicht abschalten', static function (
TestContext $test
) use ($nurSolar): void {
IPS_RequestAction($nurSolar, 'Solarladen', false);
$test->assertSame(
true,
GetValue($test->objectByIdent('Solarladen', $nurSolar))
);
});
$test->runCase('Unbekannter API-Status sperrt die Regelung', static function (
TestContext $test
) use ($sendeBeobachtung, $station): void {
$sendeBeobachtung('EH123456', 109, 99);
$test->assertSame(
false,
GetValue($test->objectByIdent('FahrzeugVerbunden', $station))
);
$test->assertInstanceStatus($station, 203);
$sendeBeobachtung('EH123456', 109, 3);
$test->assertSame(
true,
GetValue($test->objectByIdent('FahrzeugVerbunden', $station))
);
$test->assertInstanceStatus($station);
});
$test->runCase('Gateway-Ausfall verwirft Fahrzeug- und Phasenstatus', static function (
TestContext $test
) use ($gateway, $station): void {
IPS_SetProperty($gateway, 'Active', false);
IPS_ApplyChanges($gateway);
$test->assertSame(
false,
GetValue($test->objectByIdent('FahrzeugVerbunden', $station))
);
$test->assertSame(
0,
GetValue($test->objectByIdent('Phasenzahl', $station))
);
$test->assertInstanceStatus($station, 202);
});
};
+17 -2
View File
@@ -58,7 +58,14 @@ final class SymconTestContractTest extends TestCase
public function testAffectedModuleResolverSelectsAllMatches(): void
{
self::assertSame(
['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'],
[
'LadestationGateway',
'LadestationStandAlone',
'Manager',
'Pufferspeicher',
'VerbraucherEinStufig',
'Warmwassererwaermer',
],
$this->resolveAffectedModules(['libs/Nachrichtenvertrag.php'])
);
}
@@ -74,7 +81,15 @@ final class SymconTestContractTest extends TestCase
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
{
self::assertSame(
['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'],
[
'EaseeGateway',
'LadestationGateway',
'LadestationStandAlone',
'Manager',
'Pufferspeicher',
'VerbraucherEinStufig',
'Warmwassererwaermer',
],
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
);
}