30 Commits
Author SHA1 Message Date
dh 8357e8dfab merge: vorbereiteten Fahrplan-Commit uebernehmen
Tests / test (push) Successful in 54s
2026-10-01 12:34:51 +00:00
dh 38353aed93 feat(manager): Fahrplanvorgabe linear interpolieren
Tests / test (push) Successful in 54s
2026-10-01 12:16:10 +00:00
dh cf92e7623b fix(manager): Fahrplanvorgabe linear interpolieren 2026-09-30 14:38:42 +02:00
dh add1077674 fix(manager): Batterie nach Prioritaet als Restverbraucher regeln
Tests / test (push) Canceled after 0s
2026-09-30 05:42:13 +00:00
dh bfdcf4536b fix(ladestation): Steuerbefehle in Diagnose erhalten
Tests / test (push) Canceled after 0s
2026-09-30 04:37:33 +00:00
dh faa5dfb33f feat(ladestation): Phasenerkennung und Schaltsperren erweitern
Tests / test (push) Successful in 45s
2026-09-29 22:49:26 +00:00
dh 53fca5e2bb feat(manager): Batteriesollwert aus Netzfahrplan anwenden
Tests / test (push) Successful in 47s
2026-09-29 17:23:25 +00:00
dh f611da975d fix(easee): Ereignisverbindung konsistent neu aufbauen
Tests / test (push) Successful in 1m2s
2026-09-29 16:47:03 +00:00
dh 393969594e fix(manager): Regelung und Energiefluss korrigieren
Tests / test (push) Successful in 48s
2026-09-29 16:17:49 +00:00
dh cae14cf399 fix(manager): Festlasten nach Istleistung bilanzieren
Tests / test (push) Successful in 52s
2026-09-29 15:11:14 +00:00
dh 3aaa744a8a fix(manager): Batterie als neutralen Restregler behandeln
Tests / test (push) Successful in 51s
2026-09-29 14:38:14 +00:00
dh fdc2007bb1 fix(manager): Energieausgleich aus Enelix 1 wiederherstellen
Tests / test (push) Successful in 54s
2026-09-29 14:14:25 +00:00
dh 37400878b6 fix(easee): veraltete Ladeleistung nach Ladestopp nullen
Tests / test (push) Successful in 50s
2026-09-29 13:15:43 +00:00
dh ace1821213 fix(ladestation): Fahrzeugstrom nach Lernzeit begrenzen
Tests / test (push) Successful in 56s
2026-09-29 12:47:23 +00:00
dh b3b868cd2a fix(ladestation): Ladeanlauf und Easee-Verbindung stabilisieren
Tests / test (push) Successful in 1m23s
2026-09-29 12:25:35 +00:00
dh a74f14aa19 feat(warmwasser): Temperaturgrenzen konfigurierbar machen
Tests / test (push) Successful in 1m10s
2026-09-29 12:11:43 +00:00
dh fe966516b5 test: integrierte Freigabepruefungen stabilisieren
Tests / test (push) Successful in 1m14s
2026-09-29 12:06:51 +00:00
dh b3bbae1a72 fix(manager): Anlagentopologie immer synchronisieren
Tests / test (push) Failing after 50s
2026-09-29 11:51:55 +00:00
dh ae2cd8ecff Merge remote-tracking branch 'origin/develop' into develop
Tests / test (push) Successful in 46s
2026-09-29 11:43:12 +00:00
dh 995b704e36 fix(manager): Prognoseberechtigungen verständlich diagnostizieren
Tests / test (push) Successful in 49s
2026-09-29 11:40:24 +00:00
dh 5ff0d405d4 fix(manager): feste Leistungsangebote immer zuteilen 2026-09-29 11:40:24 +00:00
dh ccecc437e9 fix(easee): wartende Ladevorgaenge starten
Tests / test (push) Successful in 45s
2026-09-29 09:34:37 +00:00
dh 156591a91e fix(ladestation): Ladeende nach Update neu prüfen
Tests / test (push) Successful in 47s
2026-09-29 09:19:56 +00:00
dh e0814a0c14 fix(ladestation): Ladeende nach Update neu prüfen 2026-09-29 09:19:39 +00:00
dh 3cabf597ac fix(ladestation): Solarpause nicht als Ladeende erkennen 2026-09-29 09:07:56 +00:00
dh b86810d30f fix(ladestation): Solarpause nicht als Ladeende erkennen 2026-09-29 09:07:44 +00:00
dh d1fc62d43c fix(manager): Regelung bei veralteten Verbrauchern fortsetzen
Tests / test (push) Successful in 46s
2026-09-29 08:46:44 +00:00
dh 21577c0125 fix(manager): Prognoseberechtigungen verständlich diagnostizieren 2026-09-29 08:41:32 +00:00
dh aefe46352f fix(easee): Wartezustand ohne Ausgangsphase zulassen
Tests / test (push) Successful in 48s
2026-09-29 08:25:08 +00:00
dh 6f76e6cd86 fix(easee): Initialstatus der Ladestation laden
Tests / test (push) Successful in 48s
2026-09-29 07:55:31 +00:00
39 changed files with 2680 additions and 290 deletions
+7
View File
@@ -17,6 +17,13 @@ Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
- Stufenlose Leistungsbereiche fuer Batteriespeicher. - Stufenlose Leistungsbereiche fuer Batteriespeicher.
- Ladefreigabe neuer Ladestationsinstanzen standardmaessig aktiviert. - Ladefreigabe neuer Ladestationsinstanzen standardmaessig aktiviert.
- Stand-alone-Ladestationen erkennen nach einer einstellbaren Beobachtungszeit den niedrigeren Fahrzeug-Maximalstrom und begrenzen ihr Leistungsangebot ohne Ladeunterbruch.
- Easee-Ladestationen laden einen fehlenden Initialstatus ueber die Observations-API nach und starten bei noch unbekannter Ausgangsphase konservativ einphasig.
- Wartende Easee-Ladestationen erhalten bei positiver Stromvorgabe einmal pro Fahrzeugverbindung einen Startbefehl; Freigabe- und Blockiergruende werden diagnostiziert.
- Fehlende oder veraltete Verbraucher blockieren die Verteilung an aktuelle Verbraucher nicht mehr.
- Verbraucher gleicher Prioritaet werden wie in Enelix 1 anhand ihrer bezogenen Energie in 2-kWh-Gruppen ausgeglichen.
- Die Stoerungsueberwachung fordert einen fehlenden Geraetezugang innerhalb ihres Retry-Zyklus automatisch neu an.
- Der Warmwassererwaermer verwendet konfigurierbare Temperatur-Stoergrenzen von standardmaessig 0 bis 100 Grad C.
- Vollstaendige PHP-, Struktur- und Symcon-Modultests fuer alle Module. - Vollstaendige PHP-, Struktur- und Symcon-Modultests fuer alle Module.
### Hinweise ### Hinweise
+202 -29
View File
@@ -16,6 +16,7 @@ class EaseeGateway extends IPSModule
private const SIGNALR_BASE_URL = 'https://streams.easee.com/hubs/chargers'; private const SIGNALR_BASE_URL = 'https://streams.easee.com/hubs/chargers';
private const API_BASE_URL = 'https://api.easee.com'; private const API_BASE_URL = 'https://api.easee.com';
private const RECORD_SEPARATOR = "\x1e"; private const RECORD_SEPARATOR = "\x1e";
private const CACHE_MAXIMALALTER_SEKUNDEN = 60;
public function Create(): void public function Create(): void
{ {
@@ -50,6 +51,11 @@ class EaseeGateway extends IPSModule
0, 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'RefreshToken', false);" "IPS_RequestAction(\$_IPS['TARGET'], 'RefreshToken', false);"
); );
$this->RegisterTimer(
'ReconnectTimer',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'ReconnectAfterLoss', false);"
);
$this->RequireParent(self::WEBSOCKET_MODULE_ID); $this->RequireParent(self::WEBSOCKET_MODULE_ID);
} }
@@ -60,6 +66,7 @@ class EaseeGateway extends IPSModule
$this->SetTimerInterval('MaintainConnectionTimer', 0); $this->SetTimerInterval('MaintainConnectionTimer', 0);
$this->SetTimerInterval('TokenRefreshTimer', 0); $this->SetTimerInterval('TokenRefreshTimer', 0);
$this->SetTimerInterval('ReconnectTimer', 0);
$this->SetBuffer('SignalRReady', '0'); $this->SetBuffer('SignalRReady', '0');
$this->SetBuffer('ReceiveBuffer', ''); $this->SetBuffer('ReceiveBuffer', '');
$this->SetBuffer('Subscriptions', '{}'); $this->SetBuffer('Subscriptions', '{}');
@@ -111,8 +118,22 @@ class EaseeGateway extends IPSModule
$this->pflegeVerbindung(); $this->pflegeVerbindung();
return; return;
case 'ReconnectAfterLoss':
$this->SetTimerInterval('ReconnectTimer', 0);
if (
$this->ReadPropertyBoolean('Active')
&& !$this->ReadPropertyBoolean('Testmodus')
&& $this->GetBuffer('SignalRReady') !== '1'
) {
$this->verbindeSignalR();
}
return;
case 'RefreshToken': case 'RefreshToken':
if ($this->erneuereZugangsdaten(false)) { if (
$this->erneuereZugangsdaten(false)
&& $this->GetBuffer('SignalRReady') !== '1'
) {
$this->verbindeSignalR(); $this->verbindeSignalR();
} }
return; return;
@@ -142,6 +163,15 @@ class EaseeGateway extends IPSModule
$daten['value'] ?? null $daten['value'] ?? null
); );
return; return;
case 'TestSignalRFrame':
if (!$this->ReadPropertyBoolean('Testmodus') || !is_string($wert)) {
throw new InvalidArgumentException(
'TestSignalRFrame ist nur im Testmodus zulaessig.'
);
}
$this->verarbeiteSignalRRahmen($wert);
return;
} }
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident); throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
@@ -221,6 +251,12 @@ class EaseeGateway extends IPSModule
$seriennummer, $seriennummer,
(float) ($anfrage['amps'] ?? -1) (float) ($anfrage['amps'] ?? -1)
), JSON_THROW_ON_ERROR); ), JSON_THROW_ON_ERROR);
case 'StartCharging':
return json_encode(
$this->sendeLadestart($seriennummer),
JSON_THROW_ON_ERROR
);
} }
return json_encode([ return json_encode([
@@ -271,8 +307,10 @@ class EaseeGateway extends IPSModule
if ($letzterEmpfang > 0 && ($jetzt - $letzterEmpfang) <= 90) { if ($letzterEmpfang > 0 && ($jetzt - $letzterEmpfang) <= 90) {
return; return;
} }
$this->SetBuffer('SignalRReady', '0'); $this->planeWiederverbindung(
$this->setzeVerbunden(false); 'Seit mehr als 90 Sekunden keine Easee-Ereignisdaten empfangen.'
);
return;
} }
if (($jetzt - $letzteAushandlung) >= 45) { if (($jetzt - $letzteAushandlung) >= 45) {
@@ -285,16 +323,22 @@ class EaseeGateway extends IPSModule
private function verbindeSignalR(): bool private function verbindeSignalR(): bool
{ {
$this->SetTimerInterval('ReconnectTimer', 0);
if ($this->ReadPropertyBoolean('Testmodus')) { if ($this->ReadPropertyBoolean('Testmodus')) {
$this->SetStatus(102); $this->setzeEreignisverbindung(true);
$this->setzeVerbunden(true);
return true; return true;
} }
if (!$this->stelleAccessTokenSicher()) { if (!$this->stelleAccessTokenSicher()) {
$this->SetBuffer('SignalRReady', '0');
$this->setzeVerbunden(false);
$this->SetStatus(202); $this->SetStatus(202);
return false; return false;
} }
$this->setzeEreignisverbindung(
false,
'Easee-Ereignisverbindung wird aufgebaut.'
);
$this->SetBuffer('LastNegotiation', (string) time()); $this->SetBuffer('LastNegotiation', (string) time());
$token = $this->GetBuffer('AccessToken'); $token = $this->GetBuffer('AccessToken');
$antwort = $this->httpAnfrage( $antwort = $this->httpAnfrage(
@@ -313,8 +357,8 @@ class EaseeGateway extends IPSModule
); );
} }
if (!$antwort['success']) { if (!$antwort['success']) {
$this->SetStatus(203); $this->setzeEreignisverbindung(
$this->setzeLetztenFehler( false,
'Easee-Ereignisverbindung fehlgeschlagen: HTTP ' 'Easee-Ereignisverbindung fehlgeschlagen: HTTP '
. $antwort['httpCode'] . $antwort['httpCode']
. ($antwort['error'] !== '' ? ' / ' . $antwort['error'] : '') . ($antwort['error'] !== '' ? ' / ' . $antwort['error'] : '')
@@ -327,8 +371,10 @@ class EaseeGateway extends IPSModule
? (string) ($daten['connectionToken'] ?? $daten['connectionId'] ?? '') ? (string) ($daten['connectionToken'] ?? $daten['connectionId'] ?? '')
: ''; : '';
if ($verbindungstoken === '') { if ($verbindungstoken === '') {
$this->SetStatus(203); $this->setzeEreignisverbindung(
$this->setzeLetztenFehler('Easee liefert kein Verbindungstoken.'); false,
'Easee liefert kein Verbindungstoken.'
);
return false; return false;
} }
@@ -340,7 +386,7 @@ class EaseeGateway extends IPSModule
$this->SetBuffer('ReceiveBuffer', ''); $this->SetBuffer('ReceiveBuffer', '');
$this->SetBuffer('SubscribedThisConnection', '{}'); $this->SetBuffer('SubscribedThisConnection', '{}');
$this->SetBuffer('PendingInvocations', '{}'); $this->SetBuffer('PendingInvocations', '{}');
$this->WriteAttributeString('ObservationCache', '{}'); $this->markiereBeobachtungscacheVeraltet();
$this->setzeVerbunden(false); $this->setzeVerbunden(false);
if (!$this->konfiguriereWebSocketParent($url)) { if (!$this->konfiguriereWebSocketParent($url)) {
@@ -403,10 +449,7 @@ class EaseeGateway extends IPSModule
private function verarbeiteSignalRRahmen(string $rahmen): void private function verarbeiteSignalRRahmen(string $rahmen): void
{ {
if ($rahmen === '{}') { if ($rahmen === '{}') {
$this->SetBuffer('SignalRReady', '1'); $this->setzeEreignisverbindung(true);
$this->SetStatus(102);
$this->setzeLetztenFehler('');
$this->setzeVerbunden(true);
$this->sendeAlleAbonnements(); $this->sendeAlleAbonnements();
return; return;
} }
@@ -417,8 +460,9 @@ class EaseeGateway extends IPSModule
return; return;
} }
if (isset($nachricht['error'])) { if (isset($nachricht['error'])) {
$this->SetStatus(203); $this->planeWiederverbindung(
$this->setzeLetztenFehler('Easee-Ereignisfehler: ' . $nachricht['error']); 'Easee-Ereignisfehler: ' . $nachricht['error']
);
return; return;
} }
@@ -427,9 +471,9 @@ class EaseeGateway extends IPSModule
return; return;
} }
if ($typ === 7) { if ($typ === 7) {
$this->SetBuffer('SignalRReady', '0'); $this->planeWiederverbindung(
$this->setzeVerbunden(false); 'Easee-Ereignisverbindung wurde beendet.'
$this->setzeLetztenFehler('Easee-Ereignisverbindung wurde beendet.'); );
return; return;
} }
@@ -530,6 +574,25 @@ class EaseeGateway extends IPSModule
return; return;
} }
$this->speichereBeobachtungImCache($seriennummer, $id, $wert);
$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 speichereBeobachtungImCache(
string $seriennummer,
int $id,
$wert
): void {
$cache = $this->leseAttributArray('ObservationCache'); $cache = $this->leseAttributArray('ObservationCache');
if (!isset($cache[$seriennummer]) || !is_array($cache[$seriennummer])) { if (!isset($cache[$seriennummer]) || !is_array($cache[$seriennummer])) {
$cache[$seriennummer] = []; $cache[$seriennummer] = [];
@@ -545,21 +608,84 @@ class EaseeGateway extends IPSModule
'ObservationCache', 'ObservationCache',
json_encode($cache, JSON_THROW_ON_ERROR) json_encode($cache, JSON_THROW_ON_ERROR)
); );
}
$this->SendDataToChildren(json_encode([ private function markiereBeobachtungscacheVeraltet(): void
'DataID' => self::CHILD_EVENT_DATA_ID, {
'Buffer' => json_encode([ $cache = $this->leseAttributArray('ObservationCache');
'type' => 'Observation', foreach ($cache as &$zustand) {
'serialNumber' => $seriennummer, if (is_array($zustand)) {
'id' => $id, $zustand['updated'] = 0;
'value' => $wert, }
'timestamp' => time(), }
], JSON_THROW_ON_ERROR), unset($zustand);
], JSON_THROW_ON_ERROR)); $this->WriteAttributeString(
'ObservationCache',
json_encode($cache, JSON_THROW_ON_ERROR)
);
}
private function stelleAktuellenStatusSicher(string $seriennummer): void
{
$cache = $this->leseAttributArray('ObservationCache');
$zustand = $cache[$seriennummer] ?? [];
$cacheIstAktuell = is_array($zustand)
&& array_key_exists('109', $zustand)
&& (int) ($zustand['updated'] ?? 0) >= time() - self::CACHE_MAXIMALALTER_SEKUNDEN;
if (
$cacheIstAktuell
|| $this->ReadPropertyBoolean('Testmodus')
|| $this->GetBuffer('SignalRReady') !== '1'
) {
return;
}
$antwort = $this->autorisierteApiAnfrage(
'GET',
'/state/' . rawurlencode($seriennummer) . '/observations?ids='
. rawurlencode(implode(',', EaseeGatewayProtokoll::BEOBACHTUNGEN)),
''
);
if (!($antwort['success'] ?? false)) {
$this->setzeLetztenFehler(
'Easee-Status konnte nicht geladen werden: '
. (string) ($antwort['error'] ?? 'Unbekannter API-Fehler.')
);
return;
}
$daten = json_decode((string) ($antwort['body'] ?? ''), true);
if (!is_array($daten)) {
$this->setzeLetztenFehler('Easee-Statusantwort ist ungueltig.');
return;
}
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen(
$daten,
[$seriennummer],
$seriennummer
);
$betriebsstatusGeladen = false;
foreach ($beobachtungen as $beobachtung) {
$this->speichereBeobachtungImCache(
$beobachtung['Seriennummer'],
$beobachtung['ID'],
$beobachtung['Wert']
);
$betriebsstatusGeladen = $betriebsstatusGeladen
|| $beobachtung['ID'] === 109;
}
if (!$betriebsstatusGeladen) {
$this->setzeLetztenFehler(
'Easee-Statusantwort enthaelt keinen Betriebsstatus (109).'
);
return;
}
$this->setzeLetztenFehler('');
} }
private function erstelleStatusantwort(string $seriennummer): string private function erstelleStatusantwort(string $seriennummer): string
{ {
$this->stelleAktuellenStatusSicher($seriennummer);
$cache = $this->leseAttributArray('ObservationCache'); $cache = $this->leseAttributArray('ObservationCache');
return json_encode([ return json_encode([
'success' => true, 'success' => true,
@@ -606,6 +732,24 @@ class EaseeGateway extends IPSModule
); );
} }
/** @return array<string, mixed> */
private function sendeLadestart(string $seriennummer): array
{
if ($seriennummer === '') {
return ['success' => false, 'error' => 'Seriennummer fehlt.'];
}
if ($this->ReadPropertyBoolean('Testmodus')) {
return ['success' => true, 'httpCode' => 200, 'body' => '{}'];
}
return $this->autorisierteApiAnfrage(
'POST',
'/api/chargers/' . rawurlencode($seriennummer)
. '/commands/start_charging',
''
);
}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
private function autorisierteApiAnfrage( private function autorisierteApiAnfrage(
string $methode, string $methode,
@@ -758,9 +902,38 @@ class EaseeGateway extends IPSModule
]; ];
} }
private function setzeEreignisverbindung(
bool $verbunden,
string $fehler = ''
): void {
$this->SetBuffer('SignalRReady', $verbunden ? '1' : '0');
$this->SetStatus($verbunden ? 102 : 203);
$this->setzeLetztenFehler($verbunden ? '' : $fehler);
if ($verbunden) {
$this->SetTimerInterval('ReconnectTimer', 0);
}
$this->setzeVerbunden($verbunden);
}
private function planeWiederverbindung(string $fehler): void
{
$this->setzeEreignisverbindung(false, $fehler);
$this->SetBuffer('LastNegotiation', '0');
if (
$this->ReadPropertyBoolean('Active')
&& !$this->ReadPropertyBoolean('Testmodus')
) {
$this->SetTimerInterval('ReconnectTimer', 1000);
}
}
private function setzeVerbunden(bool $verbunden): void private function setzeVerbunden(bool $verbunden): void
{ {
$warVerbunden = (bool) $this->GetValue('Connected');
$this->SetValue('Connected', $verbunden); $this->SetValue('Connected', $verbunden);
if ($warVerbunden === $verbunden) {
return;
}
$this->SendDataToChildren(json_encode([ $this->SendDataToChildren(json_encode([
'DataID' => self::CHILD_EVENT_DATA_ID, 'DataID' => self::CHILD_EVENT_DATA_ID,
'Buffer' => json_encode([ 'Buffer' => json_encode([
+150 -13
View File
@@ -41,7 +41,13 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
'Stoertext', 'Stoertext',
'GatewayVerbunden', 'GatewayVerbunden',
'ApiMaximalstrom', 'ApiMaximalstrom',
'EaseeAktiviert',
'AutorisierungErforderlich',
'RemoteStartErforderlich',
'SmartCharging',
'GrundKeinLadestrom',
'LetzterGeraetebefehl', 'LetzterGeraetebefehl',
'LetzterStartbefehl',
'LeistungsangebotDiagnose', 'LeistungsangebotDiagnose',
]; ];
@@ -82,6 +88,11 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
$this->RegisterAttributeString('EaseeBeobachtungen', '{}'); $this->RegisterAttributeString('EaseeBeobachtungen', '{}');
$this->RegisterAttributeInteger('LetzteBeobachtung', 0); $this->RegisterAttributeInteger('LetzteBeobachtung', 0);
$this->RegisterAttributeInteger('ApiMaximalstrom', 16); $this->RegisterAttributeInteger('ApiMaximalstrom', 16);
$this->RegisterAttributeBoolean('ZustandEaseeAktiviert', true);
$this->RegisterAttributeBoolean('ZustandAutorisierungErforderlich', false);
$this->RegisterAttributeBoolean('ZustandRemoteStartErforderlich', false);
$this->RegisterAttributeBoolean('ZustandSmartCharging', false);
$this->RegisterAttributeInteger('ZustandGrundKeinLadestrom', 0);
$this->RegisterAttributeBoolean('ZustandFahrzeugVerbunden', false); $this->RegisterAttributeBoolean('ZustandFahrzeugVerbunden', false);
$this->RegisterAttributeBoolean('ZustandFahrzeugGeladen', false); $this->RegisterAttributeBoolean('ZustandFahrzeugGeladen', false);
$this->RegisterAttributeInteger('ZustandFahrzeugstatus', 0); $this->RegisterAttributeInteger('ZustandFahrzeugstatus', 0);
@@ -99,7 +110,9 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
$this->RegisterAttributeInteger('LetzteManagerID', 0); $this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0); $this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzterGesetzterStrom', -1); $this->RegisterAttributeInteger('LetzterGesetzterStrom', -1);
$this->RegisterAttributeBoolean('LadestartFuerVerbindungGesendet', false);
$this->RegisterAttributeString('LetzterGeraetebefehl', ''); $this->RegisterAttributeString('LetzterGeraetebefehl', '');
$this->RegisterAttributeString('LetzterStartbefehl', '');
$this->RegisterTimer( $this->RegisterTimer(
'Meldezyklus', 'Meldezyklus',
@@ -128,6 +141,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
$this->aktualisiereVariablen(); $this->aktualisiereVariablen();
$this->WriteAttributeBoolean('GatewayVerbunden', false); $this->WriteAttributeBoolean('GatewayVerbunden', false);
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1); $this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
$this->WriteAttributeBoolean('LadestartFuerVerbindungGesendet', false);
try { try {
$this->pruefeKonfiguration(); $this->pruefeKonfiguration();
@@ -405,6 +419,11 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
'Phasenzahl', 'Phasenzahl',
'Istleistung_W', 'Istleistung_W',
'Ladestrom_A', 'Ladestrom_A',
'EaseeAktiviert',
'AutorisierungErforderlich',
'RemoteStartErforderlich',
'SmartCharging',
'GrundKeinLadestrom',
] as $feld) { ] as $feld) {
$this->setzeZustand($feld, $status[$feld]); $this->setzeZustand($feld, $status[$feld]);
} }
@@ -416,13 +435,19 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
'ApiMaximalstrom', 'ApiMaximalstrom',
$status['ApiMaximalstrom_A'] $status['ApiMaximalstrom_A']
); );
if (!$status['FahrzeugVerbunden']) {
$this->WriteAttributeBoolean('LadestartFuerVerbindungGesendet', false);
}
$gatewayVerbunden = $this->ReadAttributeBoolean('GatewayVerbunden'); $gatewayVerbunden = $this->ReadAttributeBoolean('GatewayVerbunden');
$phaseGueltig = in_array($status['Phasenzahl'], [1, 3], true); $phaseGueltig = in_array($status['Phasenzahl'], [1, 3], true);
$regelPhasenzahl = $phaseGueltig ? $status['Phasenzahl'] : 1;
$phaseErforderlich = $status['Fahrzeugstatus'] === 3;
$steuerbar = $gatewayVerbunden $steuerbar = $gatewayVerbunden
&& $status['StatusGueltig'] && $status['StatusGueltig']
&& $status['EaseeAktiviert']
&& !$status['Stoerung'] && !$status['Stoerung']
&& (!$status['FahrzeugVerbunden'] || $phaseGueltig); && (!$phaseErforderlich || $phaseGueltig);
$solarladen = $this->ReadAttributeBoolean('ZustandSolarladen'); $solarladen = $this->ReadAttributeBoolean('ZustandSolarladen');
$peakbetrieb = $this->ReadAttributeString('Betriebsart') $peakbetrieb = $this->ReadAttributeString('Betriebsart')
@@ -432,7 +457,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
$this->ReadAttributeBoolean('ZustandLadefreigabe'), $this->ReadAttributeBoolean('ZustandLadefreigabe'),
$steuerbar && $status['FahrzeugVerbunden'], $steuerbar && $status['FahrzeugVerbunden'],
$status['FahrzeugGeladen'], $status['FahrzeugGeladen'],
$phaseGueltig ? $status['Phasenzahl'] : 1, $regelPhasenzahl,
$status['ApiMaximalstrom_A'], $status['ApiMaximalstrom_A'],
$solarladen, $solarladen,
$peakbetrieb $peakbetrieb
@@ -460,8 +485,8 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
} elseif ($status['Stoerung']) { } elseif ($status['Stoerung']) {
$stoertext = $status['Stoertext']; $stoertext = $status['Stoertext'];
$this->SetStatus(self::STATUS_LADEFEHLER); $this->SetStatus(self::STATUS_LADEFEHLER);
} elseif ($status['FahrzeugVerbunden'] && !$phaseGueltig) { } elseif ($phaseErforderlich && !$phaseGueltig) {
$stoertext = 'Easee meldet keine unterstuetzte aktive Ausgangsphase.'; $stoertext = 'Easee meldet beim Laden keine unterstuetzte aktive Ausgangsphase.';
$this->SetStatus(self::STATUS_WARTET_AUF_DATEN); $this->SetStatus(self::STATUS_WARTET_AUF_DATEN);
} else { } else {
$this->SetStatus(self::STATUS_AKTIV); $this->SetStatus(self::STATUS_AKTIV);
@@ -471,7 +496,8 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
if ($gatewayVerbunden && $status['StatusGueltig']) { if ($gatewayVerbunden && $status['StatusGueltig']) {
$this->setzeLadestrom( $this->setzeLadestrom(
$this->bestimmeWirksameSollleistung($angebot), $this->bestimmeWirksameSollleistung($angebot),
$status['Phasenzahl'] $regelPhasenzahl,
$status['Fahrzeugstatus'] === 2
); );
} else { } else {
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1); $this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
@@ -510,15 +536,15 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
return 0; return 0;
} }
private function setzeLadestrom(int $leistung, int $phasenzahl): void private function setzeLadestrom(
{ int $leistung,
int $phasenzahl,
bool $wartetAufStart
): void {
$strom = $leistung === 0 $strom = $leistung === 0
? 0 ? 0
: LadestationRegler::stromFuerLeistung($leistung, $phasenzahl); : LadestationRegler::stromFuerLeistung($leistung, $phasenzahl);
if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) { if ($strom !== $this->ReadAttributeInteger('LetzterGesetzterStrom')) {
return;
}
$antwort = $this->gatewayAnfrage([ $antwort = $this->gatewayAnfrage([
'action' => 'SetDynamicChargerCurrent', 'action' => 'SetDynamicChargerCurrent',
'serialNumber' => $this->seriennummer(), 'serialNumber' => $this->seriennummer(),
@@ -540,6 +566,36 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
$this->WriteAttributeInteger('LetzterGesetzterStrom', $strom); $this->WriteAttributeInteger('LetzterGesetzterStrom', $strom);
} }
if (
$strom > 0
&& $wartetAufStart
&& !$this->ReadAttributeBoolean('LadestartFuerVerbindungGesendet')
) {
$this->sendeLadestart();
}
}
private function sendeLadestart(): void
{
$antwort = $this->gatewayAnfrage([
'action' => 'StartCharging',
'serialNumber' => $this->seriennummer(),
]);
if (!($antwort['success'] ?? false)) {
throw new RuntimeException(
(string) ($antwort['error'] ?? 'Easee-Ladestart fehlgeschlagen.')
);
}
$befehl = json_encode([
'Aktion' => 'StartCharging',
'Seriennummer' => $this->seriennummer(),
], JSON_THROW_ON_ERROR);
$this->WriteAttributeBoolean('LadestartFuerVerbindungGesendet', true);
$this->WriteAttributeString('LetzterStartbefehl', $befehl);
$this->setzeSichtbareVariable('LetzterStartbefehl', $befehl);
}
private function seriennummer(): string private function seriennummer(): string
{ {
return EaseeGatewayProtokoll::seriennummer( return EaseeGatewayProtokoll::seriennummer(
@@ -648,6 +704,36 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
'Wert' => $this->ReadAttributeBoolean('ZustandSolarladen'), 'Wert' => $this->ReadAttributeBoolean('ZustandSolarladen'),
'Einheit' => '', 'Einheit' => '',
], ],
[
'Kennung' => 'EaseeAktiviert',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('EaseeAktiviert'),
'Einheit' => '',
],
[
'Kennung' => 'AutorisierungErforderlich',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('AutorisierungErforderlich'),
'Einheit' => '',
],
[
'Kennung' => 'RemoteStartErforderlich',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('RemoteStartErforderlich'),
'Einheit' => '',
],
[
'Kennung' => 'SmartCharging',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('SmartCharging'),
'Einheit' => '',
],
[
'Kennung' => 'GrundKeinLadestrom',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('GrundKeinLadestrom'),
'Einheit' => '',
],
[ [
'Kennung' => 'Ladefehler', 'Kennung' => 'Ladefehler',
'Art' => 'Stoerung', 'Art' => 'Stoerung',
@@ -813,17 +899,53 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
'', '',
210 210
); );
$this->RegisterVariableBoolean(
'EaseeAktiviert',
'Easee aktiviert',
'~Switch',
220
);
$this->RegisterVariableBoolean(
'AutorisierungErforderlich',
'Autorisierung erforderlich',
'~Switch',
230
);
$this->RegisterVariableBoolean(
'RemoteStartErforderlich',
'Remote-Start erforderlich',
'~Switch',
240
);
$this->RegisterVariableBoolean(
'SmartCharging',
'Easee Smart Charging',
'~Switch',
250
);
$this->RegisterVariableInteger(
'GrundKeinLadestrom',
'Grund kein Ladestrom',
'',
260
);
$this->RegisterVariableString( $this->RegisterVariableString(
'LetzterGeraetebefehl', 'LetzterGeraetebefehl',
'Letzter Geraetebefehl', 'Letzter Geraetebefehl',
'', '',
220 270
);
$this->RegisterVariableString(
'LetzterStartbefehl',
'Letzter Startbefehl',
'',
280
); );
$this->RegisterVariableString( $this->RegisterVariableString(
'LeistungsangebotDiagnose', 'LeistungsangebotDiagnose',
'Leistungsangebot', 'Leistungsangebot',
'', '',
230 290
); );
$this->SetValue( $this->SetValue(
'LeistungsangebotDiagnose', 'LeistungsangebotDiagnose',
@@ -833,11 +955,16 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
'LetzterGeraetebefehl', 'LetzterGeraetebefehl',
$this->ReadAttributeString('LetzterGeraetebefehl') $this->ReadAttributeString('LetzterGeraetebefehl')
); );
$this->SetValue(
'LetzterStartbefehl',
$this->ReadAttributeString('LetzterStartbefehl')
);
foreach (self::DIAGNOSE_VARIABLEN as $ident) { foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if (!in_array($ident, [ if (!in_array($ident, [
'GatewayVerbunden', 'GatewayVerbunden',
'ApiMaximalstrom', 'ApiMaximalstrom',
'LetzterGeraetebefehl', 'LetzterGeraetebefehl',
'LetzterStartbefehl',
'LeistungsangebotDiagnose', 'LeistungsangebotDiagnose',
], true)) { ], true)) {
$this->setzeSichtbareVariable($ident, $this->leseZustand($ident)); $this->setzeSichtbareVariable($ident, $this->leseZustand($ident));
@@ -872,6 +999,10 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
switch ($ident) { switch ($ident) {
case 'FahrzeugVerbunden': case 'FahrzeugVerbunden':
case 'FahrzeugGeladen': case 'FahrzeugGeladen':
case 'EaseeAktiviert':
case 'AutorisierungErforderlich':
case 'RemoteStartErforderlich':
case 'SmartCharging':
case 'SollwertGueltig': case 'SollwertGueltig':
case 'Verfuegbar': case 'Verfuegbar':
case 'AenderungMoeglich': case 'AenderungMoeglich':
@@ -880,6 +1011,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
break; break;
case 'Fahrzeugstatus': case 'Fahrzeugstatus':
case 'Phasenzahl': case 'Phasenzahl':
case 'GrundKeinLadestrom':
case 'Sollleistung': case 'Sollleistung':
$this->WriteAttributeInteger('Zustand' . $ident, (int) $wert); $this->WriteAttributeInteger('Zustand' . $ident, (int) $wert);
break; break;
@@ -915,6 +1047,10 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
if (in_array($ident, [ if (in_array($ident, [
'FahrzeugVerbunden', 'FahrzeugVerbunden',
'FahrzeugGeladen', 'FahrzeugGeladen',
'EaseeAktiviert',
'AutorisierungErforderlich',
'RemoteStartErforderlich',
'SmartCharging',
'SollwertGueltig', 'SollwertGueltig',
'Verfuegbar', 'Verfuegbar',
'AenderungMoeglich', 'AenderungMoeglich',
@@ -925,6 +1061,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
if (in_array($ident, [ if (in_array($ident, [
'Fahrzeugstatus', 'Fahrzeugstatus',
'Phasenzahl', 'Phasenzahl',
'GrundKeinLadestrom',
'Sollleistung', 'Sollleistung',
], true)) { ], true)) {
return $this->ReadAttributeInteger('Zustand' . $ident); return $this->ReadAttributeInteger('Zustand' . $ident);
+35
View File
@@ -37,6 +37,41 @@
"caption": "Geraetestatus abfragen", "caption": "Geraetestatus abfragen",
"suffix": " s", "suffix": " s",
"minimum": 1 "minimum": 1
},
{
"type": "NumberSpinner",
"name": "Phasenerkennungszeit",
"caption": "Phasen erkennen nach",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "FahrzeugstromErkennungszeit",
"caption": "Fahrzeug-Maximalstrom erkennen nach",
"suffix": " s",
"minimum": 30
},
{
"type": "NumberSpinner",
"name": "ZeitZwischenZustandswechseln",
"caption": "Zeit zwischen Leistungsaenderungen",
"suffix": " min",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Mindesteinschaltdauer",
"caption": "Mindesteinschaltdauer",
"suffix": " min",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Mindestausschaltdauer",
"caption": "Mindestausschaltdauer",
"suffix": " min",
"minimum": 0
} }
] ]
}, },
+292 -7
View File
@@ -34,6 +34,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
'AenderungMoeglich', 'AenderungMoeglich',
'Stoerung', 'Stoerung',
'Stoertext', 'Stoertext',
'FahrzeugMaximalstrom',
'LetzterGeraetebefehl', 'LetzterGeraetebefehl',
'LeistungsangebotDiagnose', 'LeistungsangebotDiagnose',
]; ];
@@ -50,6 +51,11 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->RegisterPropertyString('Benutzername', ''); $this->RegisterPropertyString('Benutzername', '');
$this->RegisterPropertyString('Passwort', ''); $this->RegisterPropertyString('Passwort', '');
$this->RegisterPropertyInteger('MaximalerLadestrom', 16); $this->RegisterPropertyInteger('MaximalerLadestrom', 16);
$this->RegisterPropertyInteger('Phasenerkennungszeit', 60);
$this->RegisterPropertyInteger('FahrzeugstromErkennungszeit', 90);
$this->RegisterPropertyInteger('ZeitZwischenZustandswechseln', 1);
$this->RegisterPropertyInteger('Mindesteinschaltdauer', 0);
$this->RegisterPropertyInteger('Mindestausschaltdauer', 0);
$this->RegisterPropertyInteger('Abfrageintervall', 5); $this->RegisterPropertyInteger('Abfrageintervall', 5);
$this->RegisterPropertyBoolean('Ladefreigabe', true); $this->RegisterPropertyBoolean('Ladefreigabe', true);
$this->RegisterPropertyBoolean('Solarladen', true); $this->RegisterPropertyBoolean('Solarladen', true);
@@ -87,6 +93,16 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->RegisterAttributeInteger('LetzteManagerID', 0); $this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0); $this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzterGesetzterStrom', -1); $this->RegisterAttributeInteger('LetzterGesetzterStrom', -1);
$this->RegisterAttributeInteger('LetzteGesetzteLeistung', 0);
$this->RegisterAttributeInteger('LeistungswechselGesperrtBis', 0);
$this->RegisterAttributeInteger('AusschaltenGesperrtBis', 0);
$this->RegisterAttributeInteger('EinschaltenGesperrtBis', 0);
$this->RegisterAttributeInteger('PhasenerkennungSeit', 0);
$this->RegisterAttributeInteger('PhasenerkennungVersion', 0);
$this->RegisterAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
$this->RegisterAttributeInteger('ErkannterMaximalstrom', 32);
$this->RegisterAttributeInteger('FahrzeugstromKandidat', 0);
$this->RegisterAttributeInteger('FahrzeugstromKandidatSeit', 0);
$this->RegisterTimer( $this->RegisterTimer(
'Statuszyklus', 'Statuszyklus',
@@ -117,6 +133,28 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->initialisiereLokaleEinstellungen(); $this->initialisiereLokaleEinstellungen();
$this->aktualisiereVariablen(); $this->aktualisiereVariablen();
// Ein persistiertes Ladeende nach Neueinrichtung oder Modulupdate einmal neu pruefen.
$this->setzeZustand('FahrzeugGeladen', false);
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
$this->WriteAttributeInteger('LetzteGesetzteLeistung', 0);
$this->WriteAttributeInteger('LeistungswechselGesperrtBis', 0);
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
if ($this->ReadAttributeInteger('PhasenerkennungVersion') < 1) {
$this->WriteAttributeInteger(
'ZustandPhasenzahl',
LadestationRegler::PHASEN_UNBEKANNT
);
$this->WriteAttributeInteger('PhasenerkennungVersion', 1);
}
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
$this->WriteAttributeInteger(
'ErkannterMaximalstrom',
$this->ReadPropertyInteger('MaximalerLadestrom')
);
$this->setzeFahrzeugstromKandidatZurueck();
try { try {
$this->pruefeKonfiguration(); $this->pruefeKonfiguration();
} catch (Throwable $fehler) { } catch (Throwable $fehler) {
@@ -239,7 +277,11 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$verbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden'); $verbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden');
$geladen = $this->ReadAttributeBoolean('ZustandFahrzeugGeladen'); $geladen = $this->ReadAttributeBoolean('ZustandFahrzeugGeladen');
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl'); $phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom'); $maximalstrom = min(
$this->ReadPropertyInteger('MaximalerLadestrom'),
max(6, $this->ReadAttributeInteger('ErkannterMaximalstrom'))
);
$this->setzeSichtbareVariable('FahrzeugMaximalstrom', $maximalstrom);
$solarladen = $this->ReadAttributeBoolean('ZustandSolarladen'); $solarladen = $this->ReadAttributeBoolean('ZustandSolarladen');
$peakbetrieb = $this->ReadAttributeString('Betriebsart') $peakbetrieb = $this->ReadAttributeString('Betriebsart')
=== Nachrichtenvertrag::BETRIEBSART_PEAK; === Nachrichtenvertrag::BETRIEBSART_PEAK;
@@ -263,6 +305,40 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$solarladen, $solarladen,
$peakbetrieb $peakbetrieb
); );
if ($aktiv
&& $ladefreigabe
&& $verbunden
&& !$geladen
&& !($peakbetrieb && $solarladen)
&& $phasenzahl !== LadestationRegler::PHASEN_UNBEKANNT
) {
$jetzt = time();
$letzteLeistung = $this->ReadAttributeInteger('LetzteGesetzteLeistung');
if ($jetzt < $this->ReadAttributeInteger('LeistungswechselGesperrtBis')) {
$angebot = [$letzteLeistung];
} elseif ($letzteLeistung > 0
&& $jetzt < $this->ReadAttributeInteger('AusschaltenGesperrtBis')
) {
$positiveStufen = array_values(array_filter(
LadestationRegler::leistungsangebot(
$aktiv,
$ladefreigabe,
$verbunden,
$geladen,
$phasenzahl,
$maximalstrom,
$solarladen,
false
),
static fn (int $wert): bool => $wert > 0
));
$angebot = $positiveStufen !== [] ? $positiveStufen : [$letzteLeistung];
} elseif ($letzteLeistung <= 0
&& $jetzt < $this->ReadAttributeInteger('EinschaltenGesperrtBis')
) {
$angebot = [0];
}
}
$angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR); $angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR);
$this->WriteAttributeString('Leistungsangebot', $angebotJson); $this->WriteAttributeString('Leistungsangebot', $angebotJson);
$this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson); $this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson);
@@ -270,7 +346,14 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->setzeZustand('AenderungMoeglich', $verfuegbar && count($angebot) > 1); $this->setzeZustand('AenderungMoeglich', $verfuegbar && count($angebot) > 1);
$sollleistung = $this->bestimmeWirksameSollleistung($angebot); $sollleistung = $this->bestimmeWirksameSollleistung($angebot);
if (!($aktiv
&& $ladefreigabe
&& $verbunden
&& !$geladen
&& $phasenzahl === LadestationRegler::PHASEN_UNBEKANNT
)) {
$this->setzeLadestrom($sollleistung); $this->setzeLadestrom($sollleistung);
}
$this->setzeStoerung(''); $this->setzeStoerung('');
$this->SetStatus(self::STATUS_AKTIV); $this->SetStatus(self::STATUS_AKTIV);
} catch (Throwable $fehler) { } catch (Throwable $fehler) {
@@ -299,16 +382,126 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->ReadPropertyInteger('Geraetetyp'), $this->ReadPropertyInteger('Geraetetyp'),
$antwort $antwort
); );
$warVerbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden');
$verbunden = $rohstatus['Rohstatus'] !== 1;
$bekanntePhasenzahl = $warVerbunden
? $this->ReadAttributeInteger('ZustandPhasenzahl')
: LadestationRegler::PHASEN_UNBEKANNT;
if (!$verbunden) {
$bekanntePhasenzahl = LadestationRegler::PHASEN_UNBEKANNT;
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
$this->WriteAttributeInteger('LeistungswechselGesperrtBis', 0);
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
} elseif ($bekanntePhasenzahl === LadestationRegler::PHASEN_UNBEKANNT) {
$direktErkanntePhasen = (int) ($rohstatus['Phasenzahl']
?? LadestationRegler::PHASEN_UNBEKANNT);
if (in_array(
$direktErkanntePhasen,
[LadestationRegler::PHASEN_EIN, LadestationRegler::PHASEN_DREI],
true
)) {
$bekanntePhasenzahl = $direktErkanntePhasen;
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
} elseif ($rohstatus['Istleistung_W'] > 7500.0) {
$bekanntePhasenzahl = LadestationRegler::PHASEN_DREI;
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
} elseif ((bool) $this->GetValue('Aktiv')
&& $this->ReadAttributeBoolean('ZustandLadefreigabe')
) {
$erkennungSeit = $this->ReadAttributeInteger('PhasenerkennungSeit');
if ($erkennungSeit <= 0) {
$this->sendeLadestromAnGeraet(
$this->ReadPropertyInteger('MaximalerLadestrom')
);
$erkennungSeit = time();
$this->WriteAttributeInteger('PhasenerkennungSeit', $erkennungSeit);
} elseif ((time() - $erkennungSeit)
>= $this->ReadPropertyInteger('Phasenerkennungszeit')
) {
$bekanntePhasenzahl = $rohstatus['Istleistung_W'] > 7500.0
? LadestationRegler::PHASEN_DREI
: LadestationRegler::PHASEN_EIN;
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
}
} else {
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
}
}
$status = LadestationRegler::normalisiereStatus( $status = LadestationRegler::normalisiereStatus(
$rohstatus, $rohstatus,
$this->ReadAttributeBoolean('ZustandFahrzeugVerbunden') $warVerbunden,
$this->ReadAttributeBoolean('ZustandFahrzeugGeladen'),
$this->ReadAttributeInteger('LetzterGesetzterStrom') >= 6,
$this->ReadAttributeBoolean('LadeleistungSeitFreigabeErkannt'),
$bekanntePhasenzahl
); );
if (!$status['FahrzeugVerbunden']) {
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
} elseif ($status['Ladestrom_A'] >= 3.5) {
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', true);
}
if (!$status['FahrzeugVerbunden']
|| $status['Phasenzahl'] !== LadestationRegler::PHASEN_UNBEKANNT
) {
$this->aktualisiereFahrzeugstromgrenze($status);
}
foreach ($status as $ident => $wert) { foreach ($status as $ident => $wert) {
$this->setzeZustand($ident, $wert); $this->setzeZustand($ident, $wert);
} }
} }
/** @param array<string, int|float|bool> $status */
private function aktualisiereFahrzeugstromgrenze(array $status): void
{
$konfigurierterMaximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
if (!$status['FahrzeugVerbunden']) {
$this->WriteAttributeInteger('ErkannterMaximalstrom', $konfigurierterMaximalstrom);
$this->setzeSichtbareVariable('FahrzeugMaximalstrom', $konfigurierterMaximalstrom);
$this->setzeFahrzeugstromKandidatZurueck();
return;
}
$kandidat = LadestationRegler::ermittleFahrzeugMaximalstrom(
$this->ReadAttributeInteger('LetzterGesetzterStrom'),
(float) $status['Ladestrom_A'],
$konfigurierterMaximalstrom
);
if ($kandidat === null
|| $kandidat >= $this->ReadAttributeInteger('ErkannterMaximalstrom')
) {
$this->setzeFahrzeugstromKandidatZurueck();
return;
}
$jetzt = time();
if ($this->ReadAttributeInteger('FahrzeugstromKandidat') !== $kandidat) {
$this->WriteAttributeInteger('FahrzeugstromKandidat', $kandidat);
$this->WriteAttributeInteger('FahrzeugstromKandidatSeit', $jetzt);
return;
}
if (($jetzt - $this->ReadAttributeInteger('FahrzeugstromKandidatSeit'))
< $this->ReadPropertyInteger('FahrzeugstromErkennungszeit')
) {
return;
}
$this->WriteAttributeInteger('ErkannterMaximalstrom', $kandidat);
$this->WriteAttributeInteger('LeistungswechselGesperrtBis', 0);
$this->setzeSichtbareVariable('FahrzeugMaximalstrom', $kandidat);
$this->setzeFahrzeugstromKandidatZurueck();
}
private function setzeFahrzeugstromKandidatZurueck(): void
{
$this->WriteAttributeInteger('FahrzeugstromKandidat', 0);
$this->WriteAttributeInteger('FahrzeugstromKandidatSeit', 0);
}
/** @param list<int> $angebot */ /** @param list<int> $angebot */
private function bestimmeWirksameSollleistung(array $angebot): int private function bestimmeWirksameSollleistung(array $angebot): int
{ {
@@ -318,6 +511,14 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
return $sollleistung; return $sollleistung;
} }
$this->setzeZustand('SollwertGueltig', false); $this->setzeZustand('SollwertGueltig', false);
$ersatz = 0;
foreach ($angebot as $leistungswert) {
if ($leistungswert > $sollleistung) {
break;
}
$ersatz = $leistungswert;
}
return $ersatz;
} }
if (!$this->ReadAttributeBoolean('ZustandSolarladen') && $angebot !== [0]) { if (!$this->ReadAttributeBoolean('ZustandSolarladen') && $angebot !== [0]) {
@@ -333,12 +534,61 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$strom = $leistung === 0 $strom = $leistung === 0
? 0 ? 0
: LadestationRegler::stromFuerLeistung($leistung, $phasenzahl); : LadestationRegler::stromFuerLeistung($leistung, $phasenzahl);
if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) { $bisherigeLeistung = $this->ReadAttributeInteger('LetzteGesetzteLeistung');
$stromUnveraendert = $strom === $this->ReadAttributeInteger('LetzterGesetzterStrom');
if ($stromUnveraendert && $leistung === $bisherigeLeistung) {
return;
}
if (!$stromUnveraendert) {
$this->sendeLadestromAnGeraet($strom);
}
if ($leistung === $bisherigeLeistung) {
return; return;
} }
if (!$this->ReadAttributeBoolean('ZustandFahrzeugVerbunden')) {
$leistungswechselGesperrtBis = 0;
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
} else {
$jetzt = time();
$wechselzeit = $this->ReadPropertyInteger('ZeitZwischenZustandswechseln');
$leistungswechselGesperrtBis = $wechselzeit > 0
? $jetzt + ($wechselzeit * 60)
: 0;
if ($bisherigeLeistung <= 0 && $leistung > 0) {
$mindestzeit = $this->ReadPropertyInteger('Mindesteinschaltdauer');
$this->WriteAttributeInteger(
'AusschaltenGesperrtBis',
$mindestzeit > 0 ? $jetzt + ($mindestzeit * 60) : 0
);
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
} elseif ($bisherigeLeistung > 0 && $leistung <= 0) {
$mindestzeit = $this->ReadPropertyInteger('Mindestausschaltdauer');
$this->WriteAttributeInteger(
'EinschaltenGesperrtBis',
$mindestzeit > 0 ? $jetzt + ($mindestzeit * 60) : 0
);
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
}
}
$this->WriteAttributeInteger('LetzteGesetzteLeistung', $leistung);
$this->WriteAttributeInteger(
'LeistungswechselGesperrtBis',
$leistungswechselGesperrtBis
);
}
private function sendeLadestromAnGeraet(int $strom): void
{
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
$this->setzeFahrzeugstromKandidatZurueck();
$transport = new LadestationTransport( $transport = new LadestationTransport(
fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage) fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage, true)
); );
$transport->ladestromSetzen( $transport->ladestromSetzen(
$this->ReadPropertyInteger('Geraetetyp'), $this->ReadPropertyInteger('Geraetetyp'),
@@ -351,8 +601,12 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
/** /**
* @param array<string, mixed> $anfrage * @param array<string, mixed> $anfrage
*/ */
private function fuehreHttpAnfrageAus(array $anfrage): string private function fuehreHttpAnfrageAus(
array $anfrage,
bool $alsGeraetebefehl = false
): string
{ {
if ($alsGeraetebefehl) {
$protokoll = [ $protokoll = [
'Methode' => $anfrage['Methode'], 'Methode' => $anfrage['Methode'],
'URL' => $anfrage['URL'], 'URL' => $anfrage['URL'],
@@ -361,6 +615,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
'LetzterGeraetebefehl', 'LetzterGeraetebefehl',
json_encode($protokoll, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) json_encode($protokoll, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
); );
}
if ($this->ReadPropertyBoolean('Testmodus')) { if ($this->ReadPropertyBoolean('Testmodus')) {
$url = (string) $anfrage['URL']; $url = (string) $anfrage['URL'];
@@ -571,11 +826,27 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
throw new InvalidArgumentException($property . ' muss mindestens 0 sein.'); throw new InvalidArgumentException($property . ' muss mindestens 0 sein.');
} }
} }
foreach (['Meldeintervall', 'VorgabeTimeout', 'Abfrageintervall'] as $property) { foreach ([
'Meldeintervall',
'VorgabeTimeout',
'Abfrageintervall',
'Phasenerkennungszeit',
'FahrzeugstromErkennungszeit',
] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) { if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
} }
} }
foreach ([
'ZeitZwischenZustandswechseln',
'Mindesteinschaltdauer',
'Mindestausschaltdauer',
] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException($property . ' darf nicht negativ sein.');
}
}
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom'); $maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
if ($maximalstrom < 6 || $maximalstrom > 32) { if ($maximalstrom < 6 || $maximalstrom > 32) {
throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.'); throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.');
@@ -627,12 +898,26 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->registriereVerbraucherDiagnose(); $this->registriereVerbraucherDiagnose();
$this->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 200); $this->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 200);
$this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 210); $this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 210);
$this->RegisterVariableInteger(
'FahrzeugMaximalstrom',
'Erkannter Fahrzeug-Maximalstrom',
'',
220
);
$this->SetValue(
'FahrzeugMaximalstrom',
$this->ReadAttributeInteger('ErkannterMaximalstrom')
);
$this->SetValue( $this->SetValue(
'LeistungsangebotDiagnose', 'LeistungsangebotDiagnose',
$this->ReadAttributeString('Leistungsangebot') $this->ReadAttributeString('Leistungsangebot')
); );
foreach (self::DIAGNOSE_VARIABLEN as $ident) { foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if (!in_array($ident, ['LetzterGeraetebefehl', 'LeistungsangebotDiagnose'], true)) { if (!in_array($ident, [
'LetzterGeraetebefehl',
'LeistungsangebotDiagnose',
'FahrzeugMaximalstrom',
], true)) {
$this->setzeSichtbareVariable($ident, $this->leseZustand($ident)); $this->setzeSichtbareVariable($ident, $this->leseZustand($ident));
} }
} }
+9
View File
@@ -742,6 +742,15 @@
"name": "EnergieflussAnzeigen", "name": "EnergieflussAnzeigen",
"caption": "Energiefluss unter dem Manager anzeigen", "caption": "Energiefluss unter dem Manager anzeigen",
"onChange": "IPS_RequestAction($id, 'FormEnergieflussAnzeigen', $EnergieflussAnzeigen);" "onChange": "IPS_RequestAction($id, 'FormEnergieflussAnzeigen', $EnergieflussAnzeigen);"
},
{
"type": "Select",
"name": "EnergieflussLeistungseinheit",
"caption": "Leistungseinheit im Energiefluss",
"options": [
{"caption": "Watt (W)", "value": 0},
{"caption": "Kilowatt (kW)", "value": 1}
]
} }
] ]
}, },
+128 -65
View File
@@ -173,6 +173,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterPropertyBoolean('EnergiediagrammeAnzeigen', false); $this->RegisterPropertyBoolean('EnergiediagrammeAnzeigen', false);
$this->RegisterPropertyBoolean('FunFactsAnzeigen', false); $this->RegisterPropertyBoolean('FunFactsAnzeigen', false);
$this->RegisterPropertyBoolean('EnergieflussAnzeigen', false); $this->RegisterPropertyBoolean('EnergieflussAnzeigen', false);
$this->RegisterPropertyInteger('EnergieflussLeistungseinheit', 0);
$this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10); $this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10);
$this->EnableAction('Aktiv'); $this->EnableAction('Aktiv');
@@ -180,6 +181,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterVariableFloat('Netzleistung', 'Netzleistung', '', 30); $this->RegisterVariableFloat('Netzleistung', 'Netzleistung', '', 30);
$this->RegisterAttributeString('VerbraucherCache', '{}'); $this->RegisterAttributeString('VerbraucherCache', '{}');
$this->RegisterAttributeString('VerbraucherEnergie', '{}');
$this->RegisterAttributeString('LetzteSollwerte', '{}'); $this->RegisterAttributeString('LetzteSollwerte', '{}');
$this->RegisterAttributeString('LetztePvGrenzen', '{}'); $this->RegisterAttributeString('LetztePvGrenzen', '{}');
$this->RegisterAttributeString('Netzfahrplan', '{}'); $this->RegisterAttributeString('Netzfahrplan', '{}');
@@ -266,8 +268,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
} }
$this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen')); $this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen'));
$this->aktualisiereAnschlussstatus(); $this->aktualisiereAnschlussstatus();
$geraetezugangFehlt = ($this->ReadPropertyBoolean('PrognoseAktiv') $geraetezugangFehlt = $this->geraetezugangBenoetigt()
|| $this->ReadPropertyBoolean('StoerueberwachungAktiv'))
&& $this->ReadAttributeString('PrognoseInstallationsToken') === ''; && $this->ReadAttributeString('PrognoseInstallationsToken') === '';
if (!$this->aktualisiereLizenzfreigabe( if (!$this->aktualisiereLizenzfreigabe(
$this->ReadPropertyString('Lizenzcode'), $this->ReadPropertyString('Lizenzcode'),
@@ -431,6 +432,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
if ((int) $senderID === $this->ReadPropertyInteger('NetzleistungVariableID') if ((int) $senderID === $this->ReadPropertyInteger('NetzleistungVariableID')
&& (int) $nachricht === self::VM_UPDATE && (int) $nachricht === self::VM_UPDATE
) { ) {
$this->aktualisiereEnergieflusswerte();
$this->regeln(false); $this->regeln(false);
} }
} }
@@ -500,7 +502,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
if (!is_string($wert)) { if (!is_string($wert)) {
throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.'); throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.');
} }
$this->aktualisiereLizenzfreigabe($wert, true); if ($this->aktualisiereLizenzfreigabe($wert, true)) {
$this->synchronisiereAnlagentopologie();
}
$this->UpdateFormField('LizenzInformation', 'caption', $this->lizenzStatustext($wert)); $this->UpdateFormField('LizenzInformation', 'caption', $this->lizenzStatustext($wert));
$this->aktualisiereLastspitzenformular($wert); $this->aktualisiereLastspitzenformular($wert);
$this->aktualisierePrognoseformular($wert); $this->aktualisierePrognoseformular($wert);
@@ -602,15 +606,79 @@ class Manager extends IPSModule implements ManagerSchnittstelle
throw new InvalidArgumentException('Der Absender ist diesem Manager nicht aktiv zugeordnet.'); throw new InvalidArgumentException('Der Absender ist diesem Manager nicht aktiv zugeordnet.');
} }
$jetzt = time();
$this->aktualisiereVerbraucherEnergie($absenderID, $daten, $jetzt);
$cache = $this->leseJsonAttribut('VerbraucherCache'); $cache = $this->leseJsonAttribut('VerbraucherCache');
$cache[(string) $absenderID] = [ $cache[(string) $absenderID] = [
'EmpfangenAm' => time(), 'EmpfangenAm' => $jetzt,
'Daten' => $daten, 'Daten' => $daten,
]; ];
$this->WriteAttributeString('VerbraucherCache', json_encode($cache, JSON_THROW_ON_ERROR)); $this->WriteAttributeString('VerbraucherCache', json_encode($cache, JSON_THROW_ON_ERROR));
$this->aktualisiereEnergieflusswerte();
$this->regeln(false); $this->regeln(false);
} }
/** @param array<string, mixed> $daten */
private function aktualisiereVerbraucherEnergie(int $instanzID, array $daten, int $jetzt): void
{
$energien = $this->leseJsonAttribut('VerbraucherEnergie');
$bisher = $energien[(string) $instanzID] ?? [];
$energieWh = is_array($bisher) && is_numeric($bisher['Energie_Wh'] ?? null)
? (float) $bisher['Energie_Wh']
: 0.0;
$letzterZeitpunkt = is_array($bisher) && is_int($bisher['Zeitpunkt'] ?? null)
? $bisher['Zeitpunkt']
: 0;
$letzteLeistung = is_array($bisher) && is_numeric($bisher['Leistung_W'] ?? null)
? (float) $bisher['Leistung_W']
: 0.0;
if ($letzterZeitpunkt > 0 && $jetzt > $letzterZeitpunkt) {
$dauer = min(
$jetzt - $letzterZeitpunkt,
$this->ReadPropertyInteger('VerbraucherTimeout')
);
$energieWh += $letzteLeistung * $dauer / 3600.0;
}
$energien[(string) $instanzID] = [
'Energie_Wh' => $energieWh,
'Zeitpunkt' => $jetzt,
'Leistung_W' => $this->verbraucherleistung($daten),
];
$this->WriteAttributeString(
'VerbraucherEnergie',
json_encode($energien, JSON_THROW_ON_ERROR)
);
}
/** @param array<string, mixed> $daten */
private function verbraucherleistung(array $daten): float
{
if ($daten['Leistungsquelle'] !== Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN
&& is_numeric($daten['Istleistung_W'])
) {
return (float) $daten['Istleistung_W'];
}
return (float) (ManagerRegler::angenommenerSollwert($daten) ?? 0);
}
/** @return array<int, float> */
private function bezogeneVerbraucherEnergienWh(): array
{
$ergebnis = [];
foreach ($this->leseJsonAttribut('VerbraucherEnergie') as $instanzID => $eintrag) {
if (!is_array($eintrag) || !is_numeric($eintrag['Energie_Wh'] ?? null)) {
continue;
}
$ergebnis[(int) $instanzID] = (float) $eintrag['Energie_Wh'];
}
return $ergebnis;
}
private function regeln(bool $erneuern): void private function regeln(bool $erneuern): void
{ {
if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) { if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) {
@@ -689,22 +757,6 @@ class Manager extends IPSModule implements ManagerSchnittstelle
); );
$this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0); $this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0);
$this->regleEinspeisung($netzleistung, $stoerungen, $erneuern); $this->regleEinspeisung($netzleistung, $stoerungen, $erneuern);
if (!$synchronisiert) {
$this->setzeDiagnosewert('Verteilbudget', 0.0);
$this->setzeDiagnosewert('Verbraucherstatus', json_encode([
'Betriebsart' => $betriebsart,
'Synchronisiert' => false,
'Verbraucher' => $verbraucher,
'NichtGeregelt' => $nichtGeregelt,
], JSON_THROW_ON_ERROR));
$this->setzeDiagnosewert(
'NichtGeregelteGeraete',
$this->nichtGeregelteGeraeteText($nichtGeregelt)
);
$this->aktualisiereStoerungen($verbraucher, $stoerungen);
$this->SetStatus(self::STATUS_AKTIV);
return;
}
$fahrplanVorgabe = $this->aktuelleNetzfahrplanVorgabe(); $fahrplanVorgabe = $this->aktuelleNetzfahrplanVorgabe();
$zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK $zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK
@@ -714,19 +766,22 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$netzleistung, $netzleistung,
$zielnetzleistung, $zielnetzleistung,
$verbraucher, $verbraucher,
$betriebsart === ManagerRegler::BETRIEBSART_PEAK ? 'PrioritaetPeak' : 'PrioritaetPV' $betriebsart === ManagerRegler::BETRIEBSART_PEAK ? 'PrioritaetPeak' : 'PrioritaetPV',
$this->bezogeneVerbraucherEnergienWh()
); );
$this->setzeDiagnosewert('Verteilbudget', (float) $ergebnis['Verteilbudget_W']); $this->setzeDiagnosewert('Verteilbudget', (float) $ergebnis['Verteilbudget_W']);
$this->setzeDiagnosewert('Verbraucherstatus', json_encode([ $this->setzeDiagnosewert('Verbraucherstatus', json_encode([
'Betriebsart' => $betriebsart, 'Betriebsart' => $betriebsart,
'Synchronisiert' => true, 'Synchronisiert' => $synchronisiert,
'Restabweichung_W' => $ergebnis['Restabweichung_W'], 'Restabweichung_W' => $ergebnis['Restabweichung_W'],
'Verbraucher' => $verbraucher, 'Verbraucher' => $verbraucher,
'NichtGeregelt' => [], 'NichtGeregelt' => $nichtGeregelt,
], JSON_THROW_ON_ERROR)); ], JSON_THROW_ON_ERROR));
$this->setzeDiagnosewert( $this->setzeDiagnosewert(
'NichtGeregelteGeraete', 'NichtGeregelteGeraete',
'Alle aktiven Verbraucher werden geregelt.' $nichtGeregelt === []
? 'Alle aktiven Verbraucher werden geregelt.'
: $this->nichtGeregelteGeraeteText($nichtGeregelt)
); );
$letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte'); $letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte');
@@ -1450,7 +1505,18 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$token = trim($this->ReadAttributeString('PrognoseInstallationsToken')); $token = trim($this->ReadAttributeString('PrognoseInstallationsToken'));
if ($token === '') { if ($token === '') {
throw new RuntimeException('Der Geraetezugang fuer die Stoerueberwachung fehlt.'); $this->aktualisiereLizenzfreigabe(
$this->ReadPropertyString('Lizenzcode'),
true
);
$token = trim($this->ReadAttributeString('PrognoseInstallationsToken'));
}
if ($token === '') {
$lizenzfehler = trim($this->ReadAttributeString('LizenzLetzterFehler'));
throw new RuntimeException(
'Der Geraetezugang fuer die Stoerueberwachung fehlt.'
. ($lizenzfehler !== '' ? ' Lizenzpruefung: ' . $lizenzfehler : '')
);
} }
$snapshot['capturedAt'] = gmdate('Y-m-d\TH:i:s\Z', $jetzt); $snapshot['capturedAt'] = gmdate('Y-m-d\TH:i:s\Z', $jetzt);
$this->uebertrageStoerungsSnapshot( $this->uebertrageStoerungsSnapshot(
@@ -2058,6 +2124,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
if (is_int($funFactsID) && $funFactsID > 0 && IPS_VariableExists($funFactsID)) { if (is_int($funFactsID) && $funFactsID > 0 && IPS_VariableExists($funFactsID)) {
$this->aktualisiereFunFacts($funFactsID); $this->aktualisiereFunFacts($funFactsID);
} }
$this->aktualisiereEnergieflusswerte();
} }
private function istNumerischeVariable(int $variableID): bool private function istNumerischeVariable(int $variableID): bool
@@ -2126,8 +2193,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
&& $this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus); && $this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus);
$gueltig = $leaseGueltig && $berechtigungenPassen; $gueltig = $leaseGueltig && $berechtigungenPassen;
$berechtigungenVeraltet = $basisGueltig && !$berechtigungenPassen; $berechtigungenVeraltet = $basisGueltig && !$berechtigungenPassen;
$geraetezugangFehlt = ($this->ReadPropertyBoolean('PrognoseAktiv') $geraetezugangFehlt = $this->geraetezugangBenoetigt()
|| $this->ReadPropertyBoolean('StoerueberwachungAktiv'))
&& $this->ReadAttributeString('PrognoseInstallationsToken') === ''; && $this->ReadAttributeString('PrognoseInstallationsToken') === '';
$routineFaellig = (!$gueltig && !$berechtigungenVeraltet) $routineFaellig = (!$gueltig && !$berechtigungenVeraltet)
|| $geraetezugangFehlt || $geraetezugangFehlt
@@ -2266,12 +2332,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
/** @param array<string, mixed> $lease */ /** @param array<string, mixed> $lease */
private function prognoseBerechtigungInLease(array $lease): bool private function prognoseBerechtigungInLease(array $lease): bool
{ {
return Lizenzpruefung::berechtigungsMenge($lease, Lizenzpruefung::NETZFAHRPLAN) > 0 return Lizenzpruefung::hatPrognoseBerechtigung($lease);
|| (Lizenzpruefung::berechtigungsMenge($lease, Lizenzpruefung::PROGNOSE_PV) > 0
&& Lizenzpruefung::berechtigungsMenge(
$lease,
Lizenzpruefung::PROGNOSE_VERBRAUCH
) > 0);
} }
private function berechtigungLizenziert(string $kennung, ?string $lizenzcode = null): bool private function berechtigungLizenziert(string $kennung, ?string $lizenzcode = null): bool
@@ -2421,7 +2482,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
if (!$lizenziert) { if (!$lizenziert) {
return 'Prognosen sind deaktiviert. Fuer die Manager-ID ' return 'Prognosen sind deaktiviert. Fuer die Manager-ID '
. $managerID . $managerID
. ' fehlen forecast_pv plus forecast_load oder grid_schedule.'; . ' ist keine Prognoseberechtigung vorhanden. Benoetigt wird entweder '
. 'PV-Ertragsprognose zusammen mit Verbrauchsprognose oder der '
. 'Intelligente Netzfahrplan. Nach der Erweiterung im Lizenzportal '
. 'die Lizenz erneut pruefen und binden.';
} }
return ($aktiv ? 'Prognosen sind aktiviert. ' : 'Prognosen sind deaktiviert. ') return ($aktiv ? 'Prognosen sind aktiviert. ' : 'Prognosen sind deaktiviert. ')
@@ -2480,8 +2544,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$nutzlast = json_encode([ $nutzlast = json_encode([
'code' => $lizenzcode, 'code' => $lizenzcode,
'installationId' => $installationID, 'installationId' => $installationID,
'issueDeviceToken' => ($this->ReadPropertyBoolean('PrognoseAktiv') 'issueDeviceToken' => $this->geraetezugangBenoetigt()
|| $this->ReadPropertyBoolean('StoerueberwachungAktiv'))
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '', && $this->ReadAttributeString('PrognoseInstallationsToken') === '',
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
$curl = curl_init(self::LIZENZ_ENDPOINT); $curl = curl_init(self::LIZENZ_ENDPOINT);
@@ -2651,6 +2714,25 @@ class Manager extends IPSModule implements ManagerSchnittstelle
); );
} }
private function geraetezugangBenoetigt(): bool
{
if ($this->ReadPropertyBoolean('PrognoseAktiv')
|| $this->ReadPropertyBoolean('StoerueberwachungAktiv')
) {
return true;
}
try {
$topologie = $this->leseAnlagentopologie();
} catch (Throwable $fehler) {
return false;
}
return $topologie['inverters'] !== []
|| $topologie['pvArrays'] !== []
|| $topologie['batteries'] !== [];
}
private function prognoseTelemetrieKonfiguriert(): bool private function prognoseTelemetrieKonfiguriert(): bool
{ {
return $this->ReadPropertyBoolean('PrognoseAktiv') return $this->ReadPropertyBoolean('PrognoseAktiv')
@@ -2675,38 +2757,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
return null; return null;
} }
$jetzt = time(); return ManagerRegler::interpolierteFahrplanVorgabe(
$naechsterAbstand = PHP_INT_MAX; $fahrplan['points'],
$vorgabe = null; time()
foreach ($fahrplan['points'] as $punkt) { );
if (!is_array($punkt)
|| !is_string($punkt['time'] ?? null)
|| (!is_int($punkt['gridTargetW'] ?? null)
&& !is_float($punkt['gridTargetW'] ?? null))
) {
continue;
}
$zeitpunkt = strtotime($punkt['time']);
if ($zeitpunkt === false) {
continue;
}
$abstand = abs($zeitpunkt - $jetzt);
if ($abstand < $naechsterAbstand) {
$naechsterAbstand = $abstand;
$vorgabe = (float) $punkt['gridTargetW'];
}
}
return $naechsterAbstand <= 1800 ? $vorgabe : null;
} }
private function synchronisiereAnlagentopologie(): void private function synchronisiereAnlagentopologie(): void
{ {
if (!$this->ReadPropertyBoolean('PrognoseAktiv')) {
$this->setzeDiagnosewert('Prognosestatus', 'NichtVerwendet');
return;
}
try { try {
$topologie = $this->leseAnlagentopologie(); $topologie = $this->leseAnlagentopologie();
if ($topologie['inverters'] === [] if ($topologie['inverters'] === []
@@ -2722,7 +2780,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle
throw new RuntimeException('Der Geraetezugang fuer die Prognose fehlt.'); throw new RuntimeException('Der Geraetezugang fuer die Prognose fehlt.');
} }
$this->sendeAnlagentopologie($topologie, $token); $this->sendeAnlagentopologie($topologie, $token);
$this->setzeDiagnosewert('Prognosestatus', 'Verbunden'); $this->setzeDiagnosewert(
'Prognosestatus',
$this->ReadPropertyBoolean('PrognoseAktiv')
? 'Verbunden'
: 'Topologie synchronisiert'
);
} catch (Throwable $fehler) { } catch (Throwable $fehler) {
$meldung = substr($fehler->getMessage(), 0, 500); $meldung = substr($fehler->getMessage(), 0, 500);
$this->setzeDiagnosewert('Prognosestatus', 'Fehler: ' . $meldung); $this->setzeDiagnosewert('Prognosestatus', 'Fehler: ' . $meldung);
+9 -5
View File
@@ -43,12 +43,16 @@ Ein Betriebsartwechsel wird synchronisiert:
3. `null` ist nur eine Ankuendigung und kein Schaltbefehl. 3. `null` ist nur eine Ankuendigung und kein Schaltbefehl.
4. Jeder Verbraucher berechnet sein zustandsabhaengiges Leistungsangebot neu 4. Jeder Verbraucher berechnet sein zustandsabhaengiges Leistungsangebot neu
und meldet es mit derselben Betriebsart zurueck. und meldet es mit derselben Betriebsart zurueck.
5. Erst wenn alle aktiven Verbraucher synchronisiert sind, verteilt der 5. Der Manager verteilt konkrete Sollleistungen an alle bereits
Manager konkrete Sollleistungen. synchronisierten Verbraucher.
6. Fehlende, veraltete oder noch nicht umgeschaltete Verbraucher bleiben von
der Verteilung ausgeschlossen und werden als Stoerung ausgewiesen.
Der Manager verwendet damit nie ein PV-Angebot fuer Peak oder umgekehrt. Eine Der Manager verwendet damit nie ein PV-Angebot fuer Peak oder umgekehrt. Ein
technische Schaltsperre, eine ausstehende Rueckmeldung oder eine Mindestzeit nicht antwortender Verbraucher blockiert die Regelung der uebrigen Anlage
kann das Angebot weiterhin auf die aktuell gehaltene Leistung begrenzen. nicht; sein aktueller Einfluss ist bereits in der Netzleistungsmessung
enthalten. Eine technische Schaltsperre oder eine Mindestzeit kann das Angebot
weiterhin auf die aktuell gehaltene Leistung begrenzen.
| Verbraucher | PV-Angebot | Peak-Angebot | | Verbraucher | PV-Angebot | Peak-Angebot |
| --- | --- | --- | | --- | --- | --- |
+14
View File
@@ -130,6 +130,20 @@
"minimum": 0.1, "minimum": 0.1,
"digits": 1 "digits": 1
}, },
{
"type": "NumberSpinner",
"name": "TemperaturUntergrenze",
"caption": "Untertemperaturgrenze",
"suffix": " °C",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "TemperaturObergrenze",
"caption": "Übertemperaturgrenze",
"suffix": " °C",
"digits": 1
},
{ {
"type": "List", "type": "List",
"name": "Zeitplan", "name": "Zeitplan",
+31 -11
View File
@@ -56,6 +56,8 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
$this->RegisterPropertyString('Zeitplan', '[]'); $this->RegisterPropertyString('Zeitplan', '[]');
$this->RegisterPropertyInteger('LastwechselSperrzeit', 5); $this->RegisterPropertyInteger('LastwechselSperrzeit', 5);
$this->RegisterPropertyInteger('TemperaturMaxAlter', 30); $this->RegisterPropertyInteger('TemperaturMaxAlter', 30);
$this->RegisterPropertyFloat('TemperaturUntergrenze', 0.0);
$this->RegisterPropertyFloat('TemperaturObergrenze', 100.0);
$this->RegisterPropertyFloat('Hysterese', 5.0); $this->RegisterPropertyFloat('Hysterese', 5.0);
$this->RegisterPropertyBoolean('LegionellenfunktionAktiv', true); $this->RegisterPropertyBoolean('LegionellenfunktionAktiv', true);
$this->RegisterPropertyInteger('LegionellenMinimalintervallTage', 4); $this->RegisterPropertyInteger('LegionellenMinimalintervallTage', 4);
@@ -537,6 +539,10 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
if (!$temperaturGueltig) { if (!$temperaturGueltig) {
$stoerungen[] = 'Boilertemperatur fehlt oder ist veraltet.'; $stoerungen[] = 'Boilertemperatur fehlt oder ist veraltet.';
} }
$untertemperatur = $this->istUntertemperatur($temperaturGueltig);
if ($untertemperatur) {
$stoerungen[] = 'Untertemperatur erkannt.';
}
$uebertemperatur = $this->istUebertemperatur($temperaturGueltig); $uebertemperatur = $this->istUebertemperatur($temperaturGueltig);
if ($uebertemperatur) { if ($uebertemperatur) {
$stoerungen[] = 'Uebertemperatur erkannt.'; $stoerungen[] = 'Uebertemperatur erkannt.';
@@ -555,19 +561,18 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
} }
} }
private function istUntertemperatur(bool $temperaturGueltig): bool
{
return $temperaturGueltig
&& (float) $this->GetValue('Boilertemperatur')
< $this->ReadPropertyFloat('TemperaturUntergrenze');
}
private function istUebertemperatur(bool $temperaturGueltig): bool private function istUebertemperatur(bool $temperaturGueltig): bool
{ {
if (!$temperaturGueltig) { return $temperaturGueltig
return false; && (float) $this->GetValue('Boilertemperatur')
} > $this->ReadPropertyFloat('TemperaturObergrenze');
$grenze = (float) $this->GetValue('Maximaltemperatur');
if ($this->ReadPropertyBoolean('LegionellenfunktionAktiv')) {
$grenze = max($grenze, $this->ReadAttributeFloat('ZustandLegionellentemperatur'));
}
return (float) $this->GetValue('Boilertemperatur')
> $grenze + $this->ReadPropertyFloat('Hysterese');
} }
private function sendeVerbraucherdaten(): void private function sendeVerbraucherdaten(): void
@@ -592,6 +597,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
{ {
$temperaturGueltig = (bool) $this->leseZustand('TemperaturGueltig'); $temperaturGueltig = (bool) $this->leseZustand('TemperaturGueltig');
$schaltfehler = $this->ReadAttributeString('Schaltfehler'); $schaltfehler = $this->ReadAttributeString('Schaltfehler');
$untertemperatur = $this->istUntertemperatur($temperaturGueltig);
$uebertemperatur = $this->istUebertemperatur($temperaturGueltig); $uebertemperatur = $this->istUebertemperatur($temperaturGueltig);
return [ return [
@@ -655,6 +661,13 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
'Einheit' => '', 'Einheit' => '',
'Text' => $temperaturGueltig ? '' : 'Boilertemperatur fehlt oder ist veraltet.', 'Text' => $temperaturGueltig ? '' : 'Boilertemperatur fehlt oder ist veraltet.',
], ],
[
'Kennung' => 'Untertemperatur',
'Art' => 'Stoerung',
'Wert' => $untertemperatur,
'Einheit' => '',
'Text' => $untertemperatur ? 'Untertemperatur erkannt.' : '',
],
[ [
'Kennung' => 'Uebertemperatur', 'Kennung' => 'Uebertemperatur',
'Art' => 'Stoerung', 'Art' => 'Stoerung',
@@ -745,6 +758,13 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
if ($this->ReadPropertyFloat('Hysterese') <= 0) { if ($this->ReadPropertyFloat('Hysterese') <= 0) {
throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.'); throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.');
} }
if ($this->ReadPropertyFloat('TemperaturUntergrenze')
>= $this->ReadPropertyFloat('TemperaturObergrenze')
) {
throw new InvalidArgumentException(
'Die Temperaturuntergrenze muss kleiner als die Temperaturobergrenze sein.'
);
}
$minimum = (float) $this->GetValue('Mindesttemperatur'); $minimum = (float) $this->GetValue('Mindesttemperatur');
$maximum = (float) $this->GetValue('Maximaltemperatur'); $maximum = (float) $this->GetValue('Maximaltemperatur');
$legionellen = (float) $this->ReadAttributeFloat('ZustandLegionellentemperatur'); $legionellen = (float) $this->ReadAttributeFloat('ZustandLegionellentemperatur');
+11 -2
View File
@@ -78,8 +78,11 @@ zulaessig ist.
`Sollleistung_W=null`. `Sollleistung_W=null`.
4. Jeder Verbraucher berechnet und meldet seine PowerSteps fuer diese 4. Jeder Verbraucher berechnet und meldet seine PowerSteps fuer diese
Betriebsart. Betriebsart.
5. Erst wenn alle aktiven Verbraucher synchronisiert sind, verteilt der Manager 5. Der Manager verteilt Sollleistungen an alle bereits synchronisierten
Sollleistungen. Verbraucher.
6. Fehlende, veraltete oder noch nicht umgeschaltete Verbraucher werden nicht
angesteuert und als Stoerung ausgewiesen; sie blockieren die aktuellen
Verbraucher nicht.
Damit kann jeder Verbrauchertyp unterschiedliche Angebote fuer PV und Peak Damit kann jeder Verbrauchertyp unterschiedliche Angebote fuer PV und Peak
melden, ohne dass der Manager seine interne Geraetelogik kennen muss. melden, ohne dass der Manager seine interne Geraetelogik kennen muss.
@@ -87,6 +90,12 @@ melden, ohne dass der Manager seine interne Geraetelogik kennen muss.
## Feste Regeln ## Feste Regeln
- Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet hoehere Prioritaet. - Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet hoehere Prioritaet.
- Jeder verfuegbare, synchronisierte Verbraucher mit einem nicht leeren
`Leistungswerte_W`-Angebot erhaelt einen Sollwert aus genau diesem Angebot.
- `AenderungMoeglich=false` kennzeichnet ein fixes Angebot und ist kein
Ausschlussgrund. Ein Angebot `[11000]` fuehrt deshalb zwingend zu `11000 W`.
- Prioritaeten verteilen nur die ueber den jeweiligen Mindestwert hinaus
verfuegbare Leistung; sie duerfen keinen angebotenen Mindestwert verdrängen.
- `Leistungsquelle`: 0 nicht vorhanden, 1 berechnet, 2 gemessen. - `Leistungsquelle`: 0 nicht vorhanden, 1 berechnet, 2 gemessen.
- Bei Leistungsquelle 0 ist `Istleistung_W` zwingend `null`. - Bei Leistungsquelle 0 ist `Istleistung_W` zwingend `null`.
- Leistungsbereiche enthalten jeden ganzen Wattwert von `Von_W` bis `Bis_W`. - Leistungsbereiche enthalten jeden ganzen Wattwert von `Von_W` bis `Bis_W`.
+5 -3
View File
@@ -13,9 +13,11 @@
| `Interval` | entfaellt | Die Regelung wird ereignisbasiert ausgeloest. | | `Interval` | entfaellt | Die Regelung wird ereignisbasiert ausgeloest. |
| `IdleCounterMax` | `LastwechselSperrzeit` | Zykluszaehler wird durch eine Sperrzeit in Sekunden ersetzt; Standard `5`. | | `IdleCounterMax` | `LastwechselSperrzeit` | Zykluszaehler wird durch eine Sperrzeit in Sekunden ersetzt; Standard `5`. |
Die neuen Properties `TemperaturMaxAlter`, `Hysterese`, die Parameter der Die neuen Properties `TemperaturMaxAlter`, `TemperaturUntergrenze`,
Legionellenfunktion und `DiagnosevariablenAnzeigen` ersetzen feste Werte und `TemperaturObergrenze`, `Hysterese`, die Parameter der Legionellenfunktion und
nicht pruefbare Annahmen des Altmoduls. `DiagnosevariablenAnzeigen` ersetzen feste Werte und nicht pruefbare Annahmen
des Altmoduls. Die Stoergrenzen liegen standardmaessig bei 0 und 100 Grad C und
sind von den Regel-Sollwerten `Mindesttemperatur` und `Maximaltemperatur` getrennt.
## Variablen ## Variablen
+11 -5
View File
@@ -58,12 +58,15 @@ als ungueltig behandelt und ergeben bis zur gueltigen 1-/3-Phasenmeldung nur
Immer sichtbar sind `Aktiv`, `FahrzeugVerbunden`, `FahrzeugGeladen`, Immer sichtbar sind `Aktiv`, `FahrzeugVerbunden`, `FahrzeugGeladen`,
`Fahrzeugstatus`, `Ladestrom` und `Phasenzahl`. Der normalisierte `Fahrzeugstatus`, `Ladestrom` und `Phasenzahl`. Der normalisierte
Fahrzeugstatus ist `0` unbekannt, `1` getrennt, `2` bereit, `3` laedt, Fahrzeugstatus ist `0` unbekannt, `1` getrennt, `2` wartet auf Start,
`4` geladen oder `5` Fehler. `3` laedt, `4` geladen oder `5` Fehler.
Optional sichtbar sind die gemeinsamen EMS-Diagnosewerte sowie Optional sichtbar sind die gemeinsamen EMS-Diagnosewerte sowie
`GatewayVerbunden`, `ApiMaximalstrom`, `LetzterGeraetebefehl` und `GatewayVerbunden`, `ApiMaximalstrom`, `EaseeAktiviert`,
`LeistungsangebotDiagnose`. `AutorisierungErforderlich`, `RemoteStartErforderlich`, `SmartCharging`,
`GrundKeinLadestrom`, `LetzterGeraetebefehl`, `LetzterStartbefehl` und
`LeistungsangebotDiagnose`. `GrundKeinLadestrom=55` bedeutet bei Easee, dass
die Autorisierung fehlt.
## Regelung ## Regelung
@@ -83,7 +86,10 @@ Angebot sofort neu und meldet die Aenderung kurz gebuendelt an den Manager.
Bei einer Gateway-Unterbrechung werden Fahrzeug- und Phasenstatus sofort Bei einer Gateway-Unterbrechung werden Fahrzeug- und Phasenstatus sofort
verworfen; erst ein neuer aktueller Gateway-Zustand gibt die Regelung wieder verworfen; erst ein neuer aktueller Gateway-Zustand gibt die Regelung wieder
frei. Die eigentliche Vorgabe wird als dynamischer Ladestrom mit `minutes=0` frei. Die eigentliche Vorgabe wird als dynamischer Ladestrom mit `minutes=0`
an Easee gesendet. an Easee gesendet. Meldet Easee bei positiver Stromvorgabe weiterhin
`Awaiting Start`, sendet das Modul einmal pro Fahrzeugverbindung zusaetzlich
`start_charging`. Dieser Befehl autorisiert den Ladevorgang, uebersteuert aber
keinen vorhandenen Easee-Ladeplan.
## Managerkommunikation ## Managerkommunikation
+52 -23
View File
@@ -40,13 +40,20 @@ Die Auswertung folgt bewusst dem Verhalten der bisherigen Enelix-1-Ladestation:
wird entsprechend `State != 1` ausgewertet. wird entsprechend `State != 1` ausgewertet.
2. Die gemessene Leistung wird bei der alten go-e-API mit Faktor 10, bei 2. Die gemessene Leistung wird bei der alten go-e-API mit Faktor 10, bei
Gemini direkt in Watt und bei Pico von kW in Watt umgerechnet. Gemini direkt in Watt und bei Pico von kW in Watt umgerechnet.
3. Bei einer Leistung ueber 7500 W wird dreiphasiges Laden erkannt. Darunter 3. go-e meldet die drei Phasenstroeme einzeln. Sobald mindestens zwei Phasen
wird bei verbundenem Fahrzeug einphasiges Laden angenommen. Strom fuehren, wird dreiphasiges Laden direkt erkannt.
4. Der Ladestrom wird aus der Leistung mit `230 V` einphasig beziehungsweise 4. Sind beim Anstecken noch keine belastbaren Phasenwerte vorhanden, gibt das
`1.71 * 400 V` dreiphasig berechnet. Modul zunaechst den konfigurierten Maximalstrom als Erkennungsstrom vor. Bis
5. War das Fahrzeug bereits im vorherigen Zyklus verbunden und liegt der zur abgeschlossenen Erkennung bleibt `Phasenzahl=0` und das EMS-Angebot
ermittelte Maximalstrom aus Ladestrom plus `2.5 A` unter `6 A`, wird `[0]`.
`FahrzeugGeladen=true` gesetzt. 5. Nach `Phasenerkennungszeit` gilt wie in Enelix 1: ueber 7500 W werden drei
Phasen erkannt, andernfalls eine Phase. Die erkannte Phasenzahl bleibt bis
zum Ausstecken erhalten und kippt waehrend einer Ladepause nicht zurueck.
6. Der Ladestrom wird aus der Leistung mit `230 W/A` einphasig beziehungsweise
`684 W/A` dreiphasig berechnet.
7. Ein Ladeende wird nur nach zuvor tatsaechlich gemessener Ladeleistung
erkannt. Eine vom EMS angeordnete Solarpause gilt deshalb nicht als
`FahrzeugGeladen`.
Der normalisierte `Fahrzeugstatus` verwendet folgende Werte: Der normalisierte `Fahrzeugstatus` verwendet folgende Werte:
@@ -72,10 +79,18 @@ stoppt die Ladestation.
| Peak | ein | ausschliesslich `[0]`; die Ladestation wird gestoppt | | Peak | ein | ausschliesslich `[0]`; die Ladestation wird gestoppt |
| Peak | aus | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum | | Peak | aus | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum |
Die festen ein- und dreiphasigen Leistungsstufen sowie deren Rundung wurden aus Die Leistungsstufen entsprechen Enelix 1: `230 W` pro Ampere einphasig und
Enelix 1 uebernommen. Ohne gueltige Manager-Vorgabe faehrt das Modul bei `684 W` pro Ampere dreiphasig. Ohne gueltige Manager-Vorgabe faehrt das Modul
aktivem Solarladen mit `0 A`; bei ausgeschaltetem Solarladen verwendet es die bei aktivem Solarladen mit `0 A`; bei ausgeschaltetem Solarladen verwendet es
maximale angebotene Leistung. die maximale angebotene Leistung.
Nach einer Leistungsaenderung wird die aktuelle Stufe fuer
`ZeitZwischenZustandswechseln` gehalten. Waehrend der
`Mindesteinschaltdauer` bleiben positive Leistungsstufen regelbar, `0 W`
wird jedoch nicht angeboten. Nach dem Abschalten meldet das Modul waehrend der
`Mindestausschaltdauer` ausschliesslich `[0]`. Sicherheitsabschaltungen
durch Deaktivierung, fehlende Ladefreigabe, Ausstecken oder ein erkanntes
Ladeende greifen sofort.
## Bedienung ## Bedienung
@@ -114,8 +129,13 @@ gueltige Manager-Vorgabe und berechnet das Leistungsangebot sofort neu.
| `Benutzername` | String | leer | Benutzername fuer die Pico-API. | | `Benutzername` | String | leer | Benutzername fuer die Pico-API. |
| `Passwort` | String | leer | Passwort fuer die Pico-API; im Formular als Passwortfeld dargestellt. | | `Passwort` | String | leer | Passwort fuer die Pico-API; im Formular als Passwortfeld dargestellt. |
| `MaximalerLadestrom` | Integer | `16 A` | Obergrenze der angebotenen Ladestufen; zulaessig sind 6 bis 32 A. | | `MaximalerLadestrom` | Integer | `16 A` | Obergrenze der angebotenen Ladestufen; zulaessig sind 6 bis 32 A. |
| `Phasenerkennungszeit` | Integer | `60 s` | Dauer der Maximalstrom-Probe vor der leistungsbasierten Phasenerkennung. |
| `FahrzeugstromErkennungszeit` | Integer | `90 s` | Zeit, die eine stabile Stromunterschreitung anliegen muss, bevor die Fahrzeuggrenze mit 2,5 A Reserve uebernommen wird. |
| `ZeitZwischenZustandswechseln` | Integer | `1 min` | Mindestabstand zwischen zwei Leistungsaenderungen. |
| `Mindesteinschaltdauer` | Integer | `0 min` | Mindestdauer nach dem Einschalten, in der kein Abschalten auf 0 W angeboten wird. |
| `Mindestausschaltdauer` | Integer | `0 min` | Mindestpause nach dem Abschalten, in der nur 0 W angeboten werden. |
| `Abfrageintervall` | Integer | `5 s` | Intervall der Geraetestatusabfrage. | | `Abfrageintervall` | Integer | `5 s` | Intervall der Geraetestatusabfrage. |
| `Ladefreigabe` | Boolean | `false` | Startwert der lokalen Ladefreigabe. | | `Ladefreigabe` | Boolean | `true` | Startwert der lokalen Ladefreigabe. |
| `Solarladen` | Boolean | `true` | Startwert der lokalen Solarlogik. | | `Solarladen` | Boolean | `true` | Startwert der lokalen Solarlogik. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Blendet gemeinsame und ladestationsspezifische Diagnosevariablen ein. | | `DiagnosevariablenAnzeigen` | Boolean | `false` | Blendet gemeinsame und ladestationsspezifische Diagnosevariablen ein. |
@@ -153,7 +173,7 @@ Nur mit `DiagnosevariablenAnzeigen=true` sichtbar:
| `AenderungMoeglich` | Zeigt, ob das aktuelle Angebot mehr als eine Leistungsstufe enthaelt. | | `AenderungMoeglich` | Zeigt, ob das aktuelle Angebot mehr als eine Leistungsstufe enthaelt. |
| `Stoerung` | Sammelstatus fuer Konfigurations- und Kommunikationsfehler. | | `Stoerung` | Sammelstatus fuer Konfigurations- und Kommunikationsfehler. |
| `Stoertext` | Letzte verstaendliche Fehlerbeschreibung. | | `Stoertext` | Letzte verstaendliche Fehlerbeschreibung. |
| `LetzterGeraetebefehl` | Letzte HTTP-Methode und URL ohne Zugangsdaten. | | `LetzterGeraetebefehl` | Letzter Steuerbefehl mit HTTP-Methode und URL ohne Zugangsdaten; Statusabfragen ueberschreiben ihn nicht. |
| `LeistungsangebotDiagnose` | Aktuelles Leistungsangebot als JSON-Liste in W. | | `LeistungsangebotDiagnose` | Aktuelles Leistungsangebot als JSON-Liste in W. |
Die Regellogik speichert ihren Zustand unabhaengig von der Sichtbarkeit der Die Regellogik speichert ihren Zustand unabhaengig von der Sichtbarkeit der
@@ -232,16 +252,22 @@ niemals in Repository-Dateien, Skripte oder Screenshots.
- Die Instanz erreicht Status `102`, und `Stoerung` bleibt `false`. - Die Instanz erreicht Status `102`, und `Stoerung` bleibt `false`.
- Ohne Fahrzeug sind `FahrzeugVerbunden=false`, `Phasenzahl=0` und das - Ohne Fahrzeug sind `FahrzeugVerbunden=false`, `Phasenzahl=0` und das
Leistungsangebot `[0]`. Leistungsangebot `[0]`.
- Nach dem Anstecken wird das Fahrzeug erkannt und die gemessene Leistung - Nach dem Anstecken wird das Fahrzeug erkannt und bei unbekannten Phasen
plausibel in `Istleistung` und `Ladestrom` abgebildet. zunaechst der konfigurierte Maximalstrom zur Erkennung angefordert.
- Bei einer Ladeleistung bis 7500 W wird eine Phase, darueber werden drei - Nach der Erkennungszeit werden bis 7500 W eine Phase, darueber drei Phasen
Phasen angezeigt. gespeichert. Bei go-e koennen die einzelnen Phasenstroeme die Erkennung
bereits vorher abschliessen.
- Die erkannte Phasenzahl bleibt bei einer anschliessenden Ladepause stabil.
- Die gemessene Leistung wird plausibel in `Istleistung` und `Ladestrom`
abgebildet.
- `Aktiv=false` oder `Ladefreigabe=false` stoppt die Ladestation. - `Aktiv=false` oder `Ladefreigabe=false` stoppt die Ladestation.
- PV mit `Solarladen=true` bietet `0 W` und die Ladestufen an. - PV mit `Solarladen=true` bietet `0 W` und die Ladestufen an.
- PV mit `Solarladen=false` bietet nur die maximale Leistung an. - PV mit `Solarladen=false` bietet nur die maximale Leistung an.
- Peak mit `Solarladen=true` stoppt die Ladestation und bietet nur `[0]` an. - Peak mit `Solarladen=true` stoppt die Ladestation und bietet nur `[0]` an.
- Peak mit `Solarladen=false` bietet `0 W` und die Ladestufen an. - Peak mit `Solarladen=false` bietet `0 W` und die Ladestufen an.
- Eine Manager-Vorgabe ausserhalb des gemeldeten Angebots wird abgewiesen. - Eine Manager-Vorgabe ausserhalb des gemeldeten Angebots wird abgewiesen.
- Leistungswechsel-, Mindest-Ein- und Mindest-Aus-Zeiten werden im gemeldeten
Angebot sichtbar und vom Manager eingehalten.
- Nach Ablauf des Vorgabe-Timeouts gilt wieder das lokale Ersatzverhalten. - Nach Ablauf des Vorgabe-Timeouts gilt wieder das lokale Ersatzverhalten.
- Im Debug-Protokoll und in `LetzterGeraetebefehl` erscheinen keine - Im Debug-Protokoll und in `LetzterGeraetebefehl` erscheinen keine
Zugangsdaten. Zugangsdaten.
@@ -256,9 +282,11 @@ niemals in Repository-Dateien, Skripte oder Screenshots.
- Fahrzeug wird nicht erkannt: Rohstatus der Geraete-API kontrollieren. Der - Fahrzeug wird nicht erkannt: Rohstatus der Geraete-API kontrollieren. Der
Adapter erwartet bei go-e `car` und `nrg[11]`, bei Pico `State` und Adapter erwartet bei go-e `car` und `nrg[11]`, bei Pico `State` und
`ActiveChargingPower`. `ActiveChargingPower`.
- Falsche Phasenzahl bei sehr kleiner oder stehender Ladung: Die kompatible - Phasenzahl bleibt laenger auf `0`: Waehrend der Erkennungsphase muessen
Enelix-1-Erkennung basiert auf der momentanen Leistung. Fuer eine sichere Ladestation und Fahrzeug den angeforderten Maximalstrom tatsaechlich
Dreiphasenerkennung muss waehrend der Erkennung mehr als 7500 W anliegen. freigeben. Bei Pico muss fuer eine sichere Dreiphasenerkennung waehrend der
Erkennung mehr als 7500 W anliegen; go-e kann zusaetzlich anhand der
einzelnen Phasenstroeme erkennen.
- Manager-Vorgabe wird abgewiesen: aktive Zuordnung im Manager sowie - Manager-Vorgabe wird abgewiesen: aktive Zuordnung im Manager sowie
`LeistungsangebotDiagnose` und die Betriebsart kontrollieren. `LeistungsangebotDiagnose` und die Betriebsart kontrollieren.
@@ -270,9 +298,10 @@ URL, HTTP-Methode, Authentisierung und Steueraufrufe ohne reale Hardware
geprueft. geprueft.
Der Symcon-Funktionstest verwendet den internen `Testmodus` mit simulierten Der Symcon-Funktionstest verwendet den internen `Testmodus` mit simulierten
API-Antworten. Er prueft alle drei Geraetevarianten, Fahrzeug- und API-Antworten. Er prueft alle drei Geraetevarianten, die Maximalstrom-Probe
Phasenerkennung, die Leistungsangebote in PV und Peak sowie die Umschaltung beim Anstecken, stabile Phasen waehrend einer Ladepause, die Schaltsperren,
ueber den `Solarladen`-Button. Fahrzeugstrom-Erkennung, die Leistungsangebote in PV und Peak sowie die
Umschaltung ueber den `Solarladen`-Button.
PHP-Syntax, Struktur- und Unit-Tests des gesamten Repositorys: PHP-Syntax, Struktur- und Unit-Tests des gesamten Repositorys:
+17 -8
View File
@@ -279,6 +279,7 @@ stabile ID gebunden werden.
| HTTP `429` | Zu viele Aktivierungsversuche; vor dem naechsten Versuch warten. | | HTTP `429` | Zu viele Aktivierungsversuche; vor dem naechsten Versuch warten. |
| `Lizenzserver nicht erreichbar` | DNS, Internetzugang, HTTPS und Systemzeit des Symcon-Systems pruefen. Eine bestehende Lease gilt nur bis `offlineUntil`. | | `Lizenzserver nicht erreichbar` | DNS, Internetzugang, HTTPS und Systemzeit des Symcon-Systems pruefen. Eine bestehende Lease gilt nur bis `offlineUntil`. |
| `Offline-Freigabe ist abgelaufen.` | Verbindung zum Lizenzserver wiederherstellen und Lizenz erneut pruefen. | | `Offline-Freigabe ist abgelaufen.` | Verbindung zum Lizenzserver wiederherstellen und Lizenz erneut pruefen. |
| `Keine Prognoseberechtigung vorhanden` | Im Lizenzportal entweder PV-Ertragsprognose zusammen mit Verbrauchsprognose oder den Intelligenten Netzfahrplan ergaenzen. Anschliessend im Manager `Lizenz pruefen und binden` ausloesen, die Prognose aktivieren und die Konfiguration speichern. |
Fuer eine genauere Diagnose koennen die Diagnosevariablen eingeblendet werden. Fuer eine genauere Diagnose koennen die Diagnosevariablen eingeblendet werden.
`Lizenzstatus` zeigt dann den aktuellen Zustand. Mit aktiviertem Debug-Logging `Lizenzstatus` zeigt dann den aktuellen Zustand. Mit aktiviertem Debug-Logging
@@ -302,14 +303,22 @@ der aktiven Betriebsart und der aktuellen Leistung aller frisch gemeldeten
Verbraucher. Eine gültige Istleistung wird bevorzugt; fehlt sie, bleibt der im Verbraucher. Eine gültige Istleistung wird bevorzugt; fehlt sie, bleibt der im
Zustand bestätigte Sollwert konservativ reserviert. Zustand bestätigte Sollwert konservativ reserviert.
Nicht verfügbare, nicht änderbare oder aktuell angebotlose Verbraucher behalten Nicht verfügbare oder aktuell angebotlose Verbraucher behalten ihre Leistung
ihre Leistung und erhalten keine neue Vorgabe. Die übrigen Verbraucher werden und erhalten keine neue Vorgabe. Jeder verfügbare, synchronisierte Verbraucher
nach der gemeldeten PV- beziehungsweise Peak-Priorität und danach stabil nach mit einem nicht leeren Angebot erhält dagegen einen Sollwert aus diesem Angebot.
Instanz-ID sortiert. Von ihrem jeweils kleinsten erlaubten Leistungswert aus Das gilt auch bei `AenderungMoeglich=false`: Ein fixes Angebot wie `[11000]`
wird das Budget in dieser Reihenfolge aufgefüllt. Einzelwerte und ganzzahlige wird mit genau `11000 W` zugeteilt.
Bereiche werden direkt verarbeitet; Lücken werden nie durch unzulässige Werte
geschlossen. Eine verbleibende Abweichung wird im `Verbraucherstatus` Die Verbraucher werden nach der gemeldeten PV- beziehungsweise Peak-Priorität
dokumentiert. sortiert. Bei gleicher Priorität bevorzugt der Manager wie Enelix 1 den
Verbraucher mit der bisher kleineren bezogenen Energie; verglichen wird in
2-kWh-Gruppen. Erst wenn auch diese Gruppe gleich ist, entscheidet stabil die
Instanz-ID. Zuerst reserviert der Manager für jeden Verbraucher den kleinsten
erlaubten Leistungswert. Nur das danach
verbleibende Budget wird in Prioritätsreihenfolge aufgefüllt. Einzelwerte und
ganzzahlige Bereiche werden direkt verarbeitet; Lücken werden nie durch
unzulässige Werte geschlossen. Eine verbleibende Abweichung wird im
`Verbraucherstatus` dokumentiert.
Die Betriebsart wechselt unterhalb des Solar-Sollwerts zu `PV` und oberhalb Die Betriebsart wechselt unterhalb des Solar-Sollwerts zu `PV` und oberhalb
der wirksamen Lastspitzengrenze zu `Peak`. Zwischen den beiden Zielwerten der wirksamen Lastspitzengrenze zu `Peak`. Zwischen den beiden Zielwerten
+2 -1
View File
@@ -8,7 +8,8 @@ final class EaseeGatewayProtokoll
{ {
/** @var list<int> */ /** @var list<int> */
public const BEOBACHTUNGEN = [ public const BEOBACHTUNGEN = [
47, 48, 100, 104, 109, 110, 119, 120, 182, 183, 184, 185, 250, 31, 41, 42, 43, 47, 48, 62, 96, 100, 102, 104, 109, 110,
111, 112, 113, 114, 119, 120, 182, 183, 184, 185, 250,
]; ];
public static function seriennummer(string $wert): string public static function seriennummer(string $wert): string
+26 -1
View File
@@ -22,6 +22,11 @@ final class EaseeLadestatus
* Istleistung_W: float, * Istleistung_W: float,
* Ladestrom_A: float, * Ladestrom_A: float,
* ApiMaximalstrom_A: int, * ApiMaximalstrom_A: int,
* EaseeAktiviert: bool,
* AutorisierungErforderlich: bool,
* RemoteStartErforderlich: bool,
* SmartCharging: bool,
* GrundKeinLadestrom: int,
* Stoerung: bool, * Stoerung: bool,
* Stoertext: string * Stoertext: string
* } * }
@@ -48,13 +53,18 @@ final class EaseeLadestatus
? self::phasenzahl((int) ($beobachtungen['110'] ?? 0)) ? self::phasenzahl((int) ($beobachtungen['110'] ?? 0))
: 0; : 0;
$leistung = max(0.0, (float) ($beobachtungen['120'] ?? 0.0) * 1000.0); $laedt = $betriebszustand === 3;
$leistung = $laedt
? max(0.0, (float) ($beobachtungen['120'] ?? 0.0) * 1000.0)
: 0.0;
$stroeme = []; $stroeme = [];
if ($laedt) {
foreach ([182, 183, 184, 185] as $id) { foreach ([182, 183, 184, 185] as $id) {
if (array_key_exists((string) $id, $beobachtungen)) { if (array_key_exists((string) $id, $beobachtungen)) {
$stroeme[] = abs((float) $beobachtungen[(string) $id]); $stroeme[] = abs((float) $beobachtungen[(string) $id]);
} }
} }
}
$ladestrom = $stroeme === [] ? 0.0 : max($stroeme); $ladestrom = $stroeme === [] ? 0.0 : max($stroeme);
$grenzen = [$konfigurierterMaximalstrom]; $grenzen = [$konfigurierterMaximalstrom];
@@ -65,6 +75,16 @@ final class EaseeLadestatus
} }
} }
$apiMaximalstrom = max(6, min($grenzen)); $apiMaximalstrom = max(6, min($grenzen));
$easeeAktiviert = !array_key_exists('31', $beobachtungen)
|| (bool) $beobachtungen['31'];
$autorisierungErforderlich = (bool) (
$beobachtungen['42'] ?? $beobachtungen['41'] ?? false
);
$remoteStartErforderlich = (bool) ($beobachtungen['43'] ?? false);
$smartCharging = (bool) ($beobachtungen['102'] ?? false);
$grundKeinLadestrom = is_numeric($beobachtungen['96'] ?? null)
? (int) $beobachtungen['96']
: 0;
$fehlercode = (int) ($beobachtungen['119'] ?? 0); $fehlercode = (int) ($beobachtungen['119'] ?? 0);
$stoerung = $betriebszustand === 5 || $fehlercode !== 0; $stoerung = $betriebszustand === 5 || $fehlercode !== 0;
@@ -85,6 +105,11 @@ final class EaseeLadestatus
'Istleistung_W' => $leistung, 'Istleistung_W' => $leistung,
'Ladestrom_A' => $ladestrom, 'Ladestrom_A' => $ladestrom,
'ApiMaximalstrom_A' => $apiMaximalstrom, 'ApiMaximalstrom_A' => $apiMaximalstrom,
'EaseeAktiviert' => $easeeAktiviert,
'AutorisierungErforderlich' => $autorisierungErforderlich,
'RemoteStartErforderlich' => $remoteStartErforderlich,
'SmartCharging' => $smartCharging,
'GrundKeinLadestrom' => $grundKeinLadestrom,
'Stoerung' => $stoerung, 'Stoerung' => $stoerung,
'Stoertext' => trim($stoertext), 'Stoertext' => trim($stoertext),
]; ];
+26 -5
View File
@@ -91,7 +91,7 @@ final class LadestationAdapter
} }
/** /**
* @return array{Rohstatus: int, Istleistung_W: float} * @return array{Rohstatus: int, Istleistung_W: float, Iststrom_A?: float, Phasenzahl?: int}
*/ */
public static function dekodiereStatus(int $geraetetyp, string $antwort): array public static function dekodiereStatus(int $geraetetyp, string $antwort): array
{ {
@@ -110,12 +110,33 @@ final class LadestationAdapter
throw new RuntimeException('Die go-e-Antwort enthaelt nicht alle Statusfelder.'); throw new RuntimeException('Die go-e-Antwort enthaelt nicht alle Statusfelder.');
} }
$faktor = $geraetetyp === self::GO_E_ALT ? 10.0 : 1.0; $leistungsfaktor = $geraetetyp === self::GO_E_ALT ? 10.0 : 1.0;
$status = [
return [
'Rohstatus' => (int) $daten['car'], 'Rohstatus' => (int) $daten['car'],
'Istleistung_W' => (float) $daten['nrg'][11] * $faktor, 'Istleistung_W' => (float) $daten['nrg'][11] * $leistungsfaktor,
]; ];
if (isset($daten['nrg'][4], $daten['nrg'][5], $daten['nrg'][6])
&& is_numeric($daten['nrg'][4])
&& is_numeric($daten['nrg'][5])
&& is_numeric($daten['nrg'][6])
) {
$stromfaktor = $geraetetyp === self::GO_E_ALT ? 0.1 : 1.0;
$phasenstroeme = [
(float) $daten['nrg'][4] * $stromfaktor,
(float) $daten['nrg'][5] * $stromfaktor,
(float) $daten['nrg'][6] * $stromfaktor,
];
$status['Iststrom_A'] = max($phasenstroeme);
$aktivePhasen = count(array_filter(
$phasenstroeme,
static fn (float $strom): bool => $strom >= 1.0
));
if ($aktivePhasen > 0) {
$status['Phasenzahl'] = $aktivePhasen >= 2 ? 3 : 1;
}
}
return $status;
} }
if ($geraetetyp === self::SMART_ME_PICO) { if ($geraetetyp === self::SMART_ME_PICO) {
+72 -29
View File
@@ -12,30 +12,25 @@ final class LadestationRegler
public const PHASEN_EIN = 1; public const PHASEN_EIN = 1;
public const PHASEN_DREI = 3; public const PHASEN_DREI = 3;
/** @var list<int> */ private const LEISTUNG_PRO_AMPERE_EINPHASIG = 230;
private const LEISTUNGEN_EINPHASIG = [ private const LEISTUNG_PRO_AMPERE_DREIPHASIG = 684;
1450, 1690, 1930, 2170, 2410, 2650, 2890, 3130, 3370,
3610, 3850, 4090, 4330, 4570, 4810, 5050, 5290, 5530,
5770, 6010, 6250, 6490, 6730, 6970, 7210, 7450, 7690,
];
/** @var list<int> */
private const LEISTUNGEN_DREIPHASIG = [
4150, 4850, 5550, 6250, 6950, 7600, 8300, 9000, 9700,
10300, 11000, 11750, 12450, 13150, 13850, 14550, 15250,
15900, 16600, 17300, 18000, 18700, 19400, 20100, 20800,
21500, 22000,
];
/** /**
* Die Schwelle, die Voll-Erkennung und der Zuschlag von 2.5 A stammen * Die Schwelle und der Zuschlag von 2.5 A stammen aus der Enelix-1-Ladestation.
* aus der Enelix-1-Ladestation und bleiben bewusst kompatibel. * Ein Ladeende wird erst nach zuvor gemessener Ladeleistung angenommen, damit
* die Anlaufzeit nach einer neuen Stromvorgabe nicht als volles Fahrzeug gilt.
* *
* @param array{Rohstatus: int, Istleistung_W: float} $rohstatus * @param array{Rohstatus: int, Istleistung_W: float, Iststrom_A?: float, Phasenzahl?: int} $rohstatus
* @return array<string, int|float|bool> * @return array<string, int|float|bool>
*/ */
public static function normalisiereStatus(array $rohstatus, bool $warVerbunden): array public static function normalisiereStatus(
{ array $rohstatus,
bool $warVerbunden,
bool $warGeladen = false,
bool $ladungErwartet = true,
bool $ladeleistungWurdeErkannt = false,
int $bekanntePhasenzahl = self::PHASEN_UNBEKANNT
): array {
$verbunden = $rohstatus['Rohstatus'] !== 1; $verbunden = $rohstatus['Rohstatus'] !== 1;
$leistung = max(0.0, $rohstatus['Istleistung_W']); $leistung = max(0.0, $rohstatus['Istleistung_W']);
$phasen = self::PHASEN_UNBEKANNT; $phasen = self::PHASEN_UNBEKANNT;
@@ -43,11 +38,28 @@ final class LadestationRegler
$geladen = false; $geladen = false;
if ($verbunden) { if ($verbunden) {
$phasen = $leistung > 7500.0 ? self::PHASEN_DREI : self::PHASEN_EIN; if (in_array($bekanntePhasenzahl, [self::PHASEN_EIN, self::PHASEN_DREI], true)) {
$nenner = $phasen === self::PHASEN_EIN ? 230.0 : 1.71 * 400.0; $phasen = $bekanntePhasenzahl;
$ladestrom = $leistung / $nenner; } elseif (isset($rohstatus['Phasenzahl'])
&& in_array($rohstatus['Phasenzahl'], [self::PHASEN_EIN, self::PHASEN_DREI], true)
) {
$phasen = (int) $rohstatus['Phasenzahl'];
} elseif ($leistung > 7500.0) {
$phasen = self::PHASEN_DREI;
}
$nenner = $phasen === self::PHASEN_DREI
? self::LEISTUNG_PRO_AMPERE_DREIPHASIG
: self::LEISTUNG_PRO_AMPERE_EINPHASIG;
$ladestrom = isset($rohstatus['Iststrom_A'])
? max(0.0, (float) $rohstatus['Iststrom_A'])
: ($phasen === self::PHASEN_UNBEKANNT ? 0.0 : $leistung / $nenner);
$ermittelterMaximalstrom = 2.5 + $ladestrom; $ermittelterMaximalstrom = 2.5 + $ladestrom;
$geladen = $warVerbunden && $ermittelterMaximalstrom < 6.0; $geladen = $warGeladen
|| ($warVerbunden
&& $ladungErwartet
&& $ladeleistungWurdeErkannt
&& $ermittelterMaximalstrom < 6.0);
} }
if (!$verbunden) { if (!$verbunden) {
@@ -70,6 +82,29 @@ final class LadestationRegler
]; ];
} }
public static function ermittleFahrzeugMaximalstrom(
int $vorgegebenerStrom,
float $gemessenerStrom,
int $konfigurierterMaximalstrom
): ?int {
if ($konfigurierterMaximalstrom < 6 || $konfigurierterMaximalstrom > 32) {
throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.');
}
if ($vorgegebenerStrom < 6 || $gemessenerStrom < 5.5) {
return null;
}
$kandidat = max(
6,
min($konfigurierterMaximalstrom, (int) floor($gemessenerStrom + 2.5))
);
if ($kandidat > $vorgegebenerStrom - 2 || $kandidat >= $konfigurierterMaximalstrom) {
return null;
}
return $kandidat;
}
/** @return list<int> */ /** @return list<int> */
public static function leistungsangebot( public static function leistungsangebot(
bool $aktiv, bool $aktiv,
@@ -85,6 +120,10 @@ final class LadestationRegler
return [0]; return [0];
} }
if ($phasenzahl === self::PHASEN_UNBEKANNT) {
return [0];
}
$stufen = self::leistungsstufen($phasenzahl, $maximalerLadestrom); $stufen = self::leistungsstufen($phasenzahl, $maximalerLadestrom);
if ($stufen === []) { if ($stufen === []) {
return [0]; return [0];
@@ -135,12 +174,16 @@ final class LadestationRegler
private static function stufenFuerPhasen(int $phasenzahl): array private static function stufenFuerPhasen(int $phasenzahl): array
{ {
if ($phasenzahl === self::PHASEN_EIN) { if ($phasenzahl === self::PHASEN_EIN) {
return self::LEISTUNGEN_EINPHASIG; $leistungProAmpere = self::LEISTUNG_PRO_AMPERE_EINPHASIG;
} } elseif ($phasenzahl === self::PHASEN_DREI) {
if ($phasenzahl === self::PHASEN_DREI) { $leistungProAmpere = self::LEISTUNG_PRO_AMPERE_DREIPHASIG;
return self::LEISTUNGEN_DREIPHASIG; } else {
}
throw new InvalidArgumentException('Phasenzahl muss 1 oder 3 sein.'); throw new InvalidArgumentException('Phasenzahl muss 1 oder 3 sein.');
} }
return array_map(
static fn (int $strom): int => $strom * $leistungProAmpere,
range(6, 32)
);
}
} }
+13
View File
@@ -117,6 +117,19 @@ final class Lizenzpruefung
return $menge; return $menge;
} }
/**
* Eine Prognose ist entweder durch den Netzfahrplan oder durch beide
* Einzelberechtigungen fuer PV und Verbrauch freigeschaltet.
*
* @param array<string, mixed> $lease
*/
public static function hatPrognoseBerechtigung(array $lease): bool
{
return self::berechtigungsMenge($lease, self::NETZFAHRPLAN) > 0
|| (self::berechtigungsMenge($lease, self::PROGNOSE_PV) > 0
&& self::berechtigungsMenge($lease, self::PROGNOSE_VERBRAUCH) > 0);
}
/** /**
* Rechnet freie, bestehende Einzellizenzen auf nicht separat lizenzierte Waermepumpen an. * Rechnet freie, bestehende Einzellizenzen auf nicht separat lizenzierte Waermepumpen an.
* *
+210 -9
View File
@@ -256,12 +256,73 @@ trait ManagerEnergieTrait
IPS_SetName($instanzID, 'Energiefluss'); IPS_SetName($instanzID, 'Energiefluss');
IPS_SetPosition($instanzID, 540); IPS_SetPosition($instanzID, 540);
IPS_SetHidden($instanzID, false); IPS_SetHidden($instanzID, false);
$profil = $this->energieflussLeistungsprofil();
$pvID = $this->energieflussLeistungsvariable(
'EnergieflussPV',
'Energiefluss PV',
$profil,
600
);
$netzID = $this->energieflussLeistungsvariable(
'EnergieflussNetz',
'Energiefluss Netz',
$profil,
610
);
$hausID = $this->energieflussLeistungsvariable(
'EnergieflussHaus',
'Energiefluss Hausverbrauch',
$profil,
620
);
$batterieID = $this->energieflussLeistungsvariable(
'EnergieflussBatterie',
'Energiefluss Batteriespeicher',
$profil,
630
);
$verbraucher = $this->energieflussVerbraucher();
$knoten = [ $knoten = [
$this->energieflussKnoten('PVLeistungArchiv', 'PV-Anlage', 'solar'), $this->energieflussKnoten($pvID, 'PV-Anlage', 'solar'),
$this->energieflussKnoten('NetzleistungArchiv', 'Stromnetz', 'grid'), $this->energieflussKnoten($netzID, 'Stromnetz', 'grid'),
$this->energieflussKnoten('HausverbrauchLeistungArchiv', 'Hausverbrauch', 'consumer'), $this->energieflussKnoten($hausID, 'Hausverbrauch', 'consumer'),
$this->energieflussKnoten('BatterieleistungArchiv', 'Batteriespeicher', 'battery'),
]; ];
$hatBatterieverbraucher = false;
$position = 640;
foreach ($verbraucher as $eintrag) {
$istID = $this->energieflussLeistungsvariable(
'EnergieflussIst_' . $eintrag['InstanzID'],
$eintrag['Name'] . ' Istleistung',
$profil,
$position
);
$sollID = $this->energieflussLeistungsvariable(
'EnergieflussSoll_' . $eintrag['InstanzID'],
$eintrag['Name'] . ' Sollleistung',
$profil,
$position + 1
);
$knoten[] = $this->energieflussKnoten(
$istID,
$eintrag['Name'],
$eintrag['Typ'],
true,
$sollID
);
$hatBatterieverbraucher = $hatBatterieverbraucher
|| $eintrag['Typ'] === 'storage';
$position += 10;
}
if (!$hatBatterieverbraucher) {
$knoten[] = $this->energieflussKnoten(
$batterieID,
'Batteriespeicher',
'storage'
);
}
IPS_SetProperty($instanzID, 'Calculation', true); IPS_SetProperty($instanzID, 'Calculation', true);
IPS_SetProperty( IPS_SetProperty(
$instanzID, $instanzID,
@@ -271,26 +332,166 @@ trait ManagerEnergieTrait
IPS_SetProperty($instanzID, 'EnableLimits', false); IPS_SetProperty($instanzID, 'EnableLimits', false);
IPS_SetProperty($instanzID, 'Restrictions', '[]'); IPS_SetProperty($instanzID, 'Restrictions', '[]');
IPS_ApplyChanges($instanzID); IPS_ApplyChanges($instanzID);
$this->aktualisiereEnergieflusswerte($verbraucher);
return 'Der Energiefluss wurde erstellt oder aktualisiert.'; return 'Der Energiefluss wurde erstellt oder aktualisiert.';
} }
/** @return array<string, mixed> */ /** @return array<string, mixed> */
private function energieflussKnoten(string $ident, string $name, string $typ): array private function energieflussKnoten(
{ int $variableID,
string $name,
string $typ,
bool $gruppiert = false,
int $infoVariableID = 1
): array {
return [ return [
'VariableConfigType' => 0, 'VariableConfigType' => 0,
'VariableID' => $this->GetIDForIdent($ident), 'VariableID' => $variableID,
'SecondaryVariableID' => 1, 'SecondaryVariableID' => 1,
'Name' => $name, 'Name' => $name,
'Type' => $typ, 'Type' => $typ,
'Grouped' => false, 'Grouped' => $gruppiert,
'CalculationType' => 0, 'CalculationType' => 0,
'InfoVariableID' => 1, 'InfoVariableID' => $infoVariableID,
'Invert' => false, 'Invert' => false,
]; ];
} }
private function energieflussLeistungsprofil(): string
{
if ($this->ReadPropertyInteger('EnergieflussLeistungseinheit') !== 1) {
return '~Power';
}
$profil = 'Enelix.Power.kW';
if (!IPS_VariableProfileExists($profil)) {
IPS_CreateVariableProfile($profil, 2);
IPS_SetVariableProfileValues($profil, -1000000.0, 1000000.0, 0.001);
IPS_SetVariableProfileDigits($profil, 3);
IPS_SetVariableProfileText($profil, '', ' kW');
}
return $profil;
}
private function energieflussLeistungsvariable(
string $ident,
string $name,
string $profil,
int $position
): int {
$this->RegisterVariableFloat($ident, $name, $profil, $position);
$variableID = $this->GetIDForIdent($ident);
IPS_SetHidden($variableID, true);
return $variableID;
}
/**
* @return list<array{
* InstanzID: int,
* Name: string,
* Typ: string,
* Istleistung_W: float,
* Sollleistung_W: float
* }>
*/
private function energieflussVerbraucher(): array
{
$cache = $this->leseJsonAttribut('VerbraucherCache');
$ergebnis = [];
foreach ($this->konfigurierteAktiveVerbraucherIDs() as $instanzID) {
if (!IPS_InstanceExists($instanzID)) {
continue;
}
$daten = $cache[(string) $instanzID]['Daten'] ?? null;
$istleistung = 0.0;
$sollleistung = 0.0;
if (is_array($daten)) {
$istleistung = $this->verbraucherleistung($daten);
$sollleistung = (float) (ManagerRegler::angenommenerSollwert($daten) ?? 0);
}
$modulID = strtoupper(
(string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
);
$katalog = self::VERBRAUCHER_LIZENZEN[$modulID]['Katalog'] ?? '';
$ergebnis[] = [
'InstanzID' => $instanzID,
'Name' => IPS_GetName($instanzID),
'Typ' => match ($katalog) {
'battery' => 'storage',
'ev_charger' => 'wallbox',
default => 'consumer',
},
'Istleistung_W' => $istleistung,
'Sollleistung_W' => $sollleistung,
];
}
return $ergebnis;
}
/**
* @param list<array<string, mixed>>|null $verbraucher
*/
private function aktualisiereEnergieflusswerte(?array $verbraucher = null): void
{
if (!$this->ReadPropertyBoolean('EnergieflussAnzeigen')) {
return;
}
$verbraucher = $verbraucher ?? $this->energieflussVerbraucher();
$verbraucherleistung = 0.0;
$hatBatterieverbraucher = false;
foreach ($verbraucher as $eintrag) {
$this->setzeEnergieflussLeistungswert(
'EnergieflussIst_' . $eintrag['InstanzID'],
(float) $eintrag['Istleistung_W']
);
$this->setzeEnergieflussLeistungswert(
'EnergieflussSoll_' . $eintrag['InstanzID'],
(float) $eintrag['Sollleistung_W']
);
if ($eintrag['Typ'] === 'storage') {
$hatBatterieverbraucher = true;
} else {
$verbraucherleistung += max(0.0, (float) $eintrag['Istleistung_W']);
}
}
try {
$leistungen = $this->leseMessleistungen();
} catch (Throwable $fehler) {
$this->protokolliere('Energiefluss', $fehler->getMessage());
return;
}
$this->setzeEnergieflussLeistungswert('EnergieflussPV', $leistungen['PV']);
$this->setzeEnergieflussLeistungswert('EnergieflussNetz', $leistungen['Netz']);
$this->setzeEnergieflussLeistungswert(
'EnergieflussHaus',
max(0.0, $leistungen['Haus'] - $verbraucherleistung)
);
if (!$hatBatterieverbraucher) {
$this->setzeEnergieflussLeistungswert(
'EnergieflussBatterie',
$leistungen['Batterie']
);
}
}
private function setzeEnergieflussLeistungswert(string $ident, float $watt): void
{
$variableID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (!is_int($variableID) || $variableID <= 0 || !IPS_VariableExists($variableID)) {
return;
}
$faktor = $this->ReadPropertyInteger('EnergieflussLeistungseinheit') === 1
? 0.001
: 1.0;
SetValue($variableID, $watt * $faktor);
}
private function erstelleEnergiediagramme(): string private function erstelleEnergiediagramme(): string
{ {
$leistung = [ $leistung = [
+211 -12
View File
@@ -19,6 +19,71 @@ final class ManagerRegler
public const BETRIEBSART_PV = Nachrichtenvertrag::BETRIEBSART_PV; public const BETRIEBSART_PV = Nachrichtenvertrag::BETRIEBSART_PV;
public const BETRIEBSART_PEAK = Nachrichtenvertrag::BETRIEBSART_PEAK; public const BETRIEBSART_PEAK = Nachrichtenvertrag::BETRIEBSART_PEAK;
/**
* @param list<array<string, mixed>> $punkte
*/
public static function interpolierteFahrplanVorgabe(
array $punkte,
int $jetzt,
int $maximalerPunktabstandSekunden = 1800
): ?float {
if ($maximalerPunktabstandSekunden < 0) {
throw new InvalidArgumentException('Maximaler Fahrplanpunktabstand darf nicht negativ sein.');
}
$gueltigePunkte = [];
foreach ($punkte as $punkt) {
if (!is_array($punkt)
|| !is_string($punkt['time'] ?? null)
|| (!is_int($punkt['gridTargetW'] ?? null)
&& !is_float($punkt['gridTargetW'] ?? null))
) {
continue;
}
$zeitpunkt = strtotime($punkt['time']);
$vorgabe = (float) $punkt['gridTargetW'];
if ($zeitpunkt === false || !is_finite($vorgabe)) {
continue;
}
$gueltigePunkte[$zeitpunkt] = $vorgabe;
}
if ($gueltigePunkte === []) {
return null;
}
ksort($gueltigePunkte, SORT_NUMERIC);
$vorherigerZeitpunkt = null;
$vorherigeVorgabe = null;
foreach ($gueltigePunkte as $zeitpunkt => $vorgabe) {
if ($zeitpunkt === $jetzt) {
return $vorgabe;
}
if ($zeitpunkt > $jetzt) {
if ($vorherigerZeitpunkt === null) {
return $zeitpunkt - $jetzt <= $maximalerPunktabstandSekunden
? $vorgabe
: null;
}
if ($jetzt - $vorherigerZeitpunkt > $maximalerPunktabstandSekunden
|| $zeitpunkt - $jetzt > $maximalerPunktabstandSekunden
) {
return null;
}
$anteil = ($jetzt - $vorherigerZeitpunkt) / ($zeitpunkt - $vorherigerZeitpunkt);
return $vorherigeVorgabe + (($vorgabe - $vorherigeVorgabe) * $anteil);
}
$vorherigerZeitpunkt = $zeitpunkt;
$vorherigeVorgabe = $vorgabe;
}
return $vorherigerZeitpunkt !== null
&& $jetzt - $vorherigerZeitpunkt <= $maximalerPunktabstandSekunden
? $vorherigeVorgabe
: null;
}
/** /**
* @param array<int, float|int> $monatsgrenzen Monatsnummer 1 bis 12 als Schluessel * @param array<int, float|int> $monatsgrenzen Monatsnummer 1 bis 12 als Schluessel
*/ */
@@ -92,6 +157,7 @@ final class ManagerRegler
/** /**
* @param list<array<string, mixed>> $verbraucher Vollstaendige, bereits gepruefte Verbraucherdaten * @param list<array<string, mixed>> $verbraucher Vollstaendige, bereits gepruefte Verbraucherdaten
* @param array<int, float|int> $bezogeneEnergieWh Bezogene Energie je Verbraucher in Wh
* @return array{ * @return array{
* Verteilbudget_W: int, * Verteilbudget_W: int,
* AktuelleVerbraucherleistung_W: int, * AktuelleVerbraucherleistung_W: int,
@@ -104,7 +170,8 @@ final class ManagerRegler
float $netzleistung, float $netzleistung,
float $zielnetzleistung, float $zielnetzleistung,
array $verbraucher, array $verbraucher,
string $prioritaetsfeld string $prioritaetsfeld,
array $bezogeneEnergieWh = []
): array { ): array {
if (!in_array($prioritaetsfeld, ['PrioritaetPV', 'PrioritaetPeak'], true)) { if (!in_array($prioritaetsfeld, ['PrioritaetPV', 'PrioritaetPeak'], true)) {
throw new InvalidArgumentException('Unbekanntes Prioritaetsfeld.'); throw new InvalidArgumentException('Unbekanntes Prioritaetsfeld.');
@@ -120,44 +187,107 @@ final class ManagerRegler
$aktuelleLeistung += $leistung; $aktuelleLeistung += $leistung;
$daten = $eintrag['Daten']; $daten = $eintrag['Daten'];
if (!$daten['Verfuegbar'] || !$daten['AenderungMoeglich'] || $daten['Leistungswerte_W'] === []) { if (!$daten['Verfuegbar'] || $daten['Leistungswerte_W'] === []) {
$nichtSteuerbar += $leistung; $nichtSteuerbar += $leistung;
continue; continue;
} }
$festwert = self::istFestwert($daten['Leistungswerte_W']);
$festgehalten = !$daten['AenderungMoeglich'];
if ($festwert || $festgehalten) {
$nichtSteuerbar += $leistung;
}
$energie = $bezogeneEnergieWh[$eintrag['InstanzID']] ?? 0.0;
if ((!is_int($energie) && !is_float($energie))
|| (is_float($energie) && !is_finite($energie))
) {
throw new InvalidArgumentException('Bezogene Energie muss eine endliche Zahl sein.');
}
$steuerbar[] = [ $steuerbar[] = [
'InstanzID' => $eintrag['InstanzID'], 'InstanzID' => $eintrag['InstanzID'],
'Prioritaet' => $daten[$prioritaetsfeld], 'Prioritaet' => $daten[$prioritaetsfeld],
'Energiegruppe' => (int) round((float) $energie / 2000.0),
'Leistungswerte_W' => $daten['Leistungswerte_W'], 'Leistungswerte_W' => $daten['Leistungswerte_W'],
'Festwert' => $festwert,
'Festgehalten' => $festgehalten,
'Sollwert' => self::angenommenerSollwert($daten),
]; ];
} }
usort($steuerbar, static function (array $links, array $rechts): int { usort($steuerbar, static function (array $links, array $rechts): int {
return [$links['Prioritaet'], $links['InstanzID']] <=> [$rechts['Prioritaet'], $rechts['InstanzID']]; return [
$links['Prioritaet'],
$links['Energiegruppe'],
$links['InstanzID'],
] <=> [
$rechts['Prioritaet'],
$rechts['Energiegruppe'],
$rechts['InstanzID'],
];
}); });
$verteilbudget = (int) round($aktuelleLeistung + $zielnetzleistung - $netzleistung); $verteilbudget = (int) round($aktuelleLeistung + $zielnetzleistung - $netzleistung);
$positiveRichtung = $verteilbudget - $nichtSteuerbar >= 0;
$sollwerte = []; $sollwerte = [];
$minimumSumme = 0; $ausgangssumme = 0;
foreach ($steuerbar as $eintrag) { foreach ($steuerbar as $eintrag) {
$minimum = self::kleinsterLeistungswert($eintrag['Leistungswerte_W']); if ($eintrag['Festwert']) {
$sollwerte[$eintrag['InstanzID']] = $minimum; $sollwerte[$eintrag['InstanzID']] = self::kleinsterLeistungswert(
$minimumSumme += $minimum; $eintrag['Leistungswerte_W']
);
continue;
}
if ($eintrag['Festgehalten']) {
if ($eintrag['Sollwert'] !== null) {
$sollwerte[$eintrag['InstanzID']] = $eintrag['Sollwert'];
}
continue;
} }
$verfuegbar = max(0, $verteilbudget - $nichtSteuerbar - $minimumSumme); $ausgangswert = self::richtungsneutralerLeistungswert(
$eintrag['Leistungswerte_W'],
$positiveRichtung
);
$sollwerte[$eintrag['InstanzID']] = $ausgangswert;
$ausgangssumme += $ausgangswert;
}
$rest = $verteilbudget - $nichtSteuerbar - $ausgangssumme;
foreach ($steuerbar as $eintrag) { foreach ($steuerbar as $eintrag) {
if ($eintrag['Festwert'] || $eintrag['Festgehalten']) {
continue;
}
$instanzID = $eintrag['InstanzID']; $instanzID = $eintrag['InstanzID'];
$minimum = $sollwerte[$instanzID]; $ausgangswert = $sollwerte[$instanzID];
if ($positiveRichtung) {
if ($rest <= 0) {
continue;
}
$neuerSollwert = self::groessterLeistungswertBis( $neuerSollwert = self::groessterLeistungswertBis(
$eintrag['Leistungswerte_W'], $eintrag['Leistungswerte_W'],
$minimum + $verfuegbar $ausgangswert + $rest
); );
$verfuegbar -= $neuerSollwert - $minimum; } else {
if ($rest >= 0) {
continue;
}
$neuerSollwert = self::kleinsterLeistungswertAb(
$eintrag['Leistungswerte_W'],
$ausgangswert + $rest
);
}
$rest -= $neuerSollwert - $ausgangswert;
$sollwerte[$instanzID] = $neuerSollwert; $sollwerte[$instanzID] = $neuerSollwert;
} }
$zugeteilt = $nichtSteuerbar + array_sum($sollwerte); $zugeteilt = $nichtSteuerbar;
foreach ($steuerbar as $eintrag) {
if (!$eintrag['Festwert'] && !$eintrag['Festgehalten']) {
$zugeteilt += $sollwerte[$eintrag['InstanzID']];
}
}
return [ return [
'Verteilbudget_W' => $verteilbudget, 'Verteilbudget_W' => $verteilbudget,
@@ -214,6 +344,53 @@ final class ManagerRegler
} }
} }
/**
* Ein einzelner Leistungswert bleibt als Befehl erhalten. Fuer die
* Leistungsbilanz zaehlt jedoch die gemessene Istleistung.
*
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
*/
private static function istFestwert(array $leistungswerte): bool
{
return count($leistungswerte) === 1 && is_int($leistungswerte[0]);
}
/**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
*/
private static function richtungsneutralerLeistungswert(
array $leistungswerte,
bool $positiveRichtung
): int {
$negativ = null;
$positiv = null;
foreach ($leistungswerte as $eintrag) {
if (is_int($eintrag)) {
if ($eintrag === 0) {
return 0;
}
$negativ = $eintrag < 0 ? max($negativ ?? $eintrag, $eintrag) : $negativ;
$positiv = $eintrag > 0 ? min($positiv ?? $eintrag, $eintrag) : $positiv;
continue;
}
if ($eintrag['Von_W'] <= 0 && $eintrag['Bis_W'] >= 0) {
return 0;
}
$negativ = $eintrag['Bis_W'] < 0
? max($negativ ?? $eintrag['Bis_W'], $eintrag['Bis_W'])
: $negativ;
$positiv = $eintrag['Von_W'] > 0
? min($positiv ?? $eintrag['Von_W'], $eintrag['Von_W'])
: $positiv;
}
if ($positiveRichtung) {
return $positiv ?? $negativ;
}
return $negativ ?? $positiv;
}
/** /**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte * @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
*/ */
@@ -247,4 +424,26 @@ final class ManagerRegler
return $gewaehlt; return $gewaehlt;
} }
/**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
*/
private static function kleinsterLeistungswertAb(array $leistungswerte, int $untergrenze): int
{
foreach ($leistungswerte as $eintrag) {
if (is_int($eintrag)) {
if ($eintrag >= $untergrenze) {
return $eintrag;
}
continue;
}
if ($eintrag['Bis_W'] < $untergrenze) {
continue;
}
return max($eintrag['Von_W'], $untergrenze);
}
return self::richtungsneutralerLeistungswert($leistungswerte, false);
}
} }
+31
View File
@@ -45,6 +45,37 @@ final class EaseeGatewayProtokollTest extends TestCase
self::assertSame([], $beobachtungen); self::assertSame([], $beobachtungen);
} }
public function testRestBeobachtungenVerwendenAngefragteSeriennummer(): void
{
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
['id' => 109, 'value' => 1],
['observationId' => 110, 'value' => 30],
], ['EU2FGKEU'], 'EU2FGKEU');
self::assertSame([
['Seriennummer' => 'EU2FGKEU', 'ID' => 109, 'Wert' => 1],
['Seriennummer' => 'EU2FGKEU', 'ID' => 110, 'Wert' => 30],
], $beobachtungen);
}
public function testFreigabediagnoseWirdAusBeobachtungenExtrahiert(): void
{
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
'serialNumber' => 'EH123456',
'observations' => [
['id' => 42, 'value' => true],
['id' => 43, 'value' => true],
['id' => 96, 'value' => 55],
],
], ['EH123456']);
self::assertSame([
['Seriennummer' => 'EH123456', 'ID' => 42, 'Wert' => true],
['Seriennummer' => 'EH123456', 'ID' => 43, 'Wert' => true],
['Seriennummer' => 'EH123456', 'ID' => 96, 'Wert' => 55],
], $beobachtungen);
}
public function testSeriennummerWirdNormalisiert(): void public function testSeriennummerWirdNormalisiert(): void
{ {
self::assertSame( self::assertSame(
+45
View File
@@ -77,6 +77,33 @@ final class EaseeLadestatusTest extends TestCase
self::assertSame(0, $status['Phasenzahl']); self::assertSame(0, $status['Phasenzahl']);
} }
public function testStillstehendeWallboxSetztVeralteteLeistungUndStromAufNull(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 2,
'110' => 30,
'120' => 10.294,
'182' => 14.9,
'183' => 14.8,
'184' => 14.7,
], 32);
self::assertEqualsWithDelta(0.0, $status['Istleistung_W'], 0.01);
self::assertEqualsWithDelta(0.0, $status['Ladestrom_A'], 0.01);
}
public function testFertigGeladeneWallboxSetztVeralteteLeistungAufNull(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 4,
'110' => 30,
'120' => 10.294,
], 32);
self::assertTrue($status['FahrzeugGeladen']);
self::assertEqualsWithDelta(0.0, $status['Istleistung_W'], 0.01);
}
public function testApiUndKabelgrenzenBegrenzenDenKonfiguriertenStrom(): void public function testApiUndKabelgrenzenBegrenzenDenKonfiguriertenStrom(): void
{ {
$status = EaseeLadestatus::ausBeobachtungen([ $status = EaseeLadestatus::ausBeobachtungen([
@@ -89,6 +116,24 @@ final class EaseeLadestatusTest extends TestCase
self::assertSame(13, $status['ApiMaximalstrom_A']); self::assertSame(13, $status['ApiMaximalstrom_A']);
} }
public function testFreigabeUndGrundOhneLadestromKommenAusEaseeBeobachtungen(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'31' => true,
'42' => true,
'43' => true,
'96' => 55,
'102' => true,
'109' => 2,
], 16);
self::assertTrue($status['EaseeAktiviert']);
self::assertTrue($status['AutorisierungErforderlich']);
self::assertTrue($status['RemoteStartErforderlich']);
self::assertTrue($status['SmartCharging']);
self::assertSame(55, $status['GrundKeinLadestrom']);
}
public function testNurSolarladenIstFestErzwungen(): void public function testNurSolarladenIstFestErzwungen(): void
{ {
self::assertFalse(EaseeLadestatus::solarladenErzwungen(0)); self::assertFalse(EaseeLadestatus::solarladenErzwungen(0));
+6
View File
@@ -70,6 +70,12 @@ final class EaseeModuleStrukturTest extends TestCase
self::assertStringContainsString("'Phasenzahl'", $modul); self::assertStringContainsString("'Phasenzahl'", $modul);
self::assertStringNotContainsString('7500', $modul); self::assertStringNotContainsString('7500', $modul);
self::assertStringNotContainsString('Abfrageintervall', $modul); self::assertStringNotContainsString('Abfrageintervall', $modul);
self::assertStringContainsString("'action' => 'StartCharging'", $modul);
$gateway = (string) file_get_contents(
__DIR__ . '/../EaseeGateway/module.php'
);
self::assertStringContainsString('/commands/start_charging', $gateway);
} }
/** @return array<string, mixed> */ /** @return array<string, mixed> */
+20 -6
View File
@@ -13,7 +13,7 @@ final class LadestationAdapterTest extends TestCase
/** /**
* @dataProvider statusFaelle * @dataProvider statusFaelle
* @param array<string, string> $konfiguration * @param array<string, string> $konfiguration
* @param array{Rohstatus: int, Istleistung_W: float} $erwartet * @param array{Rohstatus: int, Istleistung_W: float, Iststrom_A?: float, Phasenzahl?: int} $erwartet
*/ */
public function testStatusaufrufUndDekodierungFuerAlleGeraete( public function testStatusaufrufUndDekodierungFuerAlleGeraete(
int $geraetetyp, int $geraetetyp,
@@ -47,16 +47,26 @@ final class LadestationAdapterTest extends TestCase
yield 'go-e alt' => [ yield 'go-e alt' => [
LadestationAdapter::GO_E_ALT, LadestationAdapter::GO_E_ALT,
$this->lokaleKonfiguration(), $this->lokaleKonfiguration(),
$this->goEAntwort(2, 1100), $this->goEAntwort(2, 1100, [160, 158, 159]),
'http://192.0.2.10/mqtt?payload=', 'http://192.0.2.10/mqtt?payload=',
['Rohstatus' => 2, 'Istleistung_W' => 11000.0], [
'Rohstatus' => 2,
'Istleistung_W' => 11000.0,
'Iststrom_A' => 16.0,
'Phasenzahl' => 3,
],
]; ];
yield 'go-e Gemini' => [ yield 'go-e Gemini' => [
LadestationAdapter::GO_E_GEMINI, LadestationAdapter::GO_E_GEMINI,
$this->lokaleKonfiguration(), $this->lokaleKonfiguration(),
$this->goEAntwort(2, 2300), $this->goEAntwort(2, 2300, [9.8, 0, 0]),
'http://192.0.2.10/api/status', 'http://192.0.2.10/api/status',
['Rohstatus' => 2, 'Istleistung_W' => 2300.0], [
'Rohstatus' => 2,
'Istleistung_W' => 2300.0,
'Iststrom_A' => 9.8,
'Phasenzahl' => 1,
],
]; ];
yield 'Smart-me Pico' => [ yield 'Smart-me Pico' => [
LadestationAdapter::SMART_ME_PICO, LadestationAdapter::SMART_ME_PICO,
@@ -161,9 +171,13 @@ final class LadestationAdapterTest extends TestCase
]; ];
} }
private function goEAntwort(int $status, float $leistung): string /** @param list<int|float> $stroeme */
private function goEAntwort(int $status, float $leistung, array $stroeme): string
{ {
$nrg = array_fill(0, 12, 0); $nrg = array_fill(0, 12, 0);
foreach (array_values($stroeme) as $index => $strom) {
$nrg[4 + $index] = $strom;
}
$nrg[11] = $leistung; $nrg[11] = $leistung;
return json_encode(['car' => $status, 'nrg' => $nrg], JSON_THROW_ON_ERROR); return json_encode(['car' => $status, 'nrg' => $nrg], JSON_THROW_ON_ERROR);
+89 -6
View File
@@ -22,28 +22,111 @@ final class LadestationReglerTest extends TestCase
self::assertSame(LadestationRegler::PHASEN_DREI, $status['Phasenzahl']); self::assertSame(LadestationRegler::PHASEN_DREI, $status['Phasenzahl']);
} }
public function testEinphasigesFahrzeugWirdUnterhalb7500WErkannt(): void public function testDirektGemeldeteEinphasigkeitWirdAuchUnter7500WErkannt(): void
{ {
$status = LadestationRegler::normalisiereStatus([ $status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2, 'Rohstatus' => 2,
'Istleistung_W' => 2300.0, 'Istleistung_W' => 2300.0,
'Phasenzahl' => 1,
], false); ], false);
self::assertSame(LadestationRegler::PHASEN_EIN, $status['Phasenzahl']); self::assertSame(LadestationRegler::PHASEN_EIN, $status['Phasenzahl']);
self::assertEqualsWithDelta(10.0, $status['Ladestrom_A'], 0.01); self::assertEqualsWithDelta(10.0, $status['Ladestrom_A'], 0.01);
} }
public function testGeladenWirdNachVerbindungUnterSechsAmpereErkannt(): void public function testErkannteDreiphasigkeitBleibtWaehrendLadepauseErhalten(): void
{ {
$status = LadestationRegler::normalisiereStatus([ $status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2, 'Rohstatus' => 2,
'Istleistung_W' => 0.0, 'Istleistung_W' => 0.0,
], true); ], true, false, false, true, LadestationRegler::PHASEN_DREI);
self::assertSame(LadestationRegler::PHASEN_DREI, $status['Phasenzahl']);
self::assertSame(0.0, $status['Ladestrom_A']);
}
public function testPhasenBleibenOhneMessungBisZurErkennungUnbekannt(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 0.0,
], false);
self::assertSame(LadestationRegler::PHASEN_UNBEKANNT, $status['Phasenzahl']);
self::assertSame([0], LadestationRegler::leistungsangebot(
true,
true,
true,
false,
LadestationRegler::PHASEN_UNBEKANNT,
16,
true,
false
));
}
public function testAnlaufOhneGemesseneLadeleistungGiltNichtAlsLadeende(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 0.0,
], true, false, true, false);
self::assertFalse($status['FahrzeugGeladen']);
self::assertSame(2, $status['Fahrzeugstatus']);
}
public function testGeladenWirdErstNachZuvorGemessenerLadeleistungErkannt(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 0.0,
], true, false, true, true);
self::assertTrue($status['FahrzeugGeladen']); self::assertTrue($status['FahrzeugGeladen']);
self::assertSame(4, $status['Fahrzeugstatus']); self::assertSame(4, $status['Fahrzeugstatus']);
} }
public function testAbsichtlichGestoppteLadungWirdNichtAlsGeladenErkannt(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 0.0,
], true, false, false);
self::assertFalse($status['FahrzeugGeladen']);
self::assertSame(2, $status['Fahrzeugstatus']);
}
public function testErkanntesLadeendeBleibtBisZumAbsteckenErhalten(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 0.0,
], true, true, false);
self::assertTrue($status['FahrzeugGeladen']);
self::assertSame(4, $status['Fahrzeugstatus']);
}
public function testDirekterPhasenstromHatVorrangVorNominalerLeistungsumrechnung(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 10263.0,
'Iststrom_A' => 15.9,
], true);
self::assertEqualsWithDelta(15.9, $status['Ladestrom_A'], 0.01);
}
public function testFahrzeugMaximalstromWirdMitZweiAmpereAbstandErkannt(): void
{
self::assertSame(18, LadestationRegler::ermittleFahrzeugMaximalstrom(20, 15.9, 20));
self::assertNull(LadestationRegler::ermittleFahrzeugMaximalstrom(17, 15.9, 20));
self::assertNull(LadestationRegler::ermittleFahrzeugMaximalstrom(20, 0.0, 20));
}
public function testBetriebsartUndSolarladenBestimmenDasLeistungsangebot(): void public function testBetriebsartUndSolarladenBestimmenDasLeistungsangebot(): void
{ {
$solarPV = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, true, false); $solarPV = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, true, false);
@@ -53,7 +136,7 @@ final class LadestationReglerTest extends TestCase
self::assertSame(0, $solarPV[0]); self::assertSame(0, $solarPV[0]);
self::assertCount(12, $solarPV); self::assertCount(12, $solarPV);
self::assertSame([3850], $normalPV); self::assertSame([3680], $normalPV);
self::assertSame([0], $solarPeak); self::assertSame([0], $solarPeak);
self::assertSame($solarPV, $normalPeak); self::assertSame($solarPV, $normalPeak);
} }
@@ -61,7 +144,7 @@ final class LadestationReglerTest extends TestCase
public function testPeakKannJedesFahrzeugAufNullSetzen(): void public function testPeakKannJedesFahrzeugAufNullSetzen(): void
{ {
self::assertSame(0, LadestationRegler::stromFuerLeistung(0, 3)); self::assertSame(0, LadestationRegler::stromFuerLeistung(0, 3));
self::assertSame(6, LadestationRegler::stromFuerLeistung(4150, 3)); self::assertSame(6, LadestationRegler::stromFuerLeistung(4104, 3));
self::assertSame(16, LadestationRegler::stromFuerLeistung(11000, 3)); self::assertSame(16, LadestationRegler::stromFuerLeistung(10944, 3));
} }
} }
@@ -68,6 +68,22 @@ final class LadestationStandAloneModulstrukturTest extends TestCase
"RegisterPropertyBoolean('Ladefreigabe', true)", "RegisterPropertyBoolean('Ladefreigabe', true)",
$modul $modul
); );
self::assertStringContainsString(
"RegisterPropertyInteger('Phasenerkennungszeit', 60)",
$modul
);
self::assertStringContainsString(
"RegisterPropertyInteger('ZeitZwischenZustandswechseln', 1)",
$modul
);
self::assertStringContainsString(
"RegisterPropertyInteger('Mindesteinschaltdauer', 0)",
$modul
);
self::assertStringContainsString(
"RegisterPropertyInteger('Mindestausschaltdauer', 0)",
$modul
);
self::assertStringContainsString("EnableAction('Ladefreigabe')", $modul); self::assertStringContainsString("EnableAction('Ladefreigabe')", $modul);
self::assertStringContainsString("EnableAction('Solarladen')", $modul); self::assertStringContainsString("EnableAction('Solarladen')", $modul);
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul); self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul);
+41
View File
@@ -228,6 +228,47 @@ final class LizenzpruefungTest extends TestCase
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen); Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
} }
public function testPvUndVerbrauchGebenPrognoseFrei(): void
{
$lease = $this->lease('manager_standard');
$lease['entitlements'][] = [
'catalog_key' => Lizenzpruefung::PROGNOSE_PV,
'quantity' => 1,
'status' => 'active',
];
$lease['entitlements'][] = [
'catalog_key' => Lizenzpruefung::PROGNOSE_VERBRAUCH,
'quantity' => 1,
'status' => 'active',
];
self::assertTrue(Lizenzpruefung::hatPrognoseBerechtigung($lease));
}
public function testEinzelnePrognoseBerechtigungReichtNichtAus(): void
{
$lease = $this->lease('manager_standard');
$lease['entitlements'][] = [
'catalog_key' => Lizenzpruefung::PROGNOSE_PV,
'quantity' => 1,
'status' => 'active',
];
self::assertFalse(Lizenzpruefung::hatPrognoseBerechtigung($lease));
}
public function testNetzfahrplanGibtPrognoseAlleineFrei(): void
{
$lease = $this->lease('manager_standard');
$lease['entitlements'][] = [
'catalog_key' => Lizenzpruefung::NETZFAHRPLAN,
'quantity' => 1,
'status' => 'active',
];
self::assertTrue(Lizenzpruefung::hatPrognoseBerechtigung($lease));
}
public function testLizenzcodeWirdWieAufDemServerNormalisiert(): void public function testLizenzcodeWirdWieAufDemServerNormalisiert(): void
{ {
self::assertSame( self::assertSame(
@@ -19,6 +19,7 @@ final class ManagerEnergieModulstrukturTest extends TestCase
"RegisterPropertyBoolean('EnergiediagrammeAnzeigen', false)", "RegisterPropertyBoolean('EnergiediagrammeAnzeigen', false)",
"RegisterPropertyBoolean('FunFactsAnzeigen', false)", "RegisterPropertyBoolean('FunFactsAnzeigen', false)",
"RegisterPropertyBoolean('EnergieflussAnzeigen', false)", "RegisterPropertyBoolean('EnergieflussAnzeigen', false)",
"RegisterPropertyInteger('EnergieflussLeistungseinheit', 0)",
"RegisterPropertyInteger('LeistungsaufzeichnungMinuten', 1)", "RegisterPropertyInteger('LeistungsaufzeichnungMinuten', 1)",
"RegisterPropertyInteger('LeistungLoeschenMonate', 12)", "RegisterPropertyInteger('LeistungLoeschenMonate', 12)",
"RegisterPropertyInteger('EnergieaufzeichnungMinuten', 5)", "RegisterPropertyInteger('EnergieaufzeichnungMinuten', 5)",
@@ -48,6 +49,7 @@ final class ManagerEnergieModulstrukturTest extends TestCase
'FormEnergiediagrammeAnzeigen', 'FormEnergiediagrammeAnzeigen',
'FormFunFactsAnzeigen', 'FormFunFactsAnzeigen',
'FormEnergieflussAnzeigen', 'FormEnergieflussAnzeigen',
'EnergieflussLeistungseinheit',
'MesswerteAnzeigen', 'MesswerteAnzeigen',
'IstleistungVariableID', 'IstleistungVariableID',
'LeistungVariableID', 'LeistungVariableID',
+43 -6
View File
@@ -218,14 +218,28 @@ final class ManagerModulstrukturTest extends TestCase
self::assertStringContainsString("'issueDeviceToken' =>", $inhalt); self::assertStringContainsString("'issueDeviceToken' =>", $inhalt);
self::assertStringContainsString("unset(\$neueLease['deviceToken'])", $inhalt); self::assertStringContainsString("unset(\$neueLease['deviceToken'])", $inhalt);
self::assertStringContainsString( self::assertStringContainsString(
"|| \$this->ReadPropertyBoolean('StoerueberwachungAktiv'))", "|| \$this->ReadPropertyBoolean('StoerueberwachungAktiv')",
$inhalt $inhalt
); );
self::assertGreaterThanOrEqual(
3,
substr_count($inhalt, '$this->geraetezugangBenoetigt()'),
'Der Geraetezugang muss beim Anwenden, Erneuern und Anfordern beruecksichtigt werden.'
);
self::assertStringContainsString( self::assertStringContainsString(
"\$this->ReadPropertyString('Lizenzcode'),\n \$geraetezugangFehlt", "\$this->ReadPropertyString('Lizenzcode'),\n \$geraetezugangFehlt",
$inhalt $inhalt
); );
self::assertStringContainsString('synchronisiereAnlagentopologie()', $inhalt); self::assertStringContainsString('synchronisiereAnlagentopologie()', $inhalt);
self::assertStringContainsString('private function geraetezugangBenoetigt(): bool', $inhalt);
self::assertStringContainsString(
"if (\$this->aktualisiereLizenzfreigabe(\$wert, true)) {",
$inhalt
);
self::assertStringNotContainsString(
"if (!\$this->ReadPropertyBoolean('PrognoseAktiv')) {\n \$this->setzeDiagnosewert('Prognosestatus', 'NichtVerwendet');\n return;\n }\n\n try {",
$inhalt
);
self::assertStringContainsString( self::assertStringContainsString(
'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry', 'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry',
$inhalt $inhalt
@@ -254,6 +268,25 @@ final class ManagerModulstrukturTest extends TestCase
); );
} }
public function testManagerRegeltAktuelleVerbraucherTrotzFehlenderRueckmeldungen(): void
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertStringNotContainsString("if (!\$synchronisiert) {", $inhalt);
self::assertStringContainsString(
"'Synchronisiert' => \$synchronisiert",
$inhalt
);
self::assertStringContainsString(
"'NichtGeregelt' => \$nichtGeregelt",
$inhalt
);
self::assertStringContainsString(
"\$ergebnis = ManagerRegler::verteile(",
$inhalt
);
}
public function testManagerUebertraegtStoerungsvollsnapshotsMitRetry(): void public function testManagerUebertraegtStoerungsvollsnapshotsMitRetry(): void
{ {
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); $inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
@@ -280,13 +313,17 @@ final class ManagerModulstrukturTest extends TestCase
'"name":"StoermeldeAnschluss"', '"name":"StoermeldeAnschluss"',
str_replace(' ', '', (string) file_get_contents(__DIR__ . '/../Manager/form.json')) str_replace(' ', '', (string) file_get_contents(__DIR__ . '/../Manager/form.json'))
); );
self::assertStringContainsString(
"if (\$token === '') {\n"
. " \$this->aktualisiereLizenzfreigabe(\n"
. " \$this->ReadPropertyString('Lizenzcode'),\n"
. " true",
$inhalt
);
self::assertGreaterThanOrEqual( self::assertGreaterThanOrEqual(
3, 3,
substr_count( substr_count($inhalt, '$this->geraetezugangBenoetigt()'),
$inhalt, 'Der Geraetezugang muss beim Anwenden, Erneuern und Anfordern beruecksichtigt werden.'
"|| \$this->ReadPropertyBoolean('StoerueberwachungAktiv'))"
),
'Der Gerätezugang muss beim Anwenden, Erneuern und Anfordern berücksichtigt werden.'
); );
} }
+273 -4
View File
@@ -11,6 +11,59 @@ use PHPUnit\Framework\TestCase;
final class ManagerReglerTest extends TestCase final class ManagerReglerTest extends TestCase
{ {
public function testFahrplanVorgabeWirdZwischenPunktenLinearInterpoliert(): void
{
$punkte = [
['time' => '2026-09-30T11:35:00+00:00', 'gridTargetW' => 20486.254],
['time' => '2026-09-30T11:30:00+00:00', 'gridTargetW' => 15664.283],
];
$vorgabe = ManagerRegler::interpolierteFahrplanVorgabe(
$punkte,
(int) strtotime('2026-09-30T11:32:30+00:00')
);
self::assertEqualsWithDelta(18075.2685, $vorgabe, 0.0001);
}
public function testFahrplanVorgabeVerwendetRandpunktInnerhalbDerToleranz(): void
{
$punkte = [
['time' => '2026-09-30T11:30:00+00:00', 'gridTargetW' => 15664.283],
['time' => '2026-09-30T11:35:00+00:00', 'gridTargetW' => 20486.254],
];
self::assertSame(
15664.283,
ManagerRegler::interpolierteFahrplanVorgabe(
$punkte,
(int) strtotime('2026-09-30T11:29:44+00:00')
)
);
self::assertSame(
20486.254,
ManagerRegler::interpolierteFahrplanVorgabe(
$punkte,
(int) strtotime('2026-09-30T11:35:10+00:00')
)
);
}
public function testFahrplanVorgabeVerwirftZuGrosseZeitlicheLuecke(): void
{
$punkte = [
['time' => '2026-09-30T11:00:00+00:00', 'gridTargetW' => 10000],
['time' => '2026-09-30T12:00:00+00:00', 'gridTargetW' => 20000],
];
self::assertNull(
ManagerRegler::interpolierteFahrplanVorgabe(
$punkte,
(int) strtotime('2026-09-30T11:31:00+00:00')
)
);
}
public function testOhnePeakBegrenzungBleibtSolarladenAktiv(): void public function testOhnePeakBegrenzungBleibtSolarladenAktiv(): void
{ {
self::assertSame( self::assertSame(
@@ -113,21 +166,215 @@ final class ManagerReglerTest extends TestCase
self::assertSame(500, $ergebnis['Restabweichung_W']); self::assertSame(500, $ergebnis['Restabweichung_W']);
} }
public function testNichtAenderbarerVerbraucherReserviertSeineIstleistung(): void public function testGleichePrioritaetBevorzugtWieEnelix1DenVerbraucherMitWenigerEnergie(): void
{ {
$fest = $this->verbraucher(10, 0, [0, 3000], 3000, false); $ergebnis = ManagerRegler::verteile(
0.0,
3000.0,
[
$this->verbraucher(10, 0, [0, 3000], 0),
$this->verbraucher(20, 0, [0, 3000], 0),
],
'PrioritaetPV',
[10 => 3000.0, 20 => 0.0]
);
self::assertSame([20 => 3000, 10 => 0], $ergebnis['Sollwerte']);
self::assertSame(0, $ergebnis['Restabweichung_W']);
}
public function testEnergievergleichVerwendetWieEnelix1ZweiKilowattstundenGruppen(): void
{
$ergebnis = ManagerRegler::verteile(
0.0,
3000.0,
[
$this->verbraucher(10, 0, [0, 3000], 0),
$this->verbraucher(20, 0, [0, 3000], 0),
],
'PrioritaetPV',
[10 => 999.0, 20 => 0.0]
);
self::assertSame([10 => 3000, 20 => 0], $ergebnis['Sollwerte']);
}
public function testFestwertWirdUnabhaengigVonAenderungMoeglichZugewiesen(): void
{
$fest = $this->verbraucher(10, 0, [3000], 3000, false);
$steuerbar = $this->verbraucher(20, 1, [0, ['Von_W' => 1, 'Bis_W' => 4000]], 0); $steuerbar = $this->verbraucher(20, 1, [0, ['Von_W' => 1, 'Bis_W' => 4000]], 0);
$ergebnis = ManagerRegler::verteile(3000.0, 5000.0, [$fest, $steuerbar], 'PrioritaetPV'); $ergebnis = ManagerRegler::verteile(3000.0, 5000.0, [$fest, $steuerbar], 'PrioritaetPV');
self::assertSame(3000, $ergebnis['NichtSteuerbar_W']); self::assertSame(3000, $ergebnis['NichtSteuerbar_W']);
self::assertSame([20 => 2000], $ergebnis['Sollwerte']); self::assertSame([10 => 3000, 20 => 2000], $ergebnis['Sollwerte']);
self::assertSame(0, $ergebnis['Restabweichung_W']);
}
public function testFestwerteWerdenNachIstleistungBilanziertUndAlsBefehlBeibehalten(): void
{
$ergebnis = ManagerRegler::verteile(
-2411.0,
0.0,
[
$this->verbraucher(
44234,
0,
[['Von_W' => -39000, 'Bis_W' => 39000]],
0
),
$this->verbraucher(38676, 1, [0, 4600], 0),
$this->verbraucher(29743, 1, [0, 6500], 0),
$this->verbraucher(43878, 2, [13850], 10151, false),
$this->verbraucher(46052, 2, [11000], 0, false),
],
'PrioritaetPV'
);
self::assertSame(12562, $ergebnis['Verteilbudget_W']);
self::assertSame(10151, $ergebnis['NichtSteuerbar_W']);
self::assertSame(
[
44234 => 2411,
29743 => 0,
38676 => 0,
43878 => 13850,
46052 => 11000,
],
$ergebnis['Sollwerte']
);
self::assertSame(0, $ergebnis['Restabweichung_W']);
}
public function testBatterieGleichtGemesseneFesteLastenAusOhneSolarladungZuSpeisen(): void
{
$ergebnis = ManagerRegler::verteile(
-4526.0,
-50.0,
[
$this->verbraucher(29743, 1, [0, 6500], 6500),
$this->verbraucher(38676, 1, [0, 4600], 4600),
$this->verbraucher(22078, 2, [7690], 0, false),
$this->verbraucher(31986, 2, [11000], 10942, false),
$this->verbraucher(43878, 2, [0, 4150, 10300], 9908),
$this->verbraucher(
44234,
6,
[['Von_W' => -39000, 'Bis_W' => 39000]],
-22769
),
],
'PrioritaetPV'
);
self::assertSame(13657, $ergebnis['Verteilbudget_W']);
self::assertSame(10942, $ergebnis['NichtSteuerbar_W']);
self::assertSame(
[
29743 => 0,
38676 => 0,
22078 => 7690,
31986 => 11000,
43878 => 0,
44234 => 2715,
],
$ergebnis['Sollwerte']
);
self::assertSame(0, $ergebnis['Restabweichung_W']);
}
public function testBegrenztesFahrzeugLaedtVorBatterieOhneBatterieentladung(): void
{
$ergebnis = ManagerRegler::verteile(
-3587.0,
0.0,
[
$this->verbraucher(
10,
0,
[0, 4150, 4850, 5550, 6250, 6950, 7600, 8300, 9000, 9700, 10300, 11000],
10263
),
$this->verbraucher(
20,
1,
[['Von_W' => -39000, 'Bis_W' => 39000]],
0
),
],
'PrioritaetPV'
);
self::assertSame(13850, $ergebnis['Verteilbudget_W']);
self::assertSame([10 => 11000, 20 => 2850], $ergebnis['Sollwerte']);
self::assertGreaterThanOrEqual(0, $ergebnis['Sollwerte'][20]);
self::assertSame(0, $ergebnis['Restabweichung_W']);
}
public function testPvBetriebNutztBeiPositivemBudgetKeineNegativeLeistungsstufe(): void
{
$ergebnis = ManagerRegler::verteile(
0.0,
500.0,
[
$this->verbraucher(10, 0, [-1000, 5000], 0),
$this->verbraucher(20, 1, [0, 3000], 0),
],
'PrioritaetPV'
);
self::assertSame(500, $ergebnis['Verteilbudget_W']);
self::assertSame([10 => 5000, 20 => 0], $ergebnis['Sollwerte']);
self::assertGreaterThanOrEqual(0, $ergebnis['Sollwerte'][10]);
self::assertSame(-4500, $ergebnis['Restabweichung_W']);
}
public function testPvBetriebNutztNegativeLeistungErstBeiTatsaechlichemDefizit(): void
{
$ergebnis = ManagerRegler::verteile(
1000.0,
0.0,
[
$this->verbraucher(
10,
0,
[['Von_W' => -5000, 'Bis_W' => 5000]],
0
),
$this->verbraucher(20, 1, [0, 3000], 0),
],
'PrioritaetPV'
);
self::assertSame(-1000, $ergebnis['Verteilbudget_W']);
self::assertSame([10 => -1000, 20 => 0], $ergebnis['Sollwerte']);
self::assertSame(0, $ergebnis['Restabweichung_W']);
}
public function testFestgehaltenerVerbraucherBilanziertIstUndBehaeltSollwert(): void
{
$festgehalten = $this->verbraucher(10, 0, [0, 3000, 5000], 2000, false);
$festgehalten['Daten']['Zustand'][0]['Wert'] = 3000;
$ergebnis = ManagerRegler::verteile(
0.0,
3000.0,
[
$festgehalten,
$this->verbraucher(20, 1, [['Von_W' => 0, 'Bis_W' => 5000]], 0),
],
'PrioritaetPV'
);
self::assertSame(5000, $ergebnis['Verteilbudget_W']);
self::assertSame(2000, $ergebnis['NichtSteuerbar_W']);
self::assertSame([10 => 3000, 20 => 3000], $ergebnis['Sollwerte']);
self::assertSame(0, $ergebnis['Restabweichung_W']); self::assertSame(0, $ergebnis['Restabweichung_W']);
} }
public function testFehlendeIstleistungFaelltAufAngenommenenSollwertZurueck(): void public function testFehlendeIstleistungFaelltAufAngenommenenSollwertZurueck(): void
{ {
$verbraucher = $this->verbraucher(10, 0, [0, 3000], null, false); $verbraucher = $this->verbraucher(10, 0, [3000], null, false);
$verbraucher['Daten']['Leistungsquelle'] = Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN; $verbraucher['Daten']['Leistungsquelle'] = Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN;
$verbraucher['Daten']['Istleistung_W'] = null; $verbraucher['Daten']['Istleistung_W'] = null;
$verbraucher['Daten']['Zustand'][0]['Wert'] = 3000; $verbraucher['Daten']['Zustand'][0]['Wert'] = 3000;
@@ -135,9 +382,31 @@ final class ManagerReglerTest extends TestCase
$ergebnis = ManagerRegler::verteile(3000.0, 0.0, [$verbraucher], 'PrioritaetPV'); $ergebnis = ManagerRegler::verteile(3000.0, 0.0, [$verbraucher], 'PrioritaetPV');
self::assertSame(3000, $ergebnis['AktuelleVerbraucherleistung_W']); self::assertSame(3000, $ergebnis['AktuelleVerbraucherleistung_W']);
self::assertSame([10 => 3000], $ergebnis['Sollwerte']);
self::assertSame(-3000, $ergebnis['Restabweichung_W']); self::assertSame(-3000, $ergebnis['Restabweichung_W']);
} }
public function testBatterieMitTieferPrioritaetUebernimmtNichtNutzbarenRest(): void
{
$ergebnis = ManagerRegler::verteile(
-5000.0,
0.0,
[
$this->verbraucher(20, 1, [0, 3000, 6000], 0),
$this->verbraucher(
10,
6,
[['Von_W' => 0, 'Bis_W' => 10000]],
0
),
],
'PrioritaetPV'
);
self::assertSame([20 => 3000, 10 => 2000], $ergebnis['Sollwerte']);
self::assertSame(0, $ergebnis['Restabweichung_W']);
}
public function testMonatlicheGrenzeMussVollstaendigSein(): void public function testMonatlicheGrenzeMussVollstaendigSein(): void
{ {
$this->expectException(InvalidArgumentException::class); $this->expectException(InvalidArgumentException::class);
+47
View File
@@ -24,6 +24,38 @@ return static function (TestContext $test): void {
); );
}); });
$test->runCase('SignalR-Abbruch synchronisiert den Gatewaystatus', static function (
TestContext $test
) use ($gateway): void {
IPS_RequestAction(
$gateway,
'TestSignalRFrame',
json_encode(['type' => 7], JSON_THROW_ON_ERROR)
);
$test->assertInstanceStatus($gateway, 203);
$test->assertSame(
false,
GetValue($test->objectByIdent('Connected', $gateway))
);
$test->assertSame(
'Easee-Ereignisverbindung wurde beendet.',
GetValue($test->objectByIdent('LastError', $gateway))
);
IPS_RequestAction($gateway, 'TestSignalRFrame', '{}');
$test->assertInstanceStatus($gateway);
$test->assertSame(
true,
GetValue($test->objectByIdent('Connected', $gateway))
);
$test->assertSame(
'',
GetValue($test->objectByIdent('LastError', $gateway))
);
});
$test->runCase('Abonnement liefert injizierten API-Zustand', static function ( $test->runCase('Abonnement liefert injizierten API-Zustand', static function (
TestContext $test TestContext $test
) use ($gateway): void { ) use ($gateway): void {
@@ -72,6 +104,21 @@ return static function (TestContext $test): void {
$test->assertSame(200, $antwort['httpCode']); $test->assertSame(200, $antwort['httpCode']);
}); });
$test->runCase('Ladestart wird ueber Gateway angenommen', static function (
TestContext $test
) use ($gateway): void {
$antwort = json_decode(ENELIXEASEE_ProcessStationRequest(
$gateway,
json_encode([
'action' => 'StartCharging',
'serialNumber' => 'EH123456',
], 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 ( $test->runCase('Gebrochene Stromvorgabe wird abgelehnt', static function (
TestContext $test TestContext $test
) use ($gateway): void { ) use ($gateway): void {
+75 -1
View File
@@ -95,7 +95,7 @@ return static function (TestContext $test): void {
) use ($station): void { ) use ($station): void {
IPS_RequestAction($station, 'Aktiv', true); IPS_RequestAction($station, 'Aktiv', true);
$test->assertSame( $test->assertSame(
'[0,4150,4850,5550,6250,6950,7600,8300,9000,9700,10300,11000]', '[0,4104,4788,5472,6156,6840,7524,8208,8892,9576,10260,10944]',
(string) GetValue( (string) GetValue(
$test->objectByIdent('LeistungsangebotDiagnose', $station) $test->objectByIdent('LeistungsangebotDiagnose', $station)
) )
@@ -108,6 +108,54 @@ return static function (TestContext $test): void {
)); ));
}); });
$test->runCase('Wartezustand startet konservativ ohne aktive Phase', static function (
TestContext $test
) use ($sendeBeobachtung, $station): void {
$sendeBeobachtung('EH123456', 42, true);
$sendeBeobachtung('EH123456', 43, true);
$sendeBeobachtung('EH123456', 96, 55);
$sendeBeobachtung('EH123456', 102, true);
$sendeBeobachtung('EH123456', 109, 2);
$sendeBeobachtung('EH123456', 110, 0);
IPS_RequestAction($station, 'Solarladen', false);
$test->assertSame(
0,
GetValue($test->objectByIdent('Phasenzahl', $station))
);
$test->assertInstanceStatus($station);
$test->assertSame(
'[3680]',
(string) GetValue(
$test->objectByIdent('LeistungsangebotDiagnose', $station)
)
);
$test->assertTrue(str_contains(
(string) GetValue(
$test->objectByIdent('LetzterGeraetebefehl', $station)
),
'"Ampere":16'
));
$test->assertTrue(str_contains(
(string) GetValue(
$test->objectByIdent('LetzterStartbefehl', $station)
),
'"Aktion":"StartCharging"'
));
$test->assertSame(
55,
GetValue($test->objectByIdent('GrundKeinLadestrom', $station))
);
$test->assertSame(
true,
GetValue($test->objectByIdent('AutorisierungErforderlich', $station))
);
IPS_RequestAction($station, 'Solarladen', true);
$sendeBeobachtung('EH123456', 110, 30);
$sendeBeobachtung('EH123456', 109, 3);
});
$test->runCase('Nur-Solar-Variante laesst Solarladen nicht abschalten', static function ( $test->runCase('Nur-Solar-Variante laesst Solarladen nicht abschalten', static function (
TestContext $test TestContext $test
) use ($nurSolar): void { ) use ($nurSolar): void {
@@ -136,6 +184,32 @@ return static function (TestContext $test): void {
$test->assertInstanceStatus($station); $test->assertInstanceStatus($station);
}); });
$test->runCase('SignalR-Abbruch erreicht die Ladestation', static function (
TestContext $test
) use ($gateway, $station): void {
IPS_RequestAction(
$gateway,
'TestSignalRFrame',
json_encode(['type' => 7], JSON_THROW_ON_ERROR)
);
$test->assertInstanceStatus($gateway, 203);
$test->assertInstanceStatus($station, 202);
$test->assertSame(
false,
GetValue($test->objectByIdent('GatewayVerbunden', $station))
);
IPS_RequestAction($gateway, 'TestSignalRFrame', '{}');
$test->assertInstanceStatus($gateway);
$test->assertInstanceStatus($station);
$test->assertSame(
true,
GetValue($test->objectByIdent('GatewayVerbunden', $station))
);
});
$test->runCase('Gateway-Ausfall verwirft Fahrzeug- und Phasenstatus', static function ( $test->runCase('Gateway-Ausfall verwirft Fahrzeug- und Phasenstatus', static function (
TestContext $test TestContext $test
) use ($gateway, $station): void { ) use ($gateway, $station): void {
+308 -10
View File
@@ -6,8 +6,11 @@ use Belevo\EnelixEMS\SymconTest\TestContext;
return static function (TestContext $test): void { return static function (TestContext $test): void {
$testPasswort = hash('sha256', __FILE__); $testPasswort = hash('sha256', __FILE__);
$goEAntwort = static function (int $status, float $leistung): string { $goEAntwort = static function (int $status, float $leistung, array $stroeme = []): string {
$nrg = array_fill(0, 12, 0); $nrg = array_fill(0, 12, 0);
foreach (array_values($stroeme) as $index => $strom) {
$nrg[4 + $index] = $strom;
}
$nrg[11] = $leistung; $nrg[11] = $leistung;
return json_encode(['car' => $status, 'nrg' => $nrg], JSON_THROW_ON_ERROR); return json_encode(['car' => $status, 'nrg' => $nrg], JSON_THROW_ON_ERROR);
}; };
@@ -16,14 +19,14 @@ return static function (TestContext $test): void {
'go-e alt' => [ 'go-e alt' => [
'Geraetetyp' => 1, 'Geraetetyp' => 1,
'Geraeteadresse' => '192.0.2.10', 'Geraeteadresse' => '192.0.2.10',
'Testantwort' => $goEAntwort(2, 1100), 'Testantwort' => $goEAntwort(2, 1100, [160, 158, 159]),
'Phasenzahl' => 3, 'Phasenzahl' => 3,
'BefehlUrlTeil' => 'amp=16', 'BefehlUrlTeil' => 'amp=16',
], ],
'go-e Gemini' => [ 'go-e Gemini' => [
'Geraetetyp' => 2, 'Geraetetyp' => 2,
'Geraeteadresse' => '192.0.2.11', 'Geraeteadresse' => '192.0.2.11',
'Testantwort' => $goEAntwort(2, 2300), 'Testantwort' => $goEAntwort(2, 2300, [10, 0, 0]),
'Phasenzahl' => 1, 'Phasenzahl' => 1,
'BefehlUrlTeil' => 'amp=16', 'BefehlUrlTeil' => 'amp=16',
], ],
@@ -47,6 +50,7 @@ return static function (TestContext $test): void {
foreach ($definitionen as $name => $definition) { foreach ($definitionen as $name => $definition) {
$properties = array_merge($definition, [ $properties = array_merge($definition, [
'MaximalerLadestrom' => 16, 'MaximalerLadestrom' => 16,
'ZeitZwischenZustandswechseln' => 0,
'Ladefreigabe' => true, 'Ladefreigabe' => true,
'Solarladen' => true, 'Solarladen' => true,
'EinstellungenInVisu' => true, 'EinstellungenInVisu' => true,
@@ -61,6 +65,113 @@ return static function (TestContext $test): void {
); );
} }
$solarPause = $test->createInstance(
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
'Ladestation Solarpause',
[
'Geraetetyp' => 1,
'Geraeteadresse' => '192.0.2.12',
'MaximalerLadestrom' => 16,
'ZeitZwischenZustandswechseln' => 0,
'Ladefreigabe' => true,
'Solarladen' => true,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
'Testmodus' => true,
'Testantwort' => $goEAntwort(2, 0),
]
);
$stromerkennung = $test->createInstance(
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
'Ladestation Fahrzeugstromerkennung',
[
'Geraetetyp' => 2,
'Geraeteadresse' => '192.0.2.13',
'MaximalerLadestrom' => 20,
'FahrzeugstromErkennungszeit' => 1,
'ZeitZwischenZustandswechseln' => 0,
'Ladefreigabe' => true,
'Solarladen' => true,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
'Testmodus' => true,
'Testantwort' => $goEAntwort(2, 10260, [15.9, 15.8, 15.7]),
]
);
$phasenerkennung = $test->createInstance(
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
'Ladestation Phasenerkennung',
[
'Geraetetyp' => 2,
'Geraeteadresse' => '192.0.2.14',
'MaximalerLadestrom' => 16,
'Phasenerkennungszeit' => 1,
'ZeitZwischenZustandswechseln' => 0,
'Ladefreigabe' => true,
'Solarladen' => true,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
'Testmodus' => true,
'Testantwort' => $goEAntwort(2, 0),
]
);
$phasenhaltung = $test->createInstance(
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
'Ladestation Phasenhaltung',
[
'Geraetetyp' => 2,
'Geraeteadresse' => '192.0.2.14',
'MaximalerLadestrom' => 16,
'ZeitZwischenZustandswechseln' => 0,
'Ladefreigabe' => true,
'Solarladen' => true,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
'Testmodus' => true,
'Testantwort' => $goEAntwort(2, 10944, [16, 16, 16]),
]
);
$schaltsperre = $test->createInstance(
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
'Ladestation Schaltsperre',
[
'Geraetetyp' => 2,
'Geraeteadresse' => '192.0.2.15',
'MaximalerLadestrom' => 16,
'ZeitZwischenZustandswechseln' => 1,
'Mindesteinschaltdauer' => 2,
'Ladefreigabe' => true,
'Solarladen' => true,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
'Testmodus' => true,
'Testantwort' => $goEAntwort(2, 10944, [16, 16, 16]),
]
);
$mindestzeiten = $test->createInstance(
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
'Ladestation Mindestzeiten',
[
'Geraetetyp' => 2,
'Geraeteadresse' => '192.0.2.17',
'MaximalerLadestrom' => 16,
'ZeitZwischenZustandswechseln' => 0,
'Mindesteinschaltdauer' => 1,
'Mindestausschaltdauer' => 1,
'Ladefreigabe' => true,
'Solarladen' => true,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
'Testmodus' => true,
'Testantwort' => $goEAntwort(2, 10944, [16, 16, 16]),
]
);
$managerId = $test->createInstance( $managerId = $test->createInstance(
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}', '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
'Hilfsmanager Ladestation', 'Hilfsmanager Ladestation',
@@ -68,7 +179,16 @@ return static function (TestContext $test): void {
'AutomatischeSuche' => false, 'AutomatischeSuche' => false,
'VerbraucherZuordnung' => json_encode(array_map( 'VerbraucherZuordnung' => json_encode(array_map(
static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true], static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true],
array_values($stationen) array_merge(
array_values($stationen),
[
$stromerkennung,
$phasenerkennung,
$phasenhaltung,
$schaltsperre,
$mindestzeiten,
]
)
), JSON_THROW_ON_ERROR), ), JSON_THROW_ON_ERROR),
] ]
); );
@@ -120,12 +240,115 @@ return static function (TestContext $test): void {
} }
}); });
$test->runCase('Erkannte Phasen bleiben waehrend einer Ladepause stabil', static function (
TestContext $test
) use ($goEAntwort, $phasenhaltung): void {
IPS_RequestAction($phasenhaltung, 'Aktiv', true);
$test->assertSame(
3,
GetValue($test->objectByIdent('Phasenzahl', $phasenhaltung))
);
IPS_SetProperty($phasenhaltung, 'Testantwort', $goEAntwort(2, 0));
IPS_ApplyChanges($phasenhaltung);
IPS_RequestAction($phasenhaltung, 'Statuszyklus', false);
$test->assertSame(
3,
GetValue($test->objectByIdent('Phasenzahl', $phasenhaltung))
);
$test->assertSame(
false,
GetValue($test->objectByIdent('FahrzeugGeladen', $phasenhaltung))
);
});
$test->runCase('Schaltsperre haelt die aktuelle Leistung stabil', static function (
TestContext $test
) use ($schaltsperre, $anforderungSenden): void {
IPS_RequestAction($schaltsperre, 'Aktiv', true);
IPS_RequestAction($schaltsperre, 'Solarladen', true);
$anforderungSenden($schaltsperre, 4104);
IPS_RequestAction($schaltsperre, 'Statuszyklus', false);
$diagnose = json_encode([
'Angebot' => GetValue($test->objectByIdent('LeistungsangebotDiagnose', $schaltsperre)),
'Verbunden' => GetValue($test->objectByIdent('FahrzeugVerbunden', $schaltsperre)),
'Geladen' => GetValue($test->objectByIdent('FahrzeugGeladen', $schaltsperre)),
'Phasen' => GetValue($test->objectByIdent('Phasenzahl', $schaltsperre)),
'Sollleistung' => GetValue($test->objectByIdent('Sollleistung', $schaltsperre)),
'SollwertGueltig' => GetValue($test->objectByIdent('SollwertGueltig', $schaltsperre)),
'Befehl' => GetValue($test->objectByIdent('LetzterGeraetebefehl', $schaltsperre)),
], JSON_THROW_ON_ERROR);
$test->assertSame(
'[4104]',
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $schaltsperre)),
'Diagnose: ' . $diagnose
);
$abgewiesen = false;
try {
$anforderungSenden($schaltsperre, 4788);
} catch (Throwable $fehler) {
$abgewiesen = true;
}
if (!$abgewiesen) {
$abgewiesen = GetValue(
$test->objectByIdent('SollwertGueltig', $schaltsperre)
) !== true || GetValue(
$test->objectByIdent('Sollleistung', $schaltsperre)
) !== 4788;
}
$test->assertSame(true, $abgewiesen);
});
$test->runCase('Mindestzeiten erlauben Modulation aber verhindern Takten', static function (
TestContext $test
) use ($mindestzeiten, $anforderungSenden): void {
IPS_RequestAction($mindestzeiten, 'Aktiv', true);
$anforderungSenden($mindestzeiten, 4104);
IPS_RequestAction($mindestzeiten, 'Statuszyklus', false);
$angebot = json_decode(
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $mindestzeiten)),
true,
512,
JSON_THROW_ON_ERROR
);
$test->assertTrue(!in_array(0, $angebot, true));
$test->assertTrue(in_array(4788, $angebot, true));
$anforderungSenden($mindestzeiten, 4788);
IPS_RequestAction($mindestzeiten, 'Ladefreigabe', false);
IPS_RequestAction($mindestzeiten, 'Ladefreigabe', true);
$test->assertSame(
'[0]',
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $mindestzeiten))
);
});
$test->runCase('Solarpause wird nicht als Ladeende erkannt', static function (
TestContext $test
) use ($solarPause): void {
IPS_RequestAction($solarPause, 'Solarladen', true);
$test->assertSame(
false,
GetValue($test->objectByIdent('FahrzeugGeladen', $solarPause))
);
$test->assertSame(
2,
GetValue($test->objectByIdent('Fahrzeugstatus', $solarPause))
);
});
$test->runCase('Solar- und Peakbetrieb liefern die Enelix-1-Angebote', static function ( $test->runCase('Solar- und Peakbetrieb liefern die Enelix-1-Angebote', static function (
TestContext $test TestContext $test
) use ($stationen, $anforderungSenden): void { ) use ($stationen, $anforderungSenden): void {
$stationId = $stationen['go-e alt']; $stationId = $stationen['go-e alt'];
IPS_RequestAction($stationId, 'Solarladen', true); IPS_RequestAction($stationId, 'Solarladen', true);
$anforderungSenden($stationId, 4150); $anforderungSenden($stationId, 4104);
$befehl = (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)); $befehl = (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId));
$diagnose = json_encode([ $diagnose = json_encode([
'Sollleistung' => GetValue($test->objectByIdent('Sollleistung', $stationId)), 'Sollleistung' => GetValue($test->objectByIdent('Sollleistung', $stationId)),
@@ -140,22 +363,22 @@ return static function (TestContext $test): void {
IPS_RequestAction($stationId, 'Solarladen', false); IPS_RequestAction($stationId, 'Solarladen', false);
$angebot = (string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId)); $angebot = (string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId));
$test->assertSame('[11000]', $angebot, 'PV ohne Solarladen muss nur das Maximum anbieten.'); $test->assertSame('[10944]', $angebot, 'PV ohne Solarladen muss nur das Maximum anbieten.');
$abgewiesen = false; $abgewiesen = false;
try { try {
$anforderungSenden($stationId, 4150); $anforderungSenden($stationId, 4104);
} catch (Throwable $fehler) { } catch (Throwable $fehler) {
$abgewiesen = true; $abgewiesen = true;
} }
if (!$abgewiesen) { if (!$abgewiesen) {
$abgewiesen = GetValue($test->objectByIdent('SollwertGueltig', $stationId)) !== true $abgewiesen = GetValue($test->objectByIdent('SollwertGueltig', $stationId)) !== true
|| GetValue($test->objectByIdent('Sollleistung', $stationId)) !== 4150; || GetValue($test->objectByIdent('Sollleistung', $stationId)) !== 4104;
} }
$test->assertSame(true, $abgewiesen, 'Normalbetrieb muss eine Zwischenstufe ablehnen.'); $test->assertSame(true, $abgewiesen, 'Normalbetrieb muss eine Zwischenstufe ablehnen.');
$anforderungSenden($stationId, 4150, 'Peak'); $anforderungSenden($stationId, 4104, 'Peak');
$test->assertSame( $test->assertSame(
'[0,4150,4850,5550,6250,6950,7600,8300,9000,9700,10300,11000]', '[0,4104,4788,5472,6156,6840,7524,8208,8892,9576,10260,10944]',
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId)) (string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId))
); );
$test->assertTrue(str_contains( $test->assertTrue(str_contains(
@@ -174,4 +397,79 @@ return static function (TestContext $test): void {
'alw=0' 'alw=0'
), 'Peak mit Solarladen muss die Ladestation stoppen.'); ), 'Peak mit Solarladen muss die Ladestation stoppen.');
}); });
$test->runCase('Fahrzeugstrom wird nach stabiler Unterschreitung begrenzt', static function (
TestContext $test
) use ($stromerkennung, $anforderungSenden): void {
IPS_RequestAction($stromerkennung, 'Aktiv', true);
IPS_RequestAction($stromerkennung, 'Solarladen', true);
$anforderungSenden($stromerkennung, 13680);
IPS_RequestAction($stromerkennung, 'Statuszyklus', false);
IPS_Sleep(2100);
IPS_RequestAction($stromerkennung, 'Statuszyklus', false);
$test->assertSame(
18,
GetValue($test->objectByIdent('FahrzeugMaximalstrom', $stromerkennung))
);
$test->assertSame(
'[0,4104,4788,5472,6156,6840,7524,8208,8892,9576,10260,10944,11628,12312]',
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stromerkennung))
);
$test->assertTrue(str_contains(
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stromerkennung)),
'amp=18'
));
});
$test->runCase('Neue Ladestation wird erst nach Freigabe mit Maximalstrom erkannt', static function (
TestContext $test
) use ($phasenerkennung, $anforderungSenden): void {
$stationId = $phasenerkennung;
$test->assertSame(
0,
GetValue($test->objectByIdent('Phasenzahl', $stationId))
);
$test->assertTrue(!str_contains(
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)),
'amp=16'
), 'Eine inaktive Station darf keine Maximalstrom-Probe starten.');
IPS_RequestAction($stationId, 'Aktiv', true);
$test->assertSame(
0,
GetValue($test->objectByIdent('Phasenzahl', $stationId))
);
$test->assertTrue(str_contains(
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)),
'amp=16'
), 'Nach der Freigabe muss die Maximalstrom-Probe starten.');
IPS_Sleep(2100);
IPS_RequestAction($stationId, 'Statuszyklus', false);
$anforderungSenden($stationId, 3680);
IPS_RequestAction($stationId, 'Statuszyklus', false);
$test->assertSame(
1,
GetValue($test->objectByIdent('Phasenzahl', $stationId))
);
$test->assertSame(
false,
GetValue($test->objectByIdent('FahrzeugGeladen', $stationId))
);
$test->assertSame(
true,
GetValue($test->objectByIdent('SollwertGueltig', $stationId))
);
$test->assertSame(
3680,
GetValue($test->objectByIdent('Sollleistung', $stationId))
);
$test->assertSame(
'[0,1380,1610,1840,2070,2300,2530,2760,2990,3220,3450,3680]',
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId))
);
});
}; };
+69 -2
View File
@@ -268,6 +268,33 @@ return static function (TestContext $test): void {
'{BA90FDB3-C203-8F26-B4D6-F3DB99905724}', '{BA90FDB3-C203-8F26-B4D6-F3DB99905724}',
IPS_GetInstance($energieflussID)['ModuleInfo']['ModuleID'] IPS_GetInstance($energieflussID)['ModuleInfo']['ModuleID']
); );
$knoten = json_decode(
(string) IPS_GetProperty($energieflussID, 'Config'),
true,
512,
JSON_THROW_ON_ERROR
);
$namen = array_column($knoten, 'Name');
foreach (['Verbraucher 1-Stufig', 'Wassererwärmer', 'Pufferspeicher'] as $name) {
$test->assertTrue(in_array($name, $namen, true));
}
foreach ($knoten as $knotenEintrag) {
if (in_array(
$knotenEintrag['Name'],
['Verbraucher 1-Stufig', 'Wassererwärmer', 'Pufferspeicher'],
true
)) {
$test->assertTrue((int) $knotenEintrag['InfoVariableID'] > 1);
}
}
$pvFlussID = $test->objectByIdent('EnergieflussPV', $managerId);
$test->assertSame(4200.0, (float) GetValue($pvFlussID));
$test->assertSame('~Power', IPS_GetVariable($pvFlussID)['VariableProfile']);
IPS_SetProperty($managerId, 'EnergieflussLeistungseinheit', 1);
IPS_ApplyChanges($managerId);
$test->assertSame(4.2, (float) GetValue($pvFlussID));
$test->assertSame('Enelix.Power.kW', IPS_GetVariable($pvFlussID)['VariableProfile']);
IPS_RequestAction($managerId, 'FormEnergieflussAnzeigen', false); IPS_RequestAction($managerId, 'FormEnergieflussAnzeigen', false);
$test->assertSame(true, (bool) IPS_GetObject($energieflussID)['ObjectIsHidden']); $test->assertSame(true, (bool) IPS_GetObject($energieflussID)['ObjectIsHidden']);
@@ -416,12 +443,10 @@ return static function (TestContext $test): void {
SetValue($temperaturId, 53.0); SetValue($temperaturId, 53.0);
$test->waitUntil(static function () use ( $test->waitUntil(static function () use (
$managerId,
$warmwasserId, $warmwasserId,
$verbraucherstatus, $verbraucherstatus,
$zustandswert $zustandswert
): bool { ): bool {
IPS_RequestAction($managerId, 'Regeln', false);
$wert = $zustandswert( $wert = $zustandswert(
$verbraucherstatus(), $verbraucherstatus(),
$warmwasserId, $warmwasserId,
@@ -444,4 +469,46 @@ return static function (TestContext $test): void {
$test->objectByIdent('Sammelstoerung', $managerId) $test->objectByIdent('Sammelstoerung', $managerId)
)); ));
}); });
$test->runCase('Veralteter Verbraucher blockiert aktuelle Verbraucher nicht', static function (
TestContext $test
) use (
$managerId,
$netzleistungId,
$einStufigId,
$warmwasserId,
$pufferspeicherId,
$melden,
$verbraucherstatus
): void {
IPS_SetProperty($managerId, 'VerbraucherTimeout', 1);
IPS_ApplyChanges($managerId);
SetValue($netzleistungId, -2400.0);
$melden();
IPS_RequestAction($managerId, 'Regeln', true);
$test->waitUntil(
static fn (): bool => ($verbraucherstatus()['Synchronisiert'] ?? false) === true
);
IPS_Sleep(1100);
IPS_RequestAction($warmwasserId, 'Melden', true);
IPS_RequestAction($pufferspeicherId, 'Melden', true);
IPS_RequestAction($managerId, 'Regeln', true);
$daten = $verbraucherstatus();
$test->assertSame(false, $daten['Synchronisiert'] ?? null);
$test->assertSame(2, count($daten['Verbraucher'] ?? []));
$test->assertSame(1, count($daten['NichtGeregelt'] ?? []));
$test->assertSame(
$einStufigId,
$daten['NichtGeregelt'][0]['InstanzID'] ?? null
);
$test->assertSame(
'Meldung ist veraltet',
$daten['NichtGeregelt'][0]['Grund'] ?? null
);
$test->assertTrue(
(float) GetValue($test->objectByIdent('Verteilbudget', $managerId)) > 0.0
);
});
}; };
+6 -3
View File
@@ -22,7 +22,7 @@ return static function (TestContext $test): void {
'Wiederholsperre' => 3, 'Wiederholsperre' => 3,
'Mindestlaufzeit' => 5, 'Mindestlaufzeit' => 5,
'Mindestsperrzeit' => 0, 'Mindestsperrzeit' => 0,
'MaximaleSperrzeit' => 2, 'MaximaleSperrzeit' => 3,
'MaximaleSperrzeit24h' => 60, 'MaximaleSperrzeit24h' => 60,
'VorgabeTimeout' => 30, 'VorgabeTimeout' => 30,
'DiagnosevariablenAnzeigen' => true, 'DiagnosevariablenAnzeigen' => true,
@@ -136,13 +136,16 @@ return static function (TestContext $test): void {
) use ($waermepumpe, $kontakt1, $kontakt2, $senden): void { ) use ($waermepumpe, $kontakt1, $kontakt2, $senden): void {
$senden(null, 'Peak'); $senden(null, 'Peak');
$senden(0, 'Peak'); $senden(0, 'Peak');
$test->waitUntil(static fn (): bool => GetValue($kontakt1) === true); $test->waitUntil(static fn (): bool => GetValue($kontakt1) === true, 2500);
$test->assertSame(false, GetValue($kontakt2)); $test->assertSame(false, GetValue($kontakt2));
$test->assertSame(true, GetValue( $test->assertSame(true, GetValue(
$test->objectByIdent('SperreAktiv', $waermepumpe) $test->objectByIdent('SperreAktiv', $waermepumpe)
)); ));
$test->waitUntil(static fn (): bool => GetValue($kontakt1) === false, 2500); $test->waitUntil(static fn (): bool => GetValue($kontakt1) === false, 3500);
$test->waitUntil(static fn (): bool => GetValue(
$test->objectByIdent('SperrerholungAktiv', $waermepumpe)
) === true);
$test->assertSame(true, GetValue( $test->assertSame(true, GetValue(
$test->objectByIdent('SperrerholungAktiv', $waermepumpe) $test->objectByIdent('SperrerholungAktiv', $waermepumpe)
)); ));
@@ -31,6 +31,8 @@ return static function (TestContext $test): void {
'LastwechselSperrzeit' => 1, 'LastwechselSperrzeit' => 1,
'Hysterese' => 5.0, 'Hysterese' => 5.0,
'TemperaturMaxAlter' => 120, 'TemperaturMaxAlter' => 120,
'TemperaturUntergrenze' => 0.0,
'TemperaturObergrenze' => 100.0,
'LegionellenfunktionAktiv' => true, 'LegionellenfunktionAktiv' => true,
'LegionellenMinimalintervallTage' => 4, 'LegionellenMinimalintervallTage' => 4,
'LegionellenMaximalintervallTage' => 7, 'LegionellenMaximalintervallTage' => 7,
@@ -90,6 +92,24 @@ return static function (TestContext $test): void {
IPS_RequestAction($managerId, 'Regeln', true); IPS_RequestAction($managerId, 'Regeln', true);
}; };
$test->runCase('Temperaturgrenzen melden Unter- und Uebertemperatur', static function (
TestContext $test
) use ($verbraucherId, $temperaturSetzen): void {
$stoertextId = $test->objectByIdent('Stoertext', $verbraucherId);
$temperaturSetzen(100.0);
$test->assertTrue(!str_contains((string) GetValue($stoertextId), 'Uebertemperatur'));
$temperaturSetzen(100.1);
$test->assertTrue(str_contains((string) GetValue($stoertextId), 'Uebertemperatur'));
$temperaturSetzen(0.0);
$test->assertTrue(!str_contains((string) GetValue($stoertextId), 'Untertemperatur'));
$temperaturSetzen(-0.1);
$test->assertTrue(str_contains((string) GetValue($stoertextId), 'Untertemperatur'));
$temperaturSetzen(52.0);
});
if ($lizenzcode === '') { if ($lizenzcode === '') {
$test->runCase('Manager bleibt ohne Lizenz sicher gesperrt', static function ( $test->runCase('Manager bleibt ohne Lizenz sicher gesperrt', static function (
TestContext $test TestContext $test
+34 -2
View File
@@ -29,14 +29,14 @@ final class WarmwassererwaermerModulstrukturTest extends TestCase
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $metadaten['id']); self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $metadaten['id']);
} }
public function testAlle19PropertiesSindRegistriertUndImFormularDokumentiert(): void public function testAlle21PropertiesSindRegistriertUndImFormularDokumentiert(): void
{ {
$basis = (string) file_get_contents(__DIR__ . '/../libs/VerbraucherBasisTrait.php'); $basis = (string) file_get_contents(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php'); $modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
preg_match_all('/RegisterProperty(?:Integer|Float|Boolean|String)\(\'([^\']+)\'/', $basis . $modul, $treffer); preg_match_all('/RegisterProperty(?:Integer|Float|Boolean|String)\(\'([^\']+)\'/', $basis . $modul, $treffer);
$properties = array_values(array_unique($treffer[1])); $properties = array_values(array_unique($treffer[1]));
self::assertCount(19, $properties); self::assertCount(21, $properties);
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json'); $formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
foreach ($properties as $property) { foreach ($properties as $property) {
@@ -136,6 +136,38 @@ final class WarmwassererwaermerModulstrukturTest extends TestCase
self::assertStringContainsString('Mindesttemperatur < Maximaltemperatur', $inhalt); self::assertStringContainsString('Mindesttemperatur < Maximaltemperatur', $inhalt);
} }
public function testTemperaturstoergrenzenSindKonfigurierbarUndVonSollwertenGetrennt(): void
{
$inhalt = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString(
"RegisterPropertyFloat('TemperaturUntergrenze', 0.0)",
$inhalt
);
self::assertStringContainsString(
"RegisterPropertyFloat('TemperaturObergrenze', 100.0)",
$inhalt
);
self::assertStringContainsString("ReadPropertyFloat('TemperaturUntergrenze')", $inhalt);
self::assertStringContainsString("ReadPropertyFloat('TemperaturObergrenze')", $inhalt);
self::assertStringContainsString("'Kennung' => 'Untertemperatur'", $inhalt);
self::assertStringContainsString("'Kennung' => 'Uebertemperatur'", $inhalt);
$formular = json_decode(
(string) file_get_contents(self::MODULPFAD . 'form.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$erweitert = array_values(array_filter(
$formular['elements'],
static fn (array $element): bool =>
$element['caption'] === 'Erweiterte Speichereinstellungen'
));
self::assertCount(1, $erweitert);
self::assertContains('TemperaturUntergrenze', array_column($erweitert[0]['items'], 'name'));
self::assertContains('TemperaturObergrenze', array_column($erweitert[0]['items'], 'name'));
}
public function testSchaltfolgeIstBreakBeforeMake(): void public function testSchaltfolgeIstBreakBeforeMake(): void
{ {
$inhalt = (string) file_get_contents(self::MODULPFAD . 'module.php'); $inhalt = (string) file_get_contents(self::MODULPFAD . 'module.php');