This commit is contained in:
@@ -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)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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 : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
|
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
|
||||||
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
|
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
|
||||||
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
|
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
|
||||||
|
'{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}',
|
||||||
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}',
|
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}',
|
||||||
];
|
];
|
||||||
private const MONATSNAMEN = [
|
private const MONATSNAMEN = [
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -132,6 +132,14 @@ Symcon-Datenpunkte registriert `VerbraucherBasisTrait`.
|
|||||||
`Leistungswerte_W`, `Betriebsart` und `Zustand` werden intern gehalten
|
`Leistungswerte_W`, `Betriebsart` und `Zustand` werden intern gehalten
|
||||||
und direkt in die Nachricht geschrieben.
|
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
|
## Zeitverhalten
|
||||||
|
|
||||||
- Rueckmeldung nach Start, relevanten Aenderungen und alle `Meldeintervall`
|
- Rueckmeldung nach Start, relevanten Aenderungen und alle `Meldeintervall`
|
||||||
|
|||||||
@@ -1,35 +1,77 @@
|
|||||||
# Easee Gateway
|
# Easee Gateway
|
||||||
|
|
||||||
> Status: Diskussionsentwurf. Kommunikationsmodul, kein Verbraucher und keine
|
> Status: implementiert fuer IP-Symcon 8 und die Easee Cloud API.
|
||||||
> Verwendung der Verbraucherbasis.
|
|
||||||
|
|
||||||
Das bestehende Gateway wird übernommen. Anzeigen werden deutsch; technische
|
Das Modul stellt pro Easee-Nutzerkonto genau eine gemeinsame Verbindung bereit.
|
||||||
Alt-Idents bleiben zur Kompatibilität erhalten. Eine passende vorhandene
|
Benutzername, Passwort, Access Token und Refresh Token verbleiben im Gateway.
|
||||||
Verbindung soll bei der Instanziierung wiederverwendet werden.
|
Mehrere Instanzen von **Ladestation Gateway** koennen denselben Elternknoten
|
||||||
|
verwenden.
|
||||||
|
|
||||||
## Variablen
|
## Funktionsumfang
|
||||||
|
|
||||||
| Technischer Ident / Anzeige | Typ / Zugriff | Beschreibung |
|
- Anmeldung mit Easee-Benutzerkonto und automatische Token-Erneuerung,
|
||||||
| --- | --- | --- |
|
- gemeinsame Ereignisverbindung zu `streams.easee.com`,
|
||||||
| `Connected` / Verbunden | Boolean / Anzeige | SignalR-Verbindungsstatus. |
|
- Abonnement mehrerer Ladestationen mit aktuellem Zustand,
|
||||||
| `SubscriptionCount` / Angemeldete Ladestationen | Integer / Anzeige | Anzahl registrierter Geräte. |
|
- Verteilung der Easee-Observations an die passenden Kindinstanzen,
|
||||||
| `LastError` / Letzter Fehler | String / Anzeige | Diagnose ohne Zugangsdaten. |
|
- Stromvorgabe ueber `set_dynamic_charger_current`,
|
||||||
|
- Wiederverbindung und erneute Anmeldung aller Stationen nach Unterbrechungen,
|
||||||
|
- TLS-Zertifikatspruefung standardmaessig aktiv.
|
||||||
|
|
||||||
## Properties
|
## 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. |
|
| `Connected` | Boolean | Ereignisverbindung ist betriebsbereit. |
|
||||||
| `Username` / Benutzername | String | leer; Easee-Konto. |
|
| `SubscriptionCount` | Integer | Anzahl angemeldeter Seriennummern. |
|
||||||
| `Password` / Passwort | String | leer; vertraulich. |
|
| `LastError` | String | Letzter Fehler ohne Zugangsdaten oder Tokens. |
|
||||||
| `VerifyCertificate` / Zertifikat prüfen | Boolean | `true`; TLS-Prüfung. |
|
|
||||||
|
|
||||||
## Verhalten
|
## API und Ereignisse
|
||||||
|
|
||||||
Nach einer Wiederverbindung werden Gerätezustände erst nach neuer gültiger
|
Der Gateway-Transport ist in
|
||||||
Rückmeldung verwendet. Das Gateway sendet keine EMS-Verbrauchermeldung.
|
[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.
|
| Status | Bedeutung |
|
||||||
- Aktuelle Easee-Endpunkte und Ereignisfelder vor Übernahme verifizieren.
|
| ---: | --- |
|
||||||
|
| `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
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,41 +1,112 @@
|
|||||||
# Ladestation Gateway
|
# Ladestation Gateway
|
||||||
|
|
||||||
> Status: Diskussionsentwurf. Eigenständiges Verbrauchermodul mit zugeordnetem
|
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
|
||||||
> Easee Gateway; keine gemeinsame Ladestations-Basisklasse.
|
|
||||||
|
|
||||||
## 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. |
|
| PV | ein | `[0, ...Ladestufen]` |
|
||||||
| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` nicht verbunden, `2` bereit, `3` lädt, `4` voll, `5` Fehler. |
|
| PV | aus | nur maximale Ladestufe |
|
||||||
| `Ladestrom` | Float / Anzeige | Aktueller Ladestrom in A. |
|
| Peak | ein | `[0]` |
|
||||||
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. |
|
| Peak | aus | `[0, ...Ladestufen]` |
|
||||||
| `Ladefreigabe` | Boolean / lokal bedienbar | Lokale Ladeerlaubnis zusätzlich zu `Aktiv`. |
|
|
||||||
| `Solarladen` | Boolean / lokal bedienbar | Überschussorientiertes Laden ein/aus. |
|
|
||||||
|
|
||||||
## 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 |
|
## Managerkommunikation
|
||||||
| --- | --- | --- |
|
|
||||||
| `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. |
|
|
||||||
|
|
||||||
Es gibt hier keine Zugangsdaten; sie liegen ausschliesslich im Easee Gateway.
|
Das Modul implementiert den
|
||||||
Ladefreigabe und Solarladen werden über `EinstellungenInVisu` eingeblendet.
|
[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`,
|
1. Ein konfiguriertes Easee Gateway mit Status `102` bereitstellen.
|
||||||
`Ladestrom_A`, `Ladefehler` und `Gatewayfehler`.
|
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.
|
```bash
|
||||||
- Lokalen Mindestladebedarf bei deaktiviertem Solarladen genau festlegen.
|
composer check
|
||||||
|
tests/Symcon/bin/run-symcon-tests.sh single LadestationGateway
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# EMS-Module und Modulentwürfe
|
# EMS-Module und Modulentwürfe
|
||||||
|
|
||||||
> Manager, Warmwassererwaermer, Verbraucher 1-Stufig und Ladestation
|
> Manager, Warmwassererwaermer, Verbraucher 1-Stufig, Ladestation Stand-Alone,
|
||||||
> Stand-Alone sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren
|
> Easee Gateway und Ladestation Gateway sind als installierbare IP-Symcon-Module
|
||||||
> Ordner enthalten Besprechungsgrundlagen.
|
> umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen.
|
||||||
|
|
||||||
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
|
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
|
||||||
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
|
[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) |
|
| [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) |
|
||||||
| [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready |
|
| [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready |
|
||||||
| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung (implementiert) |
|
| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung (implementiert) |
|
||||||
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Ladestation am Easee Gateway |
|
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Eventbasierte Ladestation am Easee Gateway (implementiert) |
|
||||||
| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation |
|
| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation (implementiert) |
|
||||||
|
|
||||||
## Review-Regel
|
## Review-Regel
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ werden, wenn sie vor dem Lauf nicht existierten.
|
|||||||
- `single`: genau die als Auswahl übergebenen Module
|
- `single`: genau die als Auswahl übergebenen Module
|
||||||
- `affected`: die durch geänderte Pfade ermittelten 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.
|
Der Manager-Test enthält Manager ohne Verbraucher, jeden Verbrauchertyp einzeln und alle aktuell implementierten Verbrauchertypen gemeinsam.
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,8 +14,6 @@ final class DokumentationsstrukturTest extends TestCase
|
|||||||
return [
|
return [
|
||||||
['Batterie'],
|
['Batterie'],
|
||||||
['Waermepumpe'],
|
['Waermepumpe'],
|
||||||
['Ladestation-Gateway'],
|
|
||||||
['Easee-Gateway'],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,9 +46,23 @@ final class DokumentationsstrukturTest extends TestCase
|
|||||||
self::assertStringContainsString('Fake-HTTP-Transport', $inhalt);
|
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
|
public function testSchnittstellenDokumentiertSind(): void
|
||||||
{
|
{
|
||||||
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle.md');
|
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle.md');
|
||||||
|
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle-Easee-Gateway.md');
|
||||||
self::assertFileExists(__DIR__ . '/../docs/Obere-Anschluesse.md');
|
self::assertFileExists(__DIR__ . '/../docs/Obere-Anschluesse.md');
|
||||||
self::assertFileExists(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
|
self::assertFileExists(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,25 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
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' => [
|
'LadestationStandAlone' => [
|
||||||
'file' => __DIR__ . '/modules/LadestationStandAlone.php',
|
'file' => __DIR__ . '/modules/LadestationStandAlone.php',
|
||||||
'paths' => [
|
'paths' => [
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -58,7 +58,14 @@ final class SymconTestContractTest extends TestCase
|
|||||||
public function testAffectedModuleResolverSelectsAllMatches(): void
|
public function testAffectedModuleResolverSelectsAllMatches(): void
|
||||||
{
|
{
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'],
|
[
|
||||||
|
'LadestationGateway',
|
||||||
|
'LadestationStandAlone',
|
||||||
|
'Manager',
|
||||||
|
'Pufferspeicher',
|
||||||
|
'VerbraucherEinStufig',
|
||||||
|
'Warmwassererwaermer',
|
||||||
|
],
|
||||||
$this->resolveAffectedModules(['libs/Nachrichtenvertrag.php'])
|
$this->resolveAffectedModules(['libs/Nachrichtenvertrag.php'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,7 +81,15 @@ final class SymconTestContractTest extends TestCase
|
|||||||
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
|
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
|
||||||
{
|
{
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'],
|
[
|
||||||
|
'EaseeGateway',
|
||||||
|
'LadestationGateway',
|
||||||
|
'LadestationStandAlone',
|
||||||
|
'Manager',
|
||||||
|
'Pufferspeicher',
|
||||||
|
'VerbraucherEinStufig',
|
||||||
|
'Warmwassererwaermer',
|
||||||
|
],
|
||||||
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
|
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user