From d7308663da36a16e03a2b03a80e272c647ad7646 Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Tue, 22 Sep 2026 09:27:49 +0000 Subject: [PATCH] feat: Batteriemodul ereignisbasiert integrieren --- Batterie/README.md | 172 ++++ Batterie/form.json | 229 +++++ Batterie/module.json | 14 + Batterie/module.php | 1175 ++++++++++++++++++++++++++ Manager/module.php | 1 + README.md | 5 +- docs/Schnittstelle-Batterie.md | 110 +++ docs/Schnittstelle.md | 8 + docs/migration/Batterie.md | 67 ++ docs/module/Batterie/README.md | 156 ++-- docs/module/README.md | 4 +- libs/BatterieRegler.php | 279 ++++++ tests/BatterieModulstrukturTest.php | 106 +++ tests/BatterieReglerTest.php | 191 +++++ tests/DokumentationsstrukturTest.php | 12 +- tests/Symcon/manifest.php | 10 + tests/Symcon/modules/Batterie.php | 180 ++++ tests/SymconTestContractTest.php | 2 + 18 files changed, 2670 insertions(+), 51 deletions(-) create mode 100644 Batterie/README.md create mode 100644 Batterie/form.json create mode 100644 Batterie/module.json create mode 100644 Batterie/module.php create mode 100644 docs/Schnittstelle-Batterie.md create mode 100644 docs/migration/Batterie.md create mode 100644 libs/BatterieRegler.php create mode 100644 tests/BatterieModulstrukturTest.php create mode 100644 tests/BatterieReglerTest.php create mode 100644 tests/Symcon/modules/Batterie.php diff --git a/Batterie/README.md b/Batterie/README.md new file mode 100644 index 0000000..7bb28a7 --- /dev/null +++ b/Batterie/README.md @@ -0,0 +1,172 @@ +# Batterie + +IP-Symcon-Modul fuer Batteriespeicher im Enelix EMS. Das Modul uebernimmt die +Leistungsangebote und SoC-Logik der bisherigen Enelix-Batterie, arbeitet aber +ereignisbasiert und schreibt Stellwerte direkt in ausgewaehlte +IP-Symcon-Registervariablen. + +## Funktionen + +- positive Leistung bedeutet Laden, negative Leistung bedeutet Entladen +- 250-W-Grundraster mit 50-W-Feinwerten zwischen -200 W und +200 W +- unterschiedliche Leistungsangebote fuer PV- und Peakbetrieb +- Reserve- und Mindestladezustand mit konfigurierbarer Hysterese +- direkte Registeransteuerung fuer herstellerunabhaengige Batterien, GoodWe, + SolarEdge und Sigenergy +- Umschaltung zwischen Wechselrichter- und Enelix-Steuerung +- ereignisbasierte Reaktion auf alle ausgewaehlten Messwerte +- Managerkommunikation ueber den Enelix-Vertrag 4.0 +- Sollwert-Timeout, zeitbasierte Aenderungssperre und periodische Vollmeldung +- optionale Diagnosevariablen und Debug-Logging + +Es gibt keinen zyklischen Regel- oder Berechnungstimer. Timer werden nur fuer +Vollmeldungen, den Ablauf einer Manager-Vorgabe und das Ende der +Aenderungssperre verwendet. + +## Voraussetzungen + +- IP-Symcon ab Version 8.0 +- ein eingerichteter Enelix Manager +- numerische Variablen fuer Ladezustand, Netzleistung, Batterieleistung sowie + maximale Lade- und Entladeleistung +- numerische Registervariablen mit Standard- oder benutzerdefinierter Aktion + +## Vorzeichen und Einheiten + +| Wert | Positiv | Negativ | Einheit | +| --- | --- | --- | --- | +| Manager-Sollleistung | Laden | Entladen | W | +| Batterieleistung | Laden | Entladen | W | +| Netzleistung | Netzbezug | Einspeisung | W | +| Ladezustand | - | - | % | + +Sigenergy-Leistungsregister werden in kW beschrieben. Alle anderen +Leistungsregister werden in W beschrieben. + +## Batterietypen und Register + +| Batterietyp | Management | Modus | Leistungsregister | +| --- | --- | --- | --- | +| Herstellerunabhaengig | WR 0, Enelix 1 | Laden 0, Entladen 1 | getrennt Laden/Entladen in W | +| GoodWe | WR 1, Enelix 2 | Laden 11, Entladen 12 | gemeinsam, absoluter Wert in W | +| SolarEdge | WR 1, Enelix 4 | Laden 3, Entladen 4 | getrennt Laden/Entladen in W | +| Sigenergy | WR 0, Enelix 1 | Laden 3, Entladen 6 | getrennt Laden/Entladen in kW | + +Bei Wechselrichtersteuerung bietet die Batterie dem Manager nur [0] an. Das +Modul setzt die Leistungsregister auf 0 und schreibt den passenden +Automatikcode in das Managementregister. + +Die Konfigurationsmaske zeigt nach Wahl des Batterietyps nur die benoetigten +Registerauswahlen: + +- GoodWe: Management, Modus und gemeinsames Leistungsregister +- alle anderen Typen: Management, Modus, Ladeleistung und Entladeleistung + +## Messwerte + +| Property | Beschreibung | +| --- | --- | +| MaxLadeleistungVariableID | Aktuell zulaessige maximale Ladeleistung in W | +| MaxEntladeleistungVariableID | Aktuell zulaessige maximale Entladeleistung in W | +| LadezustandVariableID | Ladezustand in % | +| NetzleistungVariableID | Netzbezug beziehungsweise Einspeisung in W | +| IstleistungVariableID | Aktuelle Batterieleistung in W | +| MesswertMaxAlter | Maximales Alter jedes Messwertes in Sekunden | + +Alle Messwerte muessen als Integer- oder Floatvariable vorliegen. Fehlt ein +Messwert, ist er veraltet oder liegt der Ladezustand ausserhalb von 0 bis +100 %, meldet sich die Batterie als nicht verfuegbar und bietet nur [0] an. + +## Ladezustandslogik + +ReserveLadezustand entspricht der bisherigen Peakshaving-Reserve. +MindestLadezustand schuetzt vor weiterer Entladung. +LadezustandHysterese ersetzt die bisher fest im Code hinterlegte +2-Prozent-Hysterese. + +Die bisherigen betriebsartabhaengigen Angebote bleiben erhalten: + +- Im PV-Betrieb steht oberhalb der Reserve das gesamte Raster zur Verfuegung. +- Unterhalb der Reserve wird die maximale Ladeleistung angeboten. +- Bei vollem Speicher werden nur Entladung und 0 angeboten. +- Im Peakbetrieb wird oberhalb der Reserve der aus Netz- und aktueller + Batterieleistung berechnete Entladewert angeboten. +- Innerhalb der Reserve-Hysterese und unterhalb des Mindestladezustands werden + Laden und Entladen wie bisher eingeschraenkt. + +## Manager und Zeitverhalten + +Das Modul verwendet den gemeinsamen Enelix-Nachrichtenvertrag 4.0. +Betriebsart ist PV oder Peak. Ein Paket mit Sollleistung_W=null +kuendigt nur die Betriebsart an und loest die Neuberechnung des Angebots aus. + +Aenderungssperre ist die ereignisbasierte Entsprechung des frueheren +Idle-Counters. Nach einer echten Sollwertaenderung meldet das Modul +AenderungMoeglich=false. Ein einmaliger Timer hebt die Sperre nach Ablauf +der konfigurierten Sekunden wieder auf. + +VorgabeTimeout setzt die Register auf 0, wenn der Manager eine Vorgabe +nicht rechtzeitig erneuert. Meldeintervall erzeugt nur eine periodische +Vollmeldung und fuehrt keinen unabhaengigen Regelzyklus aus. + +## Sichtbare Variablen + +Immer vorhanden: + +- Aktiv: lokale EMS-Freigabe +- Ladestatus: 0 unbekannt, 1 ruhend, 2 laden, 3 entladen + +Bei aktivierten Diagnosevariablen werden zusaetzlich unter anderem +Istleistung, Sollleistung, Ladezustand, Messwertgueltigkeit, Hysterese, +Leistungsgrenzen, Energie, Leistungsangebot und der letzte Registerbefehl +angezeigt. + +LoggingEin aktiviert zusaetzliche Meldungen im IP-Symcon-Debugprotokoll. +Die Schaltflaeche **Messwerte und Angebot aktualisieren** loest eine sofortige +ereignisartige Aktualisierung aus. **Register auf sicheren Zustand setzen** +verwirft die aktuelle Vorgabe und schreibt eine Leistung von 0. + +## Inbetriebnahme + +1. Batterietyp und Steuerung auswaehlen. +2. Alle fuenf Messwertvariablen zuordnen. +3. Die automatisch eingeblendeten Registervariablen zuordnen. +4. Reserve, Mindestladezustand und Hysterese pruefen. +5. Diagnosevariablen und Logging fuer die Erstinbetriebnahme aktivieren. +6. Die Batterie im Manager zuordnen. +7. Aktiv einschalten. +8. Unter Aufsicht je einen Lade-, Entlade- und Nullsollwert senden. +9. Registercodes, Vorzeichen, Einheiten und reale Wechselrichterreaktion + kontrollieren. +10. Abschliessend die Wechselrichtersteuerung waehlen und pruefen, dass alle + Leistungsregister auf 0 sowie das Managementregister auf Automatik gehen. + +## Fehlerbilder + +- **Konfiguration ungueltig:** Variablentypen, Aktionszuordnung und die fuer + den Batterietyp erforderlichen Register pruefen. +- **Messwerte fehlen oder sind veraltet:** Variablenaktualisierung und + MesswertMaxAlter kontrollieren. +- **Register konnten nicht geschrieben werden:** Standard- oder + benutzerdefinierte Aktion der Zielvariablen sowie die Geraetekommunikation + pruefen. +- **Sollwert wird abgewiesen:** Der Wert muss im zuletzt gemeldeten Angebot + enthalten und die Aenderungssperre muss abgelaufen sein. + +## Tests + +~~~bash +composer test -- --filter 'Batterie(Regler|Modulstruktur)Test' +composer symcon:single -- Batterie +composer check +~~~ + +Der Symcon-Test prueft die Modulinstanz, direkte GoodWe-Registerbefehle, +Laden und Entladen, Messwertereignisse sowie die Rueckgabe an die +Wechselrichtersteuerung. + +## Weiterfuehrende Dokumentation + +- [Ausfuehrliche Modulbeschreibung](../docs/module/Batterie/README.md) +- [Manager-Verbraucher-Schnittstelle](../docs/Schnittstelle.md) +- [Migration von Enelix 1](../docs/migration/Batterie.md) diff --git a/Batterie/form.json b/Batterie/form.json new file mode 100644 index 0000000..c7cbcc5 --- /dev/null +++ b/Batterie/form.json @@ -0,0 +1,229 @@ +{ + "elements": [ + { + "type": "ExpansionPanel", + "caption": "Manager und Zeitverhalten", + "expanded": true, + "items": [ + { + "type": "NumberSpinner", + "name": "PrioritaetPV", + "caption": "Prioritaet PV", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "PrioritaetPeak", + "caption": "Prioritaet Peak", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "Meldeintervall", + "caption": "Meldeintervall", + "suffix": " s", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "VorgabeTimeout", + "caption": "Vorgabe-Timeout", + "suffix": " s", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "Aenderungssperre", + "caption": "Sperrzeit nach Leistungsaenderung", + "suffix": " s", + "minimum": 0 + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Batterie und Messwerte", + "expanded": true, + "items": [ + { + "type": "Select", + "name": "Batterietyp", + "caption": "Batterietyp", + "onChange": "IPS_RequestAction($id, 'FormBatterietyp', $Batterietyp);", + "options": [ + {"caption": "Unkonfiguriert", "value": 0}, + {"caption": "Herstellerunabhaengig", "value": 1}, + {"caption": "GoodWe", "value": 2}, + {"caption": "SolarEdge", "value": 3}, + {"caption": "Sigenergy", "value": 4} + ] + }, + { + "type": "Select", + "name": "Batteriemanagement", + "caption": "Steuerung der Batterie", + "options": [ + {"caption": "Wechselrichter", "value": 1}, + {"caption": "Enelix Manager", "value": 2} + ] + }, + { + "type": "SelectVariable", + "name": "MaxLadeleistungVariableID", + "caption": "Maximale Ladeleistung", + "validVariableTypes": [1, 2] + }, + { + "type": "SelectVariable", + "name": "MaxEntladeleistungVariableID", + "caption": "Maximale Entladeleistung", + "validVariableTypes": [1, 2] + }, + { + "type": "SelectVariable", + "name": "LadezustandVariableID", + "caption": "Ladezustand", + "validVariableTypes": [1, 2] + }, + { + "type": "SelectVariable", + "name": "NetzleistungVariableID", + "caption": "Netzleistung", + "validVariableTypes": [1, 2] + }, + { + "type": "SelectVariable", + "name": "IstleistungVariableID", + "caption": "Aktuelle Batterieleistung", + "validVariableTypes": [1, 2] + }, + { + "type": "NumberSpinner", + "name": "MesswertMaxAlter", + "caption": "Maximales Messwertalter", + "suffix": " s", + "minimum": 1 + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Ladezustandsgrenzen", + "expanded": true, + "items": [ + { + "type": "NumberSpinner", + "name": "ReserveLadezustand", + "caption": "Reserve fuer Peakshaving", + "suffix": " %", + "minimum": 0, + "maximum": 100, + "digits": 1 + }, + { + "type": "NumberSpinner", + "name": "MindestLadezustand", + "caption": "Minimaler Ladezustand", + "suffix": " %", + "minimum": 0, + "maximum": 100, + "digits": 1 + }, + { + "type": "NumberSpinner", + "name": "LadezustandHysterese", + "caption": "Reserve-Hysterese", + "suffix": " %", + "minimum": 0, + "maximum": 100, + "digits": 1 + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Batterieregister", + "expanded": true, + "items": [ + { + "type": "Label", + "caption": "Es werden nur die zum gewaehlten Batterietyp passenden Register angezeigt." + }, + { + "type": "SelectVariable", + "name": "ManagementRegisterVariableID", + "caption": "Managementregister", + "validVariableTypes": [1, 2], + "visible": false + }, + { + "type": "SelectVariable", + "name": "ModusRegisterVariableID", + "caption": "Lade-/Entlademodus", + "validVariableTypes": [1, 2], + "visible": false + }, + { + "type": "SelectVariable", + "name": "LeistungsRegisterVariableID", + "caption": "Leistungsregister", + "validVariableTypes": [1, 2], + "visible": false + }, + { + "type": "SelectVariable", + "name": "LadeleistungRegisterVariableID", + "caption": "Ladeleistungsregister", + "validVariableTypes": [1, 2], + "visible": false + }, + { + "type": "SelectVariable", + "name": "EntladeleistungRegisterVariableID", + "caption": "Entladeleistungsregister", + "validVariableTypes": [1, 2], + "visible": false + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Darstellung und Diagnose", + "items": [ + { + "type": "CheckBox", + "name": "EinstellungenInVisu", + "caption": "Einstellungen in der Visualisierung anzeigen" + }, + { + "type": "CheckBox", + "name": "DiagnosevariablenAnzeigen", + "caption": "Diagnosevariablen anzeigen" + }, + { + "type": "CheckBox", + "name": "LoggingEin", + "caption": "Diagnoseprotokoll aktivieren" + } + ] + } + ], + "actions": [ + { + "type": "Button", + "caption": "Messwerte und Angebot aktualisieren", + "onClick": "IPS_RequestAction($id, 'Aktualisieren', true);" + }, + { + "type": "Button", + "caption": "Register auf sicheren Zustand setzen", + "onClick": "IPS_RequestAction($id, 'SichererZustand', true);" + } + ], + "status": [ + {"code": 102, "icon": "active", "caption": "Aktiv"}, + {"code": 201, "icon": "error", "caption": "Konfiguration ungueltig"}, + {"code": 202, "icon": "inactive", "caption": "Messwerte fehlen oder sind veraltet"}, + {"code": 203, "icon": "error", "caption": "Batterieregister konnten nicht geschrieben werden"} + ] +} diff --git a/Batterie/module.json b/Batterie/module.json new file mode 100644 index 0000000..0f6882f --- /dev/null +++ b/Batterie/module.json @@ -0,0 +1,14 @@ +{ + "id": "{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}", + "name": "Batterie", + "type": 3, + "vendor": "Enelix", + "aliases": [ + "Batteriespeicher" + ], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "ENELIX", + "url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Batterie" +} diff --git a/Batterie/module.php b/Batterie/module.php new file mode 100644 index 0000000..fafc151 --- /dev/null +++ b/Batterie/module.php @@ -0,0 +1,1175 @@ + */ + private const REGISTER_PROPERTIES = [ + 'Management' => 'ManagementRegisterVariableID', + 'Modus' => 'ModusRegisterVariableID', + 'Leistung' => 'LeistungsRegisterVariableID', + 'Ladeleistung' => 'LadeleistungRegisterVariableID', + 'Entladeleistung' => 'EntladeleistungRegisterVariableID', + ]; + + /** @var list */ + private const MESSWERT_PROPERTIES = [ + 'MaxLadeleistungVariableID', + 'MaxEntladeleistungVariableID', + 'LadezustandVariableID', + 'NetzleistungVariableID', + 'IstleistungVariableID', + ]; + + /** @var list */ + private const DIAGNOSE_VARIABLEN = [ + 'Istleistung', + 'Leistungsquelle', + 'Sollleistung', + 'SollwertGueltig', + 'Verfuegbar', + 'AenderungMoeglich', + 'Stoerung', + 'Stoertext', + 'Ladezustand', + 'LadezustandGueltig', + 'Netzleistung', + 'MaxLadeleistung', + 'MaxEntladeleistung', + 'HystereseAktiv', + 'Steuerungsmodus', + 'BezogeneEnergie', + 'LeistungsangebotDiagnose', + 'LetzterRegisterbefehl', + ]; + + public function Create(): void + { + parent::Create(); + + $this->registriereVerbraucherBasis(); + $this->RegisterPropertyInteger('Batterietyp', BatterieRegler::TYP_UNKONFIGURIERT); + $this->RegisterPropertyInteger( + 'Batteriemanagement', + BatterieRegler::STEUERUNG_WECHSELRICHTER + ); + $this->RegisterPropertyInteger('MaxLadeleistungVariableID', 0); + $this->RegisterPropertyInteger('MaxEntladeleistungVariableID', 0); + $this->RegisterPropertyInteger('LadezustandVariableID', 0); + $this->RegisterPropertyInteger('NetzleistungVariableID', 0); + $this->RegisterPropertyInteger('IstleistungVariableID', 0); + $this->RegisterPropertyInteger('ManagementRegisterVariableID', 0); + $this->RegisterPropertyInteger('ModusRegisterVariableID', 0); + $this->RegisterPropertyInteger('LeistungsRegisterVariableID', 0); + $this->RegisterPropertyInteger('LadeleistungRegisterVariableID', 0); + $this->RegisterPropertyInteger('EntladeleistungRegisterVariableID', 0); + $this->RegisterPropertyFloat('ReserveLadezustand', 20.0); + $this->RegisterPropertyFloat('MindestLadezustand', 10.0); + $this->RegisterPropertyFloat('LadezustandHysterese', 2.0); + $this->RegisterPropertyInteger('MesswertMaxAlter', 60); + $this->RegisterPropertyInteger('Aenderungssperre', 4); + $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); + + $this->RegisterVariableInteger('Ladestatus', 'Ladestatus', '', 100); + + $this->RegisterAttributeString('RegistrierteMesswerte', '[]'); + $this->RegisterAttributeInteger('LetzteVorgabeZeit', 0); + $this->RegisterAttributeInteger('LetzteManagerID', 0); + $this->RegisterAttributeInteger('AenderungFreigabeAb', 0); + $this->RegisterAttributeInteger('EnergieStandZeit', 0); + $this->RegisterAttributeInteger('LetzterSollwert', 0); + $this->RegisterAttributeString('Leistungsangebot', '[0]'); + $this->RegisterAttributeString( + 'Betriebsart', + Nachrichtenvertrag::BETRIEBSART_PV + ); + $this->RegisterAttributeString('LetzterRegisterbefehl', '{}'); + $this->RegisterAttributeString('Registerfehler', ''); + $this->RegisterAttributeBoolean('RegisterInitialisiert', false); + $this->RegisterAttributeBoolean('RegisterSchreiben', false); + + $this->RegisterAttributeFloat('ZustandIstleistung', 0.0); + $this->RegisterAttributeInteger('ZustandSollleistung', 0); + $this->RegisterAttributeBoolean('ZustandSollwertGueltig', false); + $this->RegisterAttributeBoolean('ZustandVerfuegbar', false); + $this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false); + $this->RegisterAttributeBoolean('ZustandStoerung', false); + $this->RegisterAttributeString('ZustandStoertext', ''); + $this->RegisterAttributeFloat('ZustandLadezustand', 0.0); + $this->RegisterAttributeBoolean('ZustandLadezustandGueltig', false); + $this->RegisterAttributeFloat('ZustandNetzleistung', 0.0); + $this->RegisterAttributeFloat('ZustandMaxLadeleistung', 0.0); + $this->RegisterAttributeFloat('ZustandMaxEntladeleistung', 0.0); + $this->RegisterAttributeBoolean('ZustandHystereseAktiv', false); + $this->RegisterAttributeInteger('ZustandSteuerungsmodus', 0); + $this->RegisterAttributeFloat('ZustandBezogeneEnergie', 0.0); + + $this->RegisterTimer( + 'Meldezyklus', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);" + ); + $this->RegisterTimer( + 'RueckmeldungVerzoegert', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);" + ); + $this->RegisterTimer( + 'VorgabeTimeout', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', 0);" + ); + $this->RegisterTimer( + 'AenderungFreigabe', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'AenderungFreigabe', 0);" + ); + } + + public function ApplyChanges(): void + { + parent::ApplyChanges(); + + $this->aktualisiereVariablen(); + $this->registriereMesswertmeldungen(); + $this->WriteAttributeBoolean('RegisterInitialisiert', false); + + try { + $this->pruefeKonfiguration(); + } catch (Throwable $fehler) { + $this->deaktiviereTimer(); + $this->setzeZustand('Verfuegbar', false); + $this->setzeZustand('AenderungMoeglich', false); + $this->setzeStoerung($fehler->getMessage()); + $this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG); + $this->protokolliere('Konfiguration', $fehler->getMessage()); + return; + } + + $this->SetTimerInterval( + 'Meldezyklus', + $this->ReadPropertyInteger('Meldeintervall') * 1000 + ); + $this->aktualisiere(true); + } + + public function GetConfigurationForm(): string + { + $formular = json_decode( + (string) file_get_contents(__DIR__ . '/form.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + $this->setzeRegisterfelderSichtbar( + $formular['elements'], + $this->ReadPropertyInteger('Batterietyp') + ); + + return json_encode($formular, JSON_THROW_ON_ERROR); + } + + public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void + { + if ((int) $nachricht !== self::VM_UPDATE + || $this->ReadAttributeBoolean('RegisterSchreiben') + ) { + return; + } + + $messwerte = json_decode( + $this->ReadAttributeString('RegistrierteMesswerte'), + true + ); + if (is_array($messwerte) && in_array((int) $senderID, $messwerte, true)) { + $this->aktualisiere(true); + } + } + + public function RequestAction($ident, $wert): void + { + switch ($ident) { + case 'Aktiv': + $this->SetValue('Aktiv', (bool) $wert); + if (!(bool) $wert) { + $this->setzeZustand('SollwertGueltig', false); + $this->SetTimerInterval('VorgabeTimeout', 0); + } + $this->aktualisiere(true); + return; + + case 'Melden': + if ((bool) $wert) { + $this->SetTimerInterval('RueckmeldungVerzoegert', 0); + } + $this->aktualisiere(false); + $this->sendeVerbraucherdaten(); + return; + + case 'VorgabeTimeout': + $this->SetTimerInterval('VorgabeTimeout', 0); + $this->setzeZustand('SollwertGueltig', false); + $this->aktualisiere(true); + return; + + case 'AenderungFreigabe': + $this->SetTimerInterval('AenderungFreigabe', 0); + $this->aktualisiere(true); + return; + + case 'Aktualisieren': + $this->aktualisiere(true); + return; + + case 'SichererZustand': + $this->setzeZustand('SollwertGueltig', false); + $this->schreibeRegister(0, true); + $this->aktualisiere(true); + return; + + case 'FormBatterietyp': + $this->aktualisiereFormRegisterfelder((int) $wert); + return; + + case 'ManagerdatenEmpfangen': + if (!is_string($wert)) { + throw new InvalidArgumentException( + 'Managerdaten muessen als JSON uebergeben werden.' + ); + } + $daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($daten)) { + throw new InvalidArgumentException( + 'Managerdaten muessen ein JSON-Objekt sein.' + ); + } + $this->ManagerdatenEmpfangen($daten); + return; + } + + throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident); + } + + /** @param array $daten */ + public function ManagerdatenEmpfangen(array $daten): void + { + Nachrichtenvertrag::pruefeManagerdaten($daten); + if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) { + throw new InvalidArgumentException( + 'Managerdaten sind an eine andere Instanz adressiert.' + ); + } + + $managerID = $daten['Kopf']['AbsenderID']; + if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) { + throw new InvalidArgumentException( + 'Der Manager hat diese Batterie nicht aktiv zugeordnet.' + ); + } + + $this->WriteAttributeString('Betriebsart', $daten['Betriebsart']); + $this->aktualisiere(false); + + $sollleistung = $daten['Sollleistung_W']; + if ($sollleistung === null) { + if ((bool) $this->leseZustand('SollwertGueltig') + && !in_array( + (int) $this->leseZustand('Sollleistung'), + $this->leseLeistungsangebot(), + true + ) + ) { + $this->setzeZustand('SollwertGueltig', false); + $this->aktualisiere(false); + } + $this->planeMeldung(); + return; + } + + if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) { + throw new InvalidArgumentException( + 'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.' + ); + } + if (!(bool) $this->leseZustand('AenderungMoeglich') + && $sollleistung !== (int) $this->leseZustand('Sollleistung') + ) { + throw new RuntimeException( + 'Die Aenderungssperre der Batterie ist noch aktiv.' + ); + } + + $this->WriteAttributeInteger('LetzteManagerID', $managerID); + $this->WriteAttributeInteger('LetzteVorgabeZeit', time()); + $this->setzeZustand('Sollleistung', $sollleistung); + $this->setzeZustand('SollwertGueltig', true); + $this->planeVorgabeTimeout(time()); + $this->aktualisiere(false); + $this->planeMeldung(); + } + + private function aktualisiere(bool $meldungPlanen): void + { + try { + $jetzt = time(); + $this->aktualisiereEnergie($jetzt); + $messwerte = $this->leseMesswerte(); + $messwerteGueltig = $messwerte['Gueltig']; + + $this->setzeZustand('Ladezustand', $messwerte['Ladezustand']); + $this->setzeZustand('LadezustandGueltig', $messwerteGueltig); + $this->setzeZustand('Netzleistung', $messwerte['Netzleistung']); + $this->setzeZustand('MaxLadeleistung', $messwerte['MaxLaden']); + $this->setzeZustand('MaxEntladeleistung', $messwerte['MaxEntladen']); + $this->setzeZustand('Istleistung', $messwerte['Istleistung']); + $this->setzeSichtbareVariable( + 'Leistungsquelle', + $messwerteGueltig + ? Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN + : Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN + ); + $this->aktualisiereLadestatus( + $messwerte['Istleistung'], + $messwerteGueltig + ); + + if ($messwerteGueltig) { + $hysterese = BatterieRegler::hystereseAktiv( + $messwerte['Ladezustand'], + $this->ReadPropertyFloat('ReserveLadezustand'), + $this->ReadPropertyFloat('LadezustandHysterese'), + (bool) $this->leseZustand('HystereseAktiv') + ); + $this->setzeZustand('HystereseAktiv', $hysterese); + } + + $enelixSteuert = $this->ReadPropertyInteger('Batteriemanagement') + === BatterieRegler::STEUERUNG_ENELIX; + $aktiv = (bool) $this->GetValue('Aktiv'); + $verfuegbar = $aktiv && $enelixSteuert && $messwerteGueltig; + $angebot = $messwerteGueltig + ? BatterieRegler::leistungsangebot( + $aktiv, + $enelixSteuert, + $this->ReadAttributeString('Betriebsart') + === Nachrichtenvertrag::BETRIEBSART_PEAK, + $messwerte['Ladezustand'], + $this->ReadPropertyFloat('ReserveLadezustand'), + $this->ReadPropertyFloat('MindestLadezustand'), + (bool) $this->leseZustand('HystereseAktiv'), + $messwerte['Netzleistung'], + $messwerte['Istleistung'], + $messwerte['MaxLaden'], + $messwerte['MaxEntladen'] + ) + : [0]; + + $this->WriteAttributeString( + 'Leistungsangebot', + json_encode($angebot, JSON_THROW_ON_ERROR) + ); + $this->setzeSichtbareVariable( + 'LeistungsangebotDiagnose', + json_encode($angebot, JSON_THROW_ON_ERROR) + ); + + if ((bool) $this->leseZustand('SollwertGueltig') + && !in_array( + (int) $this->leseZustand('Sollleistung'), + $angebot, + true + ) + ) { + $this->setzeZustand('SollwertGueltig', false); + } + $this->pruefeVorgabeTimeout($jetzt); + + $ziel = $verfuegbar && (bool) $this->leseZustand('SollwertGueltig') + ? (int) $this->leseZustand('Sollleistung') + : 0; + $this->schreibeRegister($ziel, false); + $this->setzeZustand('Sollleistung', $ziel); + $this->setzeZustand('Verfuegbar', $verfuegbar); + $aenderungMoeglich = $verfuegbar + && $jetzt >= $this->ReadAttributeInteger('AenderungFreigabeAb'); + $this->setzeZustand('AenderungMoeglich', $aenderungMoeglich); + $this->setzeZustand( + 'Steuerungsmodus', + $this->ReadPropertyInteger('Batteriemanagement') + ); + $this->setzeStoerung( + $messwerteGueltig + ? '' + : 'Batteriemesswerte fehlen, sind veraltet oder ungueltig.' + ); + $this->SetStatus( + $messwerteGueltig + ? self::STATUS_AKTIV + : self::STATUS_MESSWERTE_UNGUELTIG + ); + $this->planeAenderungsfreigabe($jetzt); + $this->planeVorgabeTimeout($jetzt); + + if ($meldungPlanen) { + $this->planeMeldung(); + } + } catch (Throwable $fehler) { + $this->WriteAttributeString('Registerfehler', $fehler->getMessage()); + $this->setzeZustand('Verfuegbar', false); + $this->setzeZustand('AenderungMoeglich', false); + $this->setzeStoerung($fehler->getMessage()); + $this->SetStatus(self::STATUS_REGISTERFEHLER); + $this->protokolliere('Aktualisierung', $fehler->getMessage()); + if ($meldungPlanen) { + $this->planeMeldung(); + } + } + } + + /** @return array{Gueltig: bool, MaxLaden: float, MaxEntladen: float, Ladezustand: float, Netzleistung: float, Istleistung: float} */ + private function leseMesswerte(): array + { + $werte = [ + 'MaxLaden' => 0.0, + 'MaxEntladen' => 0.0, + 'Ladezustand' => 0.0, + 'Netzleistung' => 0.0, + 'Istleistung' => 0.0, + ]; + $zuordnung = [ + 'MaxLadeleistungVariableID' => 'MaxLaden', + 'MaxEntladeleistungVariableID' => 'MaxEntladen', + 'LadezustandVariableID' => 'Ladezustand', + 'NetzleistungVariableID' => 'Netzleistung', + 'IstleistungVariableID' => 'Istleistung', + ]; + + $gueltig = true; + foreach ($zuordnung as $property => $schluessel) { + $variablenID = $this->ReadPropertyInteger($property); + if (!$this->istMesswertAktuell($variablenID)) { + $gueltig = false; + continue; + } + $werte[$schluessel] = (float) GetValue($variablenID); + } + + if ($werte['MaxLaden'] <= 0.0 + || $werte['MaxEntladen'] <= 0.0 + || $werte['Ladezustand'] < 0.0 + || $werte['Ladezustand'] > 100.0 + ) { + $gueltig = false; + } + + return ['Gueltig' => $gueltig] + $werte; + } + + private function istMesswertAktuell(int $variablenID): bool + { + if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) { + return false; + } + $variable = IPS_GetVariable($variablenID); + $aktualisiert = (int) ($variable['VariableUpdated'] ?? 0); + + return $aktualisiert > 0 + && time() - $aktualisiert <= $this->ReadPropertyInteger('MesswertMaxAlter'); + } + + private function schreibeRegister(int $sollleistungW, bool $erzwingen): void + { + $werte = BatterieRegler::registerwerte( + $this->ReadPropertyInteger('Batterietyp'), + $this->ReadPropertyInteger('Batteriemanagement'), + $sollleistungW + ); + $befehl = json_encode($werte, JSON_THROW_ON_ERROR); + if (!$erzwingen + && $this->ReadAttributeBoolean('RegisterInitialisiert') + && $befehl === $this->ReadAttributeString('LetzterRegisterbefehl') + ) { + return; + } + + $this->WriteAttributeBoolean('RegisterSchreiben', true); + try { + foreach ($werte as $register => $wert) { + $property = self::REGISTER_PROPERTIES[$register]; + RequestAction($this->ReadPropertyInteger($property), $wert); + } + } catch (Throwable $fehler) { + throw new RuntimeException( + 'Batterieregister konnten nicht geschrieben werden: ' + . $fehler->getMessage() + ); + } finally { + $this->WriteAttributeBoolean('RegisterSchreiben', false); + } + + $alterSollwert = $this->ReadAttributeInteger('LetzterSollwert'); + $this->WriteAttributeString('LetzterRegisterbefehl', $befehl); + $this->WriteAttributeBoolean('RegisterInitialisiert', true); + $this->setzeSichtbareVariable('LetzterRegisterbefehl', $befehl); + $this->WriteAttributeString('Registerfehler', ''); + if ($alterSollwert !== $sollleistungW + && $this->ReadPropertyInteger('Batteriemanagement') + === BatterieRegler::STEUERUNG_ENELIX + ) { + $this->WriteAttributeInteger( + 'AenderungFreigabeAb', + time() + $this->ReadPropertyInteger('Aenderungssperre') + ); + } + $this->WriteAttributeInteger('LetzterSollwert', $sollleistungW); + $this->protokolliere('Registerbefehl', $werte); + } + + private function aktualisiereEnergie(int $jetzt): void + { + $standZeit = $this->ReadAttributeInteger('EnergieStandZeit'); + if ($standZeit > 0 && $jetzt >= $standZeit) { + $energie = (float) $this->leseZustand('BezogeneEnergie') + + (float) $this->leseZustand('Istleistung') + * ($jetzt - $standZeit) / 3600000.0; + $this->setzeZustand('BezogeneEnergie', $energie); + } + $this->WriteAttributeInteger('EnergieStandZeit', $jetzt); + } + + private function aktualisiereLadestatus( + float $istleistungW, + bool $gueltig + ): void { + $status = 0; + if ($gueltig) { + $status = $istleistungW > 0.0 ? 2 : ($istleistungW < 0.0 ? 3 : 1); + } + $this->SetValue('Ladestatus', $status); + } + + private function pruefeVorgabeTimeout(int $jetzt): void + { + if (!(bool) $this->leseZustand('SollwertGueltig')) { + return; + } + $letzteVorgabe = $this->ReadAttributeInteger('LetzteVorgabeZeit'); + if ($letzteVorgabe <= 0 + || $jetzt - $letzteVorgabe >= $this->ReadPropertyInteger('VorgabeTimeout') + ) { + $this->setzeZustand('SollwertGueltig', false); + $this->SetTimerInterval('VorgabeTimeout', 0); + } + } + + private function planeVorgabeTimeout(int $jetzt): void + { + if (!(bool) $this->leseZustand('SollwertGueltig')) { + $this->SetTimerInterval('VorgabeTimeout', 0); + return; + } + $ablauf = $this->ReadAttributeInteger('LetzteVorgabeZeit') + + $this->ReadPropertyInteger('VorgabeTimeout'); + $this->SetTimerInterval( + 'VorgabeTimeout', + max(1, ($ablauf - $jetzt) * 1000) + ); + } + + private function planeAenderungsfreigabe(int $jetzt): void + { + $freigabe = $this->ReadAttributeInteger('AenderungFreigabeAb'); + $this->SetTimerInterval( + 'AenderungFreigabe', + $freigabe > $jetzt ? max(1, ($freigabe - $jetzt) * 1000) : 0 + ); + } + + private function planeMeldung(): void + { + $this->SetTimerInterval('RueckmeldungVerzoegert', 100); + } + + private function sendeVerbraucherdaten(): void + { + foreach ($this->zugeordneteManagerIDs() as $managerID) { + $daten = $this->baueVerbraucherdaten($managerID); + Nachrichtenvertrag::pruefeVerbraucherdaten($daten); + try { + IPS_RequestAction( + $managerID, + 'VerbraucherdatenEmpfangen', + json_encode($daten, JSON_THROW_ON_ERROR) + ); + } catch (Throwable $fehler) { + $this->protokolliere( + 'Managerkommunikation', + $fehler->getMessage() + ); + } + } + } + + /** @return array */ + private function baueVerbraucherdaten(int $managerID): array + { + $messwerteGueltig = (bool) $this->leseZustand('LadezustandGueltig'); + $registerfehler = $this->ReadAttributeString('Registerfehler'); + + return [ + 'Kopf' => [ + 'Version' => Nachrichtenvertrag::VERSION, + 'AbsenderID' => $this->InstanceID, + 'EmpfaengerID' => $managerID, + 'Zeitpunkt' => time(), + ], + 'Betriebsart' => $this->ReadAttributeString('Betriebsart'), + 'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'), + 'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'), + 'Leistungswerte_W' => $this->leseLeistungsangebot(), + 'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'), + 'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'), + 'Istleistung_W' => $messwerteGueltig + ? (float) $this->leseZustand('Istleistung') + : null, + 'Leistungsquelle' => $messwerteGueltig + ? Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN + : Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN, + 'Zustand' => [ + [ + 'Kennung' => 'Sollleistung_W', + 'Art' => 'Sollwert', + 'Wert' => (bool) $this->leseZustand('SollwertGueltig') + ? (int) $this->leseZustand('Sollleistung') + : null, + 'Einheit' => 'W', + ], + [ + 'Kennung' => 'Ladezustand_Prozent', + 'Art' => 'Istwert', + 'Wert' => $messwerteGueltig + ? (float) $this->leseZustand('Ladezustand') + : null, + 'Einheit' => '%', + ], + [ + 'Kennung' => 'HystereseAktiv', + 'Art' => 'Status', + 'Wert' => (bool) $this->leseZustand('HystereseAktiv'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Batteriesteuerung', + 'Art' => 'Status', + 'Wert' => $this->ReadPropertyInteger('Batteriemanagement'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Messwertfehler', + 'Art' => 'Stoerung', + 'Wert' => !$messwerteGueltig, + 'Einheit' => '', + 'Text' => $messwerteGueltig + ? '' + : 'Batteriemesswerte fehlen, sind veraltet oder ungueltig.', + ], + [ + 'Kennung' => 'Registerfehler', + 'Art' => 'Stoerung', + 'Wert' => $registerfehler !== '', + 'Einheit' => '', + 'Text' => $registerfehler, + ], + ], + ]; + } + + /** @return list */ + private function zugeordneteManagerIDs(): array + { + $ergebnis = []; + foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) { + try { + $automatisch = (bool) IPS_GetProperty( + $managerID, + 'AutomatischeSuche' + ); + $property = $automatisch + ? 'AutomatischeVerbraucherZuordnung' + : 'VerbraucherZuordnung'; + $zuordnung = $this->leseManagerZuordnung($managerID, $property); + if ($automatisch && $zuordnung === []) { + $zuordnung = $this->leseManagerZuordnung( + $managerID, + 'VerbraucherZuordnung' + ); + } + } catch (Throwable $fehler) { + continue; + } + + foreach ($zuordnung as $eintrag) { + if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) { + continue; + } + $instanzID = $eintrag['InstanzID'] + ?? $eintrag['Verbraucher'] + ?? null; + if ($instanzID === $this->InstanceID) { + $ergebnis[] = (int) $managerID; + break; + } + } + } + + return array_values(array_unique($ergebnis)); + } + + /** @return array */ + private function leseManagerZuordnung(int $managerID, string $property): array + { + $zuordnung = json_decode( + (string) IPS_GetProperty($managerID, $property), + true, + 512, + JSON_THROW_ON_ERROR + ); + + return is_array($zuordnung) ? $zuordnung : []; + } + + private function pruefeKonfiguration(): void + { + foreach (['PrioritaetPV', 'PrioritaetPeak', 'Aenderungssperre'] as $property) { + if ($this->ReadPropertyInteger($property) < 0) { + throw new InvalidArgumentException( + $property . ' darf nicht negativ sein.' + ); + } + } + foreach (['Meldeintervall', 'VorgabeTimeout', 'MesswertMaxAlter'] as $property) { + if ($this->ReadPropertyInteger($property) <= 0) { + throw new InvalidArgumentException( + $property . ' muss groesser als 0 sein.' + ); + } + } + + BatterieRegler::hystereseAktiv( + 50.0, + $this->ReadPropertyFloat('ReserveLadezustand'), + $this->ReadPropertyFloat('LadezustandHysterese'), + false + ); + BatterieRegler::leistungsangebot( + true, + true, + false, + 50.0, + $this->ReadPropertyFloat('ReserveLadezustand'), + $this->ReadPropertyFloat('MindestLadezustand'), + false, + 0.0, + 0.0, + 1.0, + 1.0 + ); + + foreach (self::MESSWERT_PROPERTIES as $property) { + $this->pruefeNumerischeVariable( + $this->ReadPropertyInteger($property), + $property, + false + ); + } + + $registerIDs = []; + foreach (BatterieRegler::erforderlicheRegister( + $this->ReadPropertyInteger('Batterietyp') + ) as $register) { + $property = self::REGISTER_PROPERTIES[$register]; + $variablenID = $this->ReadPropertyInteger($property); + $this->pruefeNumerischeVariable($variablenID, $property, true); + if (isset($registerIDs[$variablenID])) { + throw new InvalidArgumentException( + 'Jedes Batterieregister muss eine eigene Variable verwenden.' + ); + } + $registerIDs[$variablenID] = true; + } + + BatterieRegler::registerwerte( + $this->ReadPropertyInteger('Batterietyp'), + $this->ReadPropertyInteger('Batteriemanagement'), + 0 + ); + } + + private function pruefeNumerischeVariable( + int $variablenID, + string $bezeichnung, + bool $aktionErforderlich + ): void { + if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) { + throw new InvalidArgumentException( + $bezeichnung . ' ist nicht eingerichtet.' + ); + } + $variable = IPS_GetVariable($variablenID); + if (!in_array((int) $variable['VariableType'], [1, 2], true)) { + throw new InvalidArgumentException( + $bezeichnung . ' muss eine Integer- oder Floatvariable sein.' + ); + } + if ($aktionErforderlich + && (int) $variable['VariableAction'] <= 0 + && (int) $variable['VariableCustomAction'] <= 0 + ) { + throw new InvalidArgumentException( + $bezeichnung + . ' benoetigt eine Standard- oder benutzerdefinierte Aktion.' + ); + } + } + + private function registriereMesswertmeldungen(): void + { + $alteIDs = json_decode( + $this->ReadAttributeString('RegistrierteMesswerte'), + true + ); + if (!is_array($alteIDs)) { + $alteIDs = []; + } + $neueIDs = []; + foreach (self::MESSWERT_PROPERTIES as $property) { + $variablenID = $this->ReadPropertyInteger($property); + if ($variablenID > 0) { + $neueIDs[] = $variablenID; + } + } + $neueIDs = array_values(array_unique($neueIDs)); + + foreach ($alteIDs as $alteID) { + if (!in_array((int) $alteID, $neueIDs, true)) { + $this->UnregisterMessage((int) $alteID, self::VM_UPDATE); + } + } + foreach ($neueIDs as $neueID) { + if (IPS_VariableExists($neueID)) { + $this->RegisterMessage($neueID, self::VM_UPDATE); + } + } + $this->WriteAttributeString( + 'RegistrierteMesswerte', + json_encode($neueIDs, JSON_THROW_ON_ERROR) + ); + } + + private function aktualisiereVariablen(): void + { + if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) { + $this->registriereVerbraucherDiagnose(); + $this->RegisterVariableFloat('Ladezustand', 'Ladezustand', '', 200); + $this->RegisterVariableBoolean( + 'LadezustandGueltig', + 'Ladezustand gueltig', + '~Switch', + 210 + ); + $this->RegisterVariableFloat('Netzleistung', 'Netzleistung', '', 220); + $this->RegisterVariableFloat( + 'MaxLadeleistung', + 'Maximale Ladeleistung', + '', + 230 + ); + $this->RegisterVariableFloat( + 'MaxEntladeleistung', + 'Maximale Entladeleistung', + '', + 240 + ); + $this->RegisterVariableBoolean( + 'HystereseAktiv', + 'Hysterese aktiv', + '~Switch', + 250 + ); + $this->RegisterVariableInteger( + 'Steuerungsmodus', + 'Steuerungsmodus', + '', + 260 + ); + $this->RegisterVariableFloat( + 'BezogeneEnergie', + 'Bezogene Energie', + '~Electricity', + 270 + ); + $this->RegisterVariableString( + 'LeistungsangebotDiagnose', + 'Leistungsangebot', + '', + 280 + ); + $this->RegisterVariableString( + 'LetzterRegisterbefehl', + 'Letzter Registerbefehl', + '', + 290 + ); + $this->synchronisiereDiagnosevariablen(); + return; + } + + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + $variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); + if (is_int($variablenID) + && $variablenID > 0 + && IPS_VariableExists($variablenID) + ) { + $this->UnregisterVariable($ident); + } + } + } + + private function synchronisiereDiagnosevariablen(): void + { + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + if ($ident === 'Leistungsquelle') { + continue; + } + if ($ident === 'LeistungsangebotDiagnose') { + $wert = $this->ReadAttributeString('Leistungsangebot'); + } elseif ($ident === 'LetzterRegisterbefehl') { + $wert = $this->ReadAttributeString('LetzterRegisterbefehl'); + } else { + $wert = $this->leseZustand($ident); + } + $this->setzeSichtbareVariable($ident, $wert); + } + } + + /** @param bool|int|float|string $wert */ + private function setzeZustand(string $ident, $wert): void + { + $booleanAttribute = [ + 'SollwertGueltig' => 'ZustandSollwertGueltig', + 'Verfuegbar' => 'ZustandVerfuegbar', + 'AenderungMoeglich' => 'ZustandAenderungMoeglich', + 'Stoerung' => 'ZustandStoerung', + 'LadezustandGueltig' => 'ZustandLadezustandGueltig', + 'HystereseAktiv' => 'ZustandHystereseAktiv', + ]; + $integerAttribute = [ + 'Sollleistung' => 'ZustandSollleistung', + 'Steuerungsmodus' => 'ZustandSteuerungsmodus', + ]; + $floatAttribute = [ + 'Istleistung' => 'ZustandIstleistung', + 'Ladezustand' => 'ZustandLadezustand', + 'Netzleistung' => 'ZustandNetzleistung', + 'MaxLadeleistung' => 'ZustandMaxLadeleistung', + 'MaxEntladeleistung' => 'ZustandMaxEntladeleistung', + 'BezogeneEnergie' => 'ZustandBezogeneEnergie', + ]; + $stringAttribute = ['Stoertext' => 'ZustandStoertext']; + + if (isset($booleanAttribute[$ident])) { + $wert = (bool) $wert; + $this->WriteAttributeBoolean($booleanAttribute[$ident], $wert); + } elseif (isset($integerAttribute[$ident])) { + $wert = (int) $wert; + $this->WriteAttributeInteger($integerAttribute[$ident], $wert); + } elseif (isset($floatAttribute[$ident])) { + $wert = (float) $wert; + $this->WriteAttributeFloat($floatAttribute[$ident], $wert); + } elseif (isset($stringAttribute[$ident])) { + $wert = (string) $wert; + $this->WriteAttributeString($stringAttribute[$ident], $wert); + } else { + throw new LogicException( + 'Unbekannter interner Batteriezustand: ' . $ident + ); + } + + $this->setzeSichtbareVariable($ident, $wert); + } + + /** @return bool|int|float|string */ + private function leseZustand(string $ident) + { + $booleanAttribute = [ + 'SollwertGueltig' => 'ZustandSollwertGueltig', + 'Verfuegbar' => 'ZustandVerfuegbar', + 'AenderungMoeglich' => 'ZustandAenderungMoeglich', + 'Stoerung' => 'ZustandStoerung', + 'LadezustandGueltig' => 'ZustandLadezustandGueltig', + 'HystereseAktiv' => 'ZustandHystereseAktiv', + ]; + $integerAttribute = [ + 'Sollleistung' => 'ZustandSollleistung', + 'Steuerungsmodus' => 'ZustandSteuerungsmodus', + ]; + $floatAttribute = [ + 'Istleistung' => 'ZustandIstleistung', + 'Ladezustand' => 'ZustandLadezustand', + 'Netzleistung' => 'ZustandNetzleistung', + 'MaxLadeleistung' => 'ZustandMaxLadeleistung', + 'MaxEntladeleistung' => 'ZustandMaxEntladeleistung', + 'BezogeneEnergie' => 'ZustandBezogeneEnergie', + ]; + $stringAttribute = ['Stoertext' => 'ZustandStoertext']; + + if (isset($booleanAttribute[$ident])) { + return $this->ReadAttributeBoolean($booleanAttribute[$ident]); + } + if (isset($integerAttribute[$ident])) { + return $this->ReadAttributeInteger($integerAttribute[$ident]); + } + if (isset($floatAttribute[$ident])) { + return $this->ReadAttributeFloat($floatAttribute[$ident]); + } + if (isset($stringAttribute[$ident])) { + return $this->ReadAttributeString($stringAttribute[$ident]); + } + + throw new LogicException( + 'Unbekannter interner Batteriezustand: ' . $ident + ); + } + + /** @param bool|int|float|string $wert */ + private function setzeSichtbareVariable(string $ident, $wert): void + { + $variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); + if (is_int($variablenID) + && $variablenID > 0 + && IPS_VariableExists($variablenID) + ) { + SetValue($variablenID, $wert); + } + } + + /** @return list */ + private function leseLeistungsangebot(): array + { + $angebot = json_decode( + $this->ReadAttributeString('Leistungsangebot'), + true, + 512, + JSON_THROW_ON_ERROR + ); + if (!is_array($angebot)) { + return [0]; + } + + return array_values(array_map('intval', $angebot)); + } + + private function setzeStoerung(string $text): void + { + $this->setzeZustand('Stoerung', $text !== ''); + $this->setzeZustand('Stoertext', $text); + } + + /** @param array $elemente */ + private function setzeRegisterfelderSichtbar( + array &$elemente, + int $batterietyp + ): void { + $sichtbar = $this->registerfeldSichtbarkeit($batterietyp); + foreach ($elemente as &$element) { + if (isset($element['name'], $sichtbar[$element['name']])) { + $element['visible'] = $sichtbar[$element['name']]; + } + if (isset($element['items']) && is_array($element['items'])) { + $this->setzeRegisterfelderSichtbar( + $element['items'], + $batterietyp + ); + } + } + } + + private function aktualisiereFormRegisterfelder(int $batterietyp): void + { + foreach ($this->registerfeldSichtbarkeit($batterietyp) as $name => $sichtbar) { + $this->UpdateFormField($name, 'visible', $sichtbar); + } + } + + /** @return array */ + private function registerfeldSichtbarkeit(int $batterietyp): array + { + $konfiguriert = in_array($batterietyp, [ + BatterieRegler::TYP_HERSTELLERUNABHAENGIG, + BatterieRegler::TYP_GOODWE, + BatterieRegler::TYP_SOLAREDGE, + BatterieRegler::TYP_SIGENERGY, + ], true); + $goodwe = $batterietyp === BatterieRegler::TYP_GOODWE; + $getrennt = in_array($batterietyp, [ + BatterieRegler::TYP_HERSTELLERUNABHAENGIG, + BatterieRegler::TYP_SOLAREDGE, + BatterieRegler::TYP_SIGENERGY, + ], true); + + return [ + 'ManagementRegisterVariableID' => $konfiguriert, + 'ModusRegisterVariableID' => $konfiguriert, + 'LeistungsRegisterVariableID' => $goodwe, + 'LadeleistungRegisterVariableID' => $getrennt, + 'EntladeleistungRegisterVariableID' => $getrennt, + ]; + } + + private function deaktiviereTimer(): void + { + foreach ([ + 'Meldezyklus', + 'RueckmeldungVerzoegert', + 'VorgabeTimeout', + 'AenderungFreigabe', + ] as $timer) { + $this->SetTimerInterval($timer, 0); + } + } + + /** @param mixed $daten */ + private function protokolliere(string $bezeichnung, $daten): void + { + if (!$this->ReadPropertyBoolean('LoggingEin')) { + return; + } + $this->SendDebug( + $bezeichnung, + is_string($daten) + ? $daten + : json_encode($daten, JSON_THROW_ON_ERROR), + 0 + ); + } +} diff --git a/Manager/module.php b/Manager/module.php index 6bf74c0..789cb6e 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -27,6 +27,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle '{0D94913C-0F31-4C29-A685-6EB4AE58E55D}', '{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}', '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}', + '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}', ]; private const MONATSNAMEN = [ 1 => 'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', diff --git a/README.md b/README.md index 1ec7817..150f42a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ weiteren Module sind als Diskussionsentwürfe dokumentiert. ## Geplante Module - Manager (implementiert) -- Batterie +- Batterie (implementiert) - Wassererwaermer (implementiert) - Pufferspeicher - Verbraucher 1-Stufig (implementiert) @@ -62,11 +62,14 @@ der Uebernahme in einen Freigabebranch erfolgreich sein. - [Zentrale Liste offener Teamentscheidungen](docs/Offene-Punkte.md) - [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md) +- [Batterieschnittstelle](docs/Schnittstelle-Batterie.md) - [Manager-Modul inklusive Lizenzierung](docs/module/Manager/README.md) - [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md) +- [Batteriemodul](docs/module/Batterie/README.md) - [Verbraucher-1-Stufig-Modul](docs/module/Verbraucher-1-Stufig/README.md) - [Ladestation-Stand-Alone-Modul](docs/module/Ladestation-Stand-Alone/README.md) - [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md) - [Migration Verbraucher 1-Stufig](docs/migration/Verbraucher-1-Stufig.md) +- [Migration Batterie](docs/migration/Batterie.md) - [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md) - [Modulübersicht](docs/module/README.md) diff --git a/docs/Schnittstelle-Batterie.md b/docs/Schnittstelle-Batterie.md new file mode 100644 index 0000000..310b7ae --- /dev/null +++ b/docs/Schnittstelle-Batterie.md @@ -0,0 +1,110 @@ +# Schnittstelle Batterie + +Diese Beschreibung ergaenzt den allgemeinen +[Manager-Verbraucher-Vertrag](Schnittstelle.md) fuer das Batteriemodul. +Vertragsversion ist 4.0. + +## Manager an Batterie + +Die Batterie empfaengt den unveraenderten gemeinsamen Datensatz: + +~~~json +{ + "Kopf": { + "Version": "4.0", + "AbsenderID": 10001, + "EmpfaengerID": 20001, + "Zeitpunkt": 1788825600 + }, + "Betriebsart": "PV", + "Sollleistung_W": -1500 +} +~~~ + +Sollleistung_W verwendet folgende Semantik: + +- positiver Wert: Batterie laden +- negativer Wert: Batterie entladen +- 0: Leistungsregister auf 0 setzen +- null: nur Betriebsart synchronisieren und neues Angebot anfordern + +Ein konkreter Wert muss im zuletzt fuer dieselbe Betriebsart gemeldeten +Leistungsangebot enthalten sein. Waehren der Aenderungssperre wird nur die +Wiederholung des aktuellen Sollwerts akzeptiert. + +## Batterie an Manager + +Die Batterie verwendet alle Pflichtfelder des gemeinsamen Vertrags und +ergaenzt folgende Zustandseintraege: + +| Kennung | Art | Typ | Einheit | Bedeutung | +| --- | --- | --- | --- | --- | +| Sollleistung_W | Sollwert | Integer oder null | W | Aktuell gueltige Manager-Vorgabe | +| Ladezustand_Prozent | Istwert | Float oder null | % | Physischer SoC | +| HystereseAktiv | Status | Boolean | - | Reserve-Hysterese ist aktiv | +| Batteriesteuerung | Status | Integer | - | 1 Wechselrichter, 2 Enelix | +| Messwertfehler | Stoerung | Boolean | - | Pflichtmesswert fehlt oder ist ungueltig | +| Registerfehler | Stoerung | Boolean | - | Mindestens ein Schreibbefehl ist fehlgeschlagen | + +Beispiel: + +~~~json +{ + "Kopf": { + "Version": "4.0", + "AbsenderID": 20001, + "EmpfaengerID": 10001, + "Zeitpunkt": 1788825602 + }, + "Betriebsart": "Peak", + "PrioritaetPV": 0, + "PrioritaetPeak": 0, + "Leistungswerte_W": [-1500], + "AenderungMoeglich": true, + "Verfuegbar": true, + "Istleistung_W": -250.0, + "Leistungsquelle": 2, + "Zustand": [ + { + "Kennung": "Sollleistung_W", + "Art": "Sollwert", + "Wert": -1500, + "Einheit": "W" + }, + { + "Kennung": "Ladezustand_Prozent", + "Art": "Istwert", + "Wert": 54.2, + "Einheit": "%" + } + ] +} +~~~ + +Ist die Leistungsmessung ungueltig, wird Istleistung_W als null und +Leistungsquelle als 0 gemeldet. Bei einem gueltigen Messwert ist +Leistungsquelle 2. + +## Ereignisse + +Das Modul registriert VM_UPDATE fuer: + +- maximale Ladeleistung +- maximale Entladeleistung +- Ladezustand +- Netzleistung +- aktuelle Batterieleistung + +Jede Aktualisierung berechnet Zustand und Angebot neu und plant eine +gebuendelte Rueckmeldung. Registerwerte werden nur geschrieben, wenn sich der +resultierende Befehl geaendert hat. + +## Registerausgang + +Der technische Ausgang besteht aus ausgewaehlten numerischen +IP-Symcon-Variablen. Das Modul verwendet RequestAction und setzt diese +Variablen nicht mit SetValue. Damit bleibt der jeweilige Modbus-, Skript- oder +Geraeteadapter fuer die konkrete Registerkommunikation verantwortlich. + +Die genaue Herstellerabbildung steht in der +[Moduldokumentation](module/Batterie/README.md). diff --git a/docs/Schnittstelle.md b/docs/Schnittstelle.md index 4f1ae4b..f959724 100644 --- a/docs/Schnittstelle.md +++ b/docs/Schnittstelle.md @@ -132,6 +132,14 @@ Symcon-Datenpunkte registriert `VerbraucherBasisTrait`. `Leistungswerte_W`, `Betriebsart` und `Zustand` werden intern gehalten und direkt in die Nachricht geschrieben. +## Batteriespezifische Erweiterung + +Die Batterie verwendet denselben Vertrag 4.0 und ergaenzt Zustandseintraege +fuer Ladezustand, Hysterese, Steuerungsmodus, Messwertfehler und +Registerfehler. Positive Leistung bedeutet Laden, negative Leistung +Entladen. Die vollstaendige Semantik und Registeranbindung beschreibt die +[Schnittstelle Batterie](Schnittstelle-Batterie.md). + ## Easee-Gateway-Transport Der technische JSON-Vertrag zwischen `EaseeGateway` und diff --git a/docs/migration/Batterie.md b/docs/migration/Batterie.md new file mode 100644 index 0000000..07dc19d --- /dev/null +++ b/docs/migration/Batterie.md @@ -0,0 +1,67 @@ +# Migration Batterie aus Enelix 1 + +## Ziel + +Die bisherige Batterie wird als Enelix-2-Verbraucher auf Vertrag 4.0 +umgestellt. Das alte Repository bleibt unveraendert und dient nur als +Verhaltensreferenz. + +## Uebernommen + +- positive Sollleistung fuer Laden und negative Sollleistung fuer Entladen +- dynamische maximale Lade- und Entladeleistung aus Variablen +- Ladezustand, Netzleistung und aktuelle Batterieleistung als Eingaben +- 250-W-Grundraster und 50-W-Feinwerte um 0 +- Reserve-, Mindestladezustands- und Hystereselogik +- getrennte Angebote fuer PV und Peak +- Wechselrichter- oder Enelix-Steuerung +- Registercodes fuer generisch, GoodWe, SolarEdge und Sigenergy +- Aufsummierung der bezogenen Energie + +## Angepasst + +| Enelix 1 | Enelix 2 | +| --- | --- | +| zyklischer Timer Do_UserCalc | Messwert- und Managerereignisse | +| IdleCounter in Zyklen | Aenderungssperre in Sekunden mit Einmaltimer | +| PowerSteps als Modulvariable | Leistungswerte_W direkt im Vertrag | +| interne Stellwertvariablen | ausgewaehlte Registervariablen mit RequestAction | +| fest verdrahtete Herstellerhilfsvariablen | dynamisch sichtbare Registerauswahl | +| fest codierte 2-%-Hysterese | Property LadezustandHysterese | +| berechnete Istleistung | gemessene Istleistung, Leistungsquelle 2 | +| Interval-basierte Energie | Zeitintegration zwischen Ereignissen und Vollmeldungen | +| Is_Peak_Shaving als Variable | Betriebsart PV oder Peak im Vertrag 4.0 | + +## Neu implementiert + +- Validierung aller Mess- und Registervariablen +- Pflichtaktion auf jeder Zielregistervariable +- Messwertalter und sicherer Zustand bei ungueltigen Messwerten +- Sollwert-Timeout +- Diagnosevariablen, Registerfehlerstatus und Debug-Logging +- standardisierte PHPUnit- und Symcon-8.0-Laufzeittests +- explizite Schnittstellenbeschreibung + +## Verworfen + +- direkter SetValue-Aufruf auf internen Leistungs- und Modusvariablen +- zyklische Regelung mit frei konfigurierbarem Interval +- doppelte Peak-/PV-Codepfade mit identischer Registerabbildung +- ungenutzte Batteriespannungsproperty +- CheckIdle mit globalem GetValue ohne Objekt-ID + +## Umstellung einer Anlage + +1. Bestehende Batterieinstanz und alle bisherigen Variablen-IDs dokumentieren. +2. Sicherung der IP-Symcon-Konfiguration erstellen. +3. Neue Enelix-2-Batterieinstanz anlegen. +4. Fuenf Messwertvariablen zuordnen. +5. Batterietyp waehlen und die eingeblendeten Registervariablen zuordnen. +6. Reserve, Mindestladezustand und Aenderungssperre uebertragen. +7. Neue Batterie im Enelix-2-Manager zuordnen. +8. Diagnose und Logging aktivieren. +9. Laden, Entladen, 0 W und Wechselrichtermodus unter Aufsicht testen. +10. Alte Instanz erst nach erfolgreicher Feldpruefung deaktivieren. + +Ein automatisches Loeschen oder Ueberschreiben der alten Instanz findet nicht +statt. diff --git a/docs/module/Batterie/README.md b/docs/module/Batterie/README.md index c87dd94..c99a783 100644 --- a/docs/module/Batterie/README.md +++ b/docs/module/Batterie/README.md @@ -1,59 +1,121 @@ # Batterie -> Status: Diskussionsentwurf. Positiv bedeutet Laden, negativ Entladen. +> Status: implementiert. Zielplattform ist IP-Symcon ab Version 8.0, +> Nachrichtenvertrag 4.0. -Die Auswahl des Batterietyps bestimmt den Adapter und die sichtbaren -Anschlussfelder. Ohne Logging bleibt von den zusätzlichen Variablen nur -`Ladestatus` sichtbar. +## Verantwortung -## Zusätzliche Variablen +Das Modul bildet einen Batteriespeicher als bidirektionalen Enelix-Verbraucher +ab. Es berechnet das betriebsart- und ladezustandsabhaengige +Leistungsangebot, empfaengt Sollleistungen vom Manager und uebersetzt sie in +herstellerspezifische Registerwerte. -| Ident | Typ / Zugriff | Beschreibung | -| --- | --- | --- | -| `Ladestatus` | Integer / Anzeige | `0` unbekannt, `1` ruhend, `2` laden, `3` entladen. | -| `Ladezustand` | Float / Logging | Physischer Ladezustand in %. | -| `LadezustandGueltig` | Boolean / Logging | Gültigkeit unabhängig von der Leistungsmessung. | -| `Steuerungsmodus` | Integer / lokal bedienbar | `0` Batterie, `1` Manager, `2` Virtuelle Batterie führt. | -| `NotnachladungAktiv` | Boolean / Logging | Aus Ladezustand ermittelter lokaler Bedarf. | -| `LadeleistungVerfuegbar` | Float / Logging | Aktuell nutzbare Ladeleistung in W. | -| `EntladeleistungVerfuegbar` | Float / Logging | Aktuell nutzbare Entladeleistung als positiver Betrag in W. | -| `MaxLadeleistung` | Float / lokal bedienbar | Wirksamer Parameter in W. | -| `MaxEntladeleistung` | Float / lokal bedienbar | Wirksamer Parameter in W. | -| `Mindestladezustand` | Float / lokal bedienbar | Wirksamer Parameter in %. | -| `Maximalladezustand` | Float / lokal bedienbar | Wirksamer Parameter in %. | -| `Nachladeschwelle` | Float / lokal bedienbar | Wirksamer Parameter in %. | -| `Nachladehysterese` | Float / lokal bedienbar | Wirksamer Parameter in %. | -| `Notnachladeleistung` | Float / lokal bedienbar | `0` deaktiviert; sonst Leistung in W. | +Positive Leistung bedeutet Laden, negative Leistung Entladen. Die +Geraeteanbindung erfolgt ausschliesslich ueber vom Benutzer ausgewaehlte +numerische IP-Symcon-Variablen. Schreibziele muessen eine Aktion besitzen. -## Zusätzliche Properties +## Ereignismodell -| Ident | Typ | Standard / Beschreibung | -| --- | --- | --- | -| `MaxLadeleistung` | Float | `0*` W; einzige einstellbare obere Ladegrenze. | -| `MaxEntladeleistung` | Float | `0` W; einzige einstellbare obere Entladegrenze. | -| `Mindestladezustand` | Float | `0*` %; Bereich 0–100. | -| `Maximalladezustand` | Float | `100` %; grösser als Mindestladezustand. | -| `Nachladeschwelle` | Float | `0*` %; zwischen Mindest- und Maximalladezustand. | -| `Nachladehysterese` | Float | `2` %; Schwelle plus Hysterese höchstens Maximalladezustand. | -| `Notnachladeleistung` | Float | `0` W; sonst höchstens MaxLadeleistung. | -| `Batterietyp` | Auswahl | `Unkonfiguriert`, `Herstellerunabhaengig`, `Goodwe`, `SolarEdge`, `SigEnergy`. | -| `BatterieInstanzID` | Integer | `0`; für kompatible Herstelleradapter. | -| `LadezustandVariableID` | Integer | `0`; nur herstellerunabhängig. | -| `IstleistungVariableID` | Integer | `0`; nur herstellerunabhängig. | -| `SollleistungVariableID` | Integer | `0`; nur herstellerunabhängig, vorzeichenbehaftet in W. | -| `LadezustandMaxAlter` | Integer | `60` s; Aktualität des Ladezustands. | -| `VirtuelleBatterieID` | Integer | `0`; Pflicht bei Steuerungsmodus 2. | +Fachliche Neuberechnungen werden ausgeloest durch: -Anschlussart, Batteriespannung, Gerätezuordnung, Leistungsschritt, -Sollwertquelle und externe Sollwerte entfallen. +- Aktualisierung eines der fuenf Messwerte +- Managerdaten mit Betriebsart oder Sollleistung +- lokale Aenderung der Variablen Aktiv +- Ablauf von Vorgabe-Timeout oder Aenderungssperre +- manuelle Aktualisierung in der Konfigurationsmaske -## Zustand +Der Meldezyklus verschickt eine Vollmeldung und dient nicht als Regelzyklus. +Ein periodischer Berechnungstimer oder Idle-Counter existiert nicht. -`Ladezustand_Prozent` als Istwert; `Steuerungsmodus` und -`NotnachladungAktiv` als Status; `LadezustandFehlt` und `Geraetefehler` als -Störungen. +## Properties -## Offene Punkte +### Gemeinsame Verbraucherproperties -- Michis konkrete Fehlerliste nachliefern und gegen den Entwurf prüfen. -- Datenpunkte und Leistungsgrenzen der drei Herstelleradapter verifizieren. +PrioritaetPV, PrioritaetPeak, Meldeintervall, VorgabeTimeout, +EinstellungenInVisu und LoggingEin stammen aus VerbraucherBasisTrait. + +### Batteriespezifische Properties + +| Property | Typ | Standard | Bedeutung | +| --- | --- | ---: | --- | +| Batterietyp | Integer | 0 | 0 unkonfiguriert, 1 generisch, 2 GoodWe, 3 SolarEdge, 4 Sigenergy | +| Batteriemanagement | Integer | 1 | 1 Wechselrichter, 2 Enelix | +| MaxLadeleistungVariableID | Integer | 0 | Dynamische Ladegrenze in W | +| MaxEntladeleistungVariableID | Integer | 0 | Dynamische Entladegrenze in W | +| LadezustandVariableID | Integer | 0 | SoC in Prozent | +| NetzleistungVariableID | Integer | 0 | Positiv Netzbezug, negativ Einspeisung | +| IstleistungVariableID | Integer | 0 | Positiv Laden, negativ Entladen | +| ManagementRegisterVariableID | Integer | 0 | Steuerungsquelle des Wechselrichters | +| ModusRegisterVariableID | Integer | 0 | Laden-/Entladen-Code | +| LeistungsRegisterVariableID | Integer | 0 | GoodWe-Leistungsregister in W | +| LadeleistungRegisterVariableID | Integer | 0 | Ladeleistung in W beziehungsweise kW | +| EntladeleistungRegisterVariableID | Integer | 0 | Entladeleistung in W beziehungsweise kW | +| ReserveLadezustand | Float | 20 | Peakshaving-Reserve in Prozent | +| MindestLadezustand | Float | 10 | Untere Entladegrenze in Prozent | +| LadezustandHysterese | Float | 2 | Hysterese oberhalb der Reserve | +| MesswertMaxAlter | Integer | 60 | Maximales Messwertalter in Sekunden | +| Aenderungssperre | Integer | 4 | Sperrzeit nach Sollwertaenderung | +| DiagnosevariablenAnzeigen | Boolean | false | Technische Variablen einblenden | + +## Registeradapter + +| Typ | WR-Management | Enelix-Management | Laden | Entladen | +| --- | ---: | ---: | --- | --- | +| Generisch | 0 | 1 | Modus 0, Ladeleistung W | Modus 1, Entladeleistung W | +| GoodWe | 1 | 2 | Modus 11, Betrag W | Modus 12, Betrag W | +| SolarEdge | 1 | 4 | Modus 3, Ladeleistung W | Modus 4, Entladeleistung W | +| Sigenergy | 0 | 1 | Modus 3, Ladeleistung kW | Modus 6, Entladeleistung kW | + +Beim Wechsel in die Wechselrichtersteuerung werden zuerst die zum Typ +gehoerenden Leistungsregister auf 0 gesetzt und danach der Automatikcode +geschrieben. Im Enelix-Modus werden zuerst die Leistungswerte, dann Modus und +zuletzt Management geschrieben. + +## Leistungsangebot + +Das Raster besteht wie in Enelix 1 aus 250-W-Grundschritten. Um 0 werden +zusaetzlich -200, -150, -100, -50, 0, 50, 100, 150 und 200 W angeboten, +soweit diese Werte innerhalb der dynamischen Leistungsgrenzen liegen. + +PV und Peak verwenden die bisherige SoC-Logik. Die Hysterese wird als +persistenter Modulzustand gefuehrt. Bei Wechselrichtersteuerung, lokaler +Deaktivierung oder ungueltigen Messwerten lautet das Angebot [0]. + +## Variablen + +Immer sichtbar sind Aktiv und Ladestatus. Ladestatus verwendet: + +| Wert | Bedeutung | +| ---: | --- | +| 0 | Messwert ungueltig oder unbekannt | +| 1 | Ruhezustand | +| 2 | Laden | +| 3 | Entladen | + +Die Diagnoseoption ergaenzt die gemeinsamen Verbraucherdiagnosen sowie +Ladezustand, Netzleistung, Leistungsgrenzen, Hysterese, Steuerungsmodus, +BezogeneEnergie, LeistungsangebotDiagnose und LetzterRegisterbefehl. + +## Fehlerbehandlung + +- Status 201: Konfiguration oder Variablentyp ungueltig +- Status 202: Messwert fehlt, ist veraltet oder fachlich ungueltig +- Status 203: RequestAction auf mindestens ein Register ist fehlgeschlagen + +Ein ungueltiger oder abgelaufener Sollwert wird verworfen und durch 0 ersetzt. +Die konkrete Stoerung wird im Vertrag und bei aktivierter Diagnose in +Stoerung und Stoertext gemeldet. + +## Managerkommunikation + +Die fachliche Schnittstelle ist in +[Schnittstelle Batterie](../../Schnittstelle-Batterie.md) beschrieben. +Transport und gemeinsame Felder folgen +[EMS-Schnittstelle](../../Schnittstelle.md). + +## Tests + +- BatterieReglerTest: Raster, Hysterese, PV/Peak und Herstellerabbildung +- BatterieModulstrukturTest: Metadaten, Formular, Ereignismodell und Manager-ID +- Symcon/modules/Batterie.php: reale Modulinstanz und Registeraktionen unter + IP-Symcon 8.0 diff --git a/docs/module/README.md b/docs/module/README.md index 23545a9..ab68a9b 100644 --- a/docs/module/README.md +++ b/docs/module/README.md @@ -1,6 +1,6 @@ # EMS-Module und Modulentwürfe -> Manager, Warmwassererwaermer, Verbraucher 1-Stufig, Ladestation Stand-Alone, +> Manager, Warmwassererwaermer, Batterie, Verbraucher 1-Stufig, Ladestation Stand-Alone, > Easee Gateway und Ladestation Gateway sind als installierbare IP-Symcon-Module > umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen. @@ -11,7 +11,7 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte. | Modul | Rolle | | --- | --- | | [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse (implementiert) | -| [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers | +| [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers (implementiert) | | [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung (implementiert) | | [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve | | [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) | diff --git a/libs/BatterieRegler.php b/libs/BatterieRegler.php new file mode 100644 index 0000000..17d0835 --- /dev/null +++ b/libs/BatterieRegler.php @@ -0,0 +1,279 @@ + + */ + public static function leistungsraster(float $maxLadenW, float $maxEntladenW): array + { + self::pruefeLeistungsgrenzen($maxLadenW, $maxEntladenW); + + $maxLaden = (int) floor($maxLadenW / 50.0) * 50; + $maxEntladen = (int) floor($maxEntladenW / 50.0) * 50; + $werte = [0]; + + for ($leistung = -$maxEntladen; $leistung <= 0; $leistung += 250) { + $werte[] = $leistung; + } + for ($leistung = 0; $leistung <= $maxLaden; $leistung += 250) { + $werte[] = $leistung; + } + for ($leistung = -200; $leistung <= 200; $leistung += 50) { + if ($leistung >= -$maxEntladen && $leistung <= $maxLaden) { + $werte[] = $leistung; + } + } + + $werte = array_values(array_unique($werte)); + sort($werte, SORT_NUMERIC); + + return $werte; + } + + public static function hystereseAktiv( + float $ladezustand, + float $reserve, + float $hysterese, + bool $bisherAktiv + ): bool { + self::pruefeLadezustandsgrenzen($reserve, $hysterese); + + if ($ladezustand > $reserve + $hysterese) { + return false; + } + if ($ladezustand <= $reserve) { + return true; + } + + return $bisherAktiv; + } + + /** + * Bildet das zustands- und betriebsartabhaengige Angebot aus Enelix 1 ab. + * + * @return list + */ + public static function leistungsangebot( + bool $aktiv, + bool $enelixSteuert, + bool $peakbetrieb, + float $ladezustand, + float $reserve, + float $minimum, + bool $hystereseAktiv, + float $netzleistungW, + float $istleistungW, + float $maxLadenW, + float $maxEntladenW + ): array { + self::pruefeLadezustandsgrenzen($reserve, 0.0); + if ($minimum < 0.0 || $minimum > 100.0 || $minimum > $reserve) { + throw new InvalidArgumentException( + 'Mindestladezustand muss zwischen 0 und der Reserve liegen.' + ); + } + self::pruefeLeistungsgrenzen($maxLadenW, $maxEntladenW); + + if (!$aktiv || !$enelixSteuert) { + return [0]; + } + + $raster = self::leistungsraster($maxLadenW, $maxEntladenW); + if ($peakbetrieb) { + if ($ladezustand > $reserve && !$hystereseAktiv) { + $ziel = $netzleistungW > 0.0 + ? -min($netzleistungW, $maxEntladenW) + $istleistungW + : $netzleistungW; + + return [self::begrenzeLeistung($ziel, $maxLadenW, $maxEntladenW)]; + } + if ($ladezustand > $reserve && $hystereseAktiv) { + return self::filtere($raster, static fn (int $wert): bool => $wert <= 0); + } + if ($ladezustand > $minimum) { + return $raster; + } + + return self::filtere($raster, static fn (int $wert): bool => $wert >= 0); + } + + if ($ladezustand > 99.0) { + return self::filtere($raster, static fn (int $wert): bool => $wert <= 0); + } + if ($ladezustand > $reserve && !$hystereseAktiv) { + return $raster; + } + if ($ladezustand >= $reserve && $hystereseAktiv) { + return self::filtere($raster, static fn (int $wert): bool => $wert >= 0); + } + + return [(int) round($maxLadenW)]; + } + + /** + * Liefert nur die Registerwerte, die der gewaehlte Batterietyp benoetigt. + * + * @return array + */ + public static function registerwerte( + int $batterietyp, + int $steuerung, + int $sollleistungW + ): array { + self::pruefeBatterietyp($batterietyp); + self::pruefeSteuerung($steuerung); + + $management = self::managementCode($batterietyp, $steuerung); + if ($steuerung === self::STEUERUNG_WECHSELRICHTER) { + if ($batterietyp === self::TYP_GOODWE) { + return ['Leistung' => 0, 'Management' => $management]; + } + + return [ + 'Ladeleistung' => 0, + 'Entladeleistung' => 0, + 'Management' => $management, + ]; + } + + $betrag = abs($sollleistungW); + if ($batterietyp === self::TYP_HERSTELLERUNABHAENGIG) { + return [ + 'Ladeleistung' => $sollleistungW >= 0 ? $betrag : 0, + 'Entladeleistung' => $sollleistungW < 0 ? $betrag : 0, + 'Modus' => $sollleistungW >= 0 ? 0 : 1, + 'Management' => $management, + ]; + } + if ($batterietyp === self::TYP_GOODWE) { + return [ + 'Leistung' => $betrag, + 'Modus' => $sollleistungW >= 0 ? 11 : 12, + 'Management' => $management, + ]; + } + if ($batterietyp === self::TYP_SOLAREDGE) { + return [ + 'Ladeleistung' => $sollleistungW >= 0 ? $betrag : 0, + 'Entladeleistung' => $sollleistungW < 0 ? $betrag : 0, + 'Modus' => $sollleistungW >= 0 ? 3 : 4, + 'Management' => $management, + ]; + } + + return [ + 'Ladeleistung' => $sollleistungW >= 0 ? $betrag / 1000.0 : 0.0, + 'Entladeleistung' => $sollleistungW < 0 ? $betrag / 1000.0 : 0.0, + 'Modus' => $sollleistungW >= 0 ? 3 : 6, + 'Management' => $management, + ]; + } + + /** @return list */ + public static function erforderlicheRegister(int $batterietyp): array + { + self::pruefeBatterietyp($batterietyp); + + if ($batterietyp === self::TYP_GOODWE) { + return ['Management', 'Modus', 'Leistung']; + } + + return ['Management', 'Modus', 'Ladeleistung', 'Entladeleistung']; + } + + private static function managementCode(int $batterietyp, int $steuerung): int + { + if ($steuerung === self::STEUERUNG_WECHSELRICHTER) { + return in_array( + $batterietyp, + [self::TYP_GOODWE, self::TYP_SOLAREDGE], + true + ) ? 1 : 0; + } + + if ($batterietyp === self::TYP_SOLAREDGE) { + return 4; + } + if ($batterietyp === self::TYP_GOODWE) { + return 2; + } + + return 1; + } + + /** @param list $werte + * @return list + */ + private static function filtere(array $werte, callable $filter): array + { + return array_values(array_filter($werte, $filter)); + } + + private static function begrenzeLeistung( + float $leistungW, + float $maxLadenW, + float $maxEntladenW + ): int { + return (int) round(min($maxLadenW, max(-$maxEntladenW, $leistungW))); + } + + private static function pruefeLeistungsgrenzen(float $maxLadenW, float $maxEntladenW): void + { + if ($maxLadenW < 0.0 || $maxEntladenW < 0.0) { + throw new InvalidArgumentException('Leistungsgrenzen duerfen nicht negativ sein.'); + } + } + + private static function pruefeLadezustandsgrenzen(float $reserve, float $hysterese): void + { + if ($reserve < 0.0 || $reserve > 100.0) { + throw new InvalidArgumentException('Reserve muss zwischen 0 und 100 Prozent liegen.'); + } + if ($hysterese < 0.0 || $reserve + $hysterese > 100.0) { + throw new InvalidArgumentException( + 'Hysterese muss positiv sein und mit der Reserve hoechstens 100 Prozent ergeben.' + ); + } + } + + private static function pruefeBatterietyp(int $batterietyp): void + { + if (!in_array($batterietyp, [ + self::TYP_HERSTELLERUNABHAENGIG, + self::TYP_GOODWE, + self::TYP_SOLAREDGE, + self::TYP_SIGENERGY, + ], true)) { + throw new InvalidArgumentException('Batterietyp ist nicht konfiguriert.'); + } + } + + private static function pruefeSteuerung(int $steuerung): void + { + if (!in_array($steuerung, [ + self::STEUERUNG_WECHSELRICHTER, + self::STEUERUNG_ENELIX, + ], true)) { + throw new InvalidArgumentException('Batteriesteuerung ist ungueltig.'); + } + } +} diff --git a/tests/BatterieModulstrukturTest.php b/tests/BatterieModulstrukturTest.php new file mode 100644 index 0000000..7df7eff --- /dev/null +++ b/tests/BatterieModulstrukturTest.php @@ -0,0 +1,106 @@ +", $modul); + self::assertStringNotContainsString('Timer_Do_UserCalc_Battery', $modul); + self::assertStringNotContainsString("RegisterTimer('Regelzyklus'", $modul); + self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul); + } + + public function testRegisterWerdenUeberAusgewaehlteAktionsvariablenGeschrieben(): void + { + $modul = (string) file_get_contents(self::MODULPFAD . 'module.php'); + $formular = (string) file_get_contents(self::MODULPFAD . 'form.json'); + + self::assertStringContainsString( + 'RequestAction($this->ReadPropertyInteger($property), $wert)', + $modul + ); + self::assertStringContainsString('FormBatterietyp', $modul); + self::assertStringContainsString('UpdateFormField', $modul); + foreach ([ + 'ManagementRegisterVariableID', + 'ModusRegisterVariableID', + 'LeistungsRegisterVariableID', + 'LadeleistungRegisterVariableID', + 'EntladeleistungRegisterVariableID', + ] as $property) { + self::assertStringContainsString('"name": "' . $property . '"', $formular); + } + } + + public function testLoggingDiagnoseUndAktionsbuttonsSindVorhanden(): void + { + $modul = (string) file_get_contents(self::MODULPFAD . 'module.php'); + $formular = (string) file_get_contents(self::MODULPFAD . 'form.json'); + + self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul); + self::assertStringContainsString('"name": "DiagnosevariablenAnzeigen"', $formular); + self::assertStringContainsString('Messwerte und Angebot aktualisieren', $formular); + self::assertStringContainsString('Register auf sicheren Zustand setzen', $formular); + } + + public function testManagerKenntDieBatterieModulIDGenauEinmal(): void + { + $manager = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); + + self::assertSame( + 1, + substr_count($manager, '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') + ); + } +} diff --git a/tests/BatterieReglerTest.php b/tests/BatterieReglerTest.php new file mode 100644 index 0000000..208dc93 --- /dev/null +++ b/tests/BatterieReglerTest.php @@ -0,0 +1,191 @@ + 1500, + 'Entladeleistung' => 0, + 'Modus' => 0, + 'Management' => 1, + ], + BatterieRegler::registerwerte( + BatterieRegler::TYP_HERSTELLERUNABHAENGIG, + BatterieRegler::STEUERUNG_ENELIX, + 1500 + ) + ); + self::assertSame( + [ + 'Ladeleistung' => 0, + 'Entladeleistung' => 1500, + 'Modus' => 1, + 'Management' => 1, + ], + BatterieRegler::registerwerte( + BatterieRegler::TYP_HERSTELLERUNABHAENGIG, + BatterieRegler::STEUERUNG_ENELIX, + -1500 + ) + ); + } + + public function testGoodweRegisterwerte(): void + { + self::assertSame( + ['Leistung' => 750, 'Modus' => 11, 'Management' => 2], + BatterieRegler::registerwerte( + BatterieRegler::TYP_GOODWE, + BatterieRegler::STEUERUNG_ENELIX, + 750 + ) + ); + self::assertSame( + ['Leistung' => 750, 'Modus' => 12, 'Management' => 2], + BatterieRegler::registerwerte( + BatterieRegler::TYP_GOODWE, + BatterieRegler::STEUERUNG_ENELIX, + -750 + ) + ); + self::assertSame( + ['Leistung' => 0, 'Management' => 1], + BatterieRegler::registerwerte( + BatterieRegler::TYP_GOODWE, + BatterieRegler::STEUERUNG_WECHSELRICHTER, + -750 + ) + ); + } + + public function testSolarEdgeRegisterwerte(): void + { + self::assertSame( + [ + 'Ladeleistung' => 0, + 'Entladeleistung' => 500, + 'Modus' => 4, + 'Management' => 4, + ], + BatterieRegler::registerwerte( + BatterieRegler::TYP_SOLAREDGE, + BatterieRegler::STEUERUNG_ENELIX, + -500 + ) + ); + } + + public function testSigenergySkaliertLeistungAufKilowatt(): void + { + self::assertSame( + [ + 'Ladeleistung' => 1.5, + 'Entladeleistung' => 0.0, + 'Modus' => 3, + 'Management' => 1, + ], + BatterieRegler::registerwerte( + BatterieRegler::TYP_SIGENERGY, + BatterieRegler::STEUERUNG_ENELIX, + 1500 + ) + ); + self::assertSame( + [ + 'Ladeleistung' => 0.0, + 'Entladeleistung' => 1.5, + 'Modus' => 6, + 'Management' => 1, + ], + BatterieRegler::registerwerte( + BatterieRegler::TYP_SIGENERGY, + BatterieRegler::STEUERUNG_ENELIX, + -1500 + ) + ); + } + + public function testUngueltigeGrenzenWerdenAbgewiesen(): void + { + $this->expectException(InvalidArgumentException::class); + + BatterieRegler::leistungsraster(-1.0, 1000.0); + } +} diff --git a/tests/DokumentationsstrukturTest.php b/tests/DokumentationsstrukturTest.php index 6048d1a..21a26b5 100644 --- a/tests/DokumentationsstrukturTest.php +++ b/tests/DokumentationsstrukturTest.php @@ -12,7 +12,6 @@ final class DokumentationsstrukturTest extends TestCase public function modulReadmes(): array { return [ - ['Batterie'], ['Waermepumpe'], ]; } @@ -29,6 +28,17 @@ final class DokumentationsstrukturTest extends TestCase self::assertStringContainsString('Properties', $inhalt); } + public function testBatterieDokumentiertDenImplementiertenStand(): void + { + $inhalt = file_get_contents(__DIR__ . '/../docs/module/Batterie/README.md'); + self::assertNotFalse($inhalt); + self::assertStringContainsString('Status: implementiert', $inhalt); + self::assertStringContainsString('Properties', $inhalt); + self::assertStringContainsString('Registeradapter', $inhalt); + self::assertFileExists(__DIR__ . '/../docs/Schnittstelle-Batterie.md'); + self::assertFileExists(__DIR__ . '/../docs/migration/Batterie.md'); + } + public function testPufferspeicherDokumentiertDenImplementiertenStand(): void { $inhalt = file_get_contents(__DIR__ . '/../docs/module/Pufferspeicher/README.md'); diff --git a/tests/Symcon/manifest.php b/tests/Symcon/manifest.php index 04609ac..fee8dff 100644 --- a/tests/Symcon/manifest.php +++ b/tests/Symcon/manifest.php @@ -3,6 +3,16 @@ declare(strict_types=1); return [ + 'Batterie' => [ + 'file' => __DIR__ . '/modules/Batterie.php', + 'paths' => [ + 'Batterie/*', + 'libs/BatterieRegler.php', + 'libs/VerbraucherBasisTrait.php', + 'libs/VerbraucherSchnittstelle.php', + 'libs/Nachrichtenvertrag.php', + ], + ], 'EaseeGateway' => [ 'file' => __DIR__ . '/modules/EaseeGateway.php', 'paths' => [ diff --git a/tests/Symcon/modules/Batterie.php b/tests/Symcon/modules/Batterie.php new file mode 100644 index 0000000..093c5ae --- /dev/null +++ b/tests/Symcon/modules/Batterie.php @@ -0,0 +1,180 @@ +createVariable( + $typ, + $name, + $typ === 1 ? 0 : 0.0 + ); + $skriptID = IPS_CreateScript(0); + IPS_SetParent($skriptID, $test->rootId()); + IPS_SetName($skriptID, $name . ' Aktion'); + IPS_SetHidden($skriptID, true); + IPS_SetScriptContent($skriptID, <<<'PHP' +createVariable(2, 'Max Laden', 2000.0); + $maxEntladenID = $test->createVariable(2, 'Max Entladen', 2000.0); + $ladezustandID = $test->createVariable(2, 'Ladezustand', 50.0); + $netzleistungID = $test->createVariable(2, 'Netzleistung', 0.0); + $istleistungID = $test->createVariable(2, 'Batterieleistung', 0.0); + $managementID = $numerischesRegister($test, 'Management', 1); + $modusID = $numerischesRegister($test, 'Modus', 1); + $leistungID = $numerischesRegister($test, 'Leistung', 2); + + $batterieID = $test->createInstance( + '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}', + 'Batterie GoodWe', + [ + 'Batterietyp' => 2, + 'Batteriemanagement' => 2, + 'MaxLadeleistungVariableID' => $maxLadenID, + 'MaxEntladeleistungVariableID' => $maxEntladenID, + 'LadezustandVariableID' => $ladezustandID, + 'NetzleistungVariableID' => $netzleistungID, + 'IstleistungVariableID' => $istleistungID, + 'ManagementRegisterVariableID' => $managementID, + 'ModusRegisterVariableID' => $modusID, + 'LeistungsRegisterVariableID' => $leistungID, + 'ReserveLadezustand' => 20.0, + 'MindestLadezustand' => 10.0, + 'LadezustandHysterese' => 2.0, + 'MesswertMaxAlter' => 120, + 'Aenderungssperre' => 0, + 'DiagnosevariablenAnzeigen' => true, + ] + ); + $managerID = $test->createInstance( + '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}', + 'Hilfsmanager Batterie', + [ + 'AutomatischeSuche' => false, + 'VerbraucherZuordnung' => json_encode([ + ['InstanzID' => $batterieID, 'Aktiv' => true], + ], JSON_THROW_ON_ERROR), + ] + ); + + $managerdatenSenden = static function ( + string $betriebsart, + ?int $leistung + ) use ($managerID, $batterieID): void { + IPS_RequestAction($batterieID, 'ManagerdatenEmpfangen', json_encode([ + 'Kopf' => [ + 'Version' => '4.0', + 'AbsenderID' => $managerID, + 'EmpfaengerID' => $batterieID, + 'Zeitpunkt' => time(), + ], + 'Betriebsart' => $betriebsart, + 'Sollleistung_W' => $leistung, + ], JSON_THROW_ON_ERROR)); + }; + + $test->runCase( + 'Instanz und Diagnosevariablen werden angelegt', + static function (TestContext $test) use ($batterieID): void { + $test->assertInstanceStatus($batterieID); + foreach ([ + 'Aktiv', + 'Ladestatus', + 'Istleistung', + 'Sollleistung', + 'Ladezustand', + 'LadezustandGueltig', + 'HystereseAktiv', + 'LeistungsangebotDiagnose', + 'LetzterRegisterbefehl', + ] as $ident) { + $test->objectByIdent($ident, $batterieID); + } + } + ); + + $test->runCase( + 'GoodWe-Laden schreibt Leistung Modus und Management direkt', + static function (TestContext $test) use ( + $batterieID, + $managerdatenSenden, + $managementID, + $modusID, + $leistungID + ): void { + IPS_RequestAction($batterieID, 'Aktiv', true); + $managerdatenSenden('PV', null); + $managerdatenSenden('PV', 500); + + $test->waitUntil( + static fn (): bool => (float) GetValue($leistungID) === 500.0 + && (int) GetValue($modusID) === 11 + && (int) GetValue($managementID) === 2 + ); + } + ); + + $test->runCase( + 'GoodWe-Entladen verwendet negativen Managerwert und Modus 12', + static function (TestContext $test) use ( + $managerdatenSenden, + $modusID, + $leistungID + ): void { + $managerdatenSenden('PV', -500); + $test->waitUntil( + static fn (): bool => (float) GetValue($leistungID) === 500.0 + && (int) GetValue($modusID) === 12 + ); + } + ); + + $test->runCase( + 'Istleistungsereignis aktualisiert den Ladestatus ohne Regelzyklus', + static function (TestContext $test) use ( + $batterieID, + $istleistungID + ): void { + SetValue($istleistungID, -250.0); + $ladestatusID = $test->objectByIdent('Ladestatus', $batterieID); + $test->waitUntil( + static fn (): bool => (int) GetValue($ladestatusID) === 3 + ); + + SetValue($istleistungID, 250.0); + $test->waitUntil( + static fn (): bool => (int) GetValue($ladestatusID) === 2 + ); + } + ); + + $test->runCase( + 'Wechselrichtersteuerung setzt Leistung auf null und Automatikmodus', + static function (TestContext $test) use ( + $batterieID, + $managementID, + $leistungID + ): void { + $test->configureInstance($batterieID, ['Batteriemanagement' => 1]); + $test->waitUntil( + static fn (): bool => (float) GetValue($leistungID) === 0.0 + && (int) GetValue($managementID) === 1 + ); + } + ); +}; diff --git a/tests/SymconTestContractTest.php b/tests/SymconTestContractTest.php index 3c66886..abf8021 100644 --- a/tests/SymconTestContractTest.php +++ b/tests/SymconTestContractTest.php @@ -59,6 +59,7 @@ final class SymconTestContractTest extends TestCase { self::assertSame( [ + 'Batterie', 'LadestationGateway', 'LadestationStandAlone', 'Manager', @@ -82,6 +83,7 @@ final class SymconTestContractTest extends TestCase { self::assertSame( [ + 'Batterie', 'EaseeGateway', 'LadestationGateway', 'LadestationStandAlone',