81 Commits
Author SHA1 Message Date
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 3cabf597ac fix(ladestation): Solarpause nicht als Ladeende erkennen 2026-09-29 09:07:56 +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 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
dh 18c34b32e7 chore(release): erste Beta vorbereiten
Tests / test (push) Successful in 52s
2026-09-29 06:25:26 +00:00
dh b111a5c501 Ladefreigabe für Ladestationen standardmäßig aktivieren
Tests / test (push) Successful in 55s
2026-09-29 06:24:26 +00:00
dh ef708564d8 feat: Wärmepumpenlizenz separat freischalten
Tests / test (push) Successful in 47s
2026-09-29 05:53:15 +00:00
dh cf2ce3aff4 feat(manager): Lizenzierung und Energietopologie verfeinern
Tests / test (push) Successful in 46s
2026-09-28 17:47:22 +00:00
dh 708a8ecea2 feat(manager): Energieaufzeichnung und Visualisierungen integrieren
Tests / test (push) Successful in 47s
2026-09-27 19:29:37 +00:00
dh 40f41a2fb8 fix(manager): Störungszugang zuverlässig aktivieren
Tests / test (push) Successful in 52s
2026-09-27 16:39:15 +00:00
dh 2d24ce0900 feat(manager): Störungsüberwachung ans Portal anbinden
Tests / test (push) Successful in 47s
2026-09-27 15:41:15 +00:00
dh f47c908798 feat(batterie): stufenlose Leistungsbereiche unterstützen
Tests / test (push) Successful in 49s
2026-09-27 08:46:17 +00:00
dh 72d96aeb81 feat: Einspeisebegrenzung in Demo simulieren
Tests / test (push) Successful in 46s
2026-09-26 10:09:10 +00:00
dh dcf51bd8be feat: anlagenweite Einspeisebegrenzung und Netzfahrplan
Tests / test (push) Successful in 50s
2026-09-26 09:09:08 +00:00
dh efa8fe698c feat: Prognosetelemetrie aus Demoanlage senden
Tests / test (push) Successful in 48s
2026-09-23 06:40:17 +00:00
dh f250dea59a feat: Demoanlage an Prognosetopologie anbinden
Tests / test (push) Successful in 49s
2026-09-23 05:46:07 +00:00
dh 1e6481330a feat: Anlagentopologie mit Prognoseportal synchronisieren
Tests / test (push) Successful in 45s
2026-09-23 04:36:59 +00:00
dh dbd728dd03 feat: Anlagentopologie im Manager abbilden 2026-09-22 17:11:12 +00:00
dh a67018e68f fix: Waermepumpe ohne Vorgabe normal betreiben
Tests / test (push) Successful in 45s
2026-09-22 15:13:04 +00:00
dh 0f1e0c9fbb Installations-ID bei Manager-Updates erhalten
Tests / test (push) Successful in 45s
2026-09-22 15:06:31 +00:00
dh 693beb2263 docs: Projektstatus und Einrichtungsentscheidung aktualisieren
Tests / test (push) Successful in 49s
2026-09-22 14:43:36 +00:00
dh b1cb289922 docs: Ladestation Stand-Alone vollständig dokumentieren 2026-09-22 14:43:36 +00:00
dh 98ee569178 feat: Demoanlage um Batterie und Tagesprofile erweitern 2026-09-22 14:43:36 +00:00
dh 2392a9c209 feat: Lizenzkontingente im Manager durchsetzen 2026-09-22 14:43:36 +00:00
dh 7845c66fd8 feat: Wärmepumpenmodul integrieren
Tests / test (push) Successful in 49s
2026-09-22 14:34:23 +00:00
dh d7308663da feat: Batteriemodul ereignisbasiert integrieren
Tests / test (push) Successful in 45s
2026-09-22 09:27:49 +00:00
dh d03a7fd8fb Easee-Gateway und Ladestationsmodul implementieren
Tests / test (push) Successful in 44s
2026-09-22 07:09:59 +00:00
dh d804ba4bc1 Manager-Lizenzierung dokumentieren
Tests / test (push) Successful in 45s
2026-09-22 07:07:29 +00:00
dh e7c546ff2d docs: README fuer Verbraucher 1-Stufig ergaenzen 2026-09-22 07:04:41 +00:00
dh e6516f5d7a docs: add field-test location decision
Tests / test (push) Successful in 43s
2026-09-22 05:17:58 +00:00
dh 8ae1f1ea8a docs: add open product decisions
Tests / test (push) Successful in 47s
2026-09-22 05:03:49 +00:00
dh 3c71626b5f Verbrauchermodule und betriebsartabhängige Angebote ergänzen
Tests / test (push) Successful in 42s
2026-09-21 18:46:17 +00:00
dh 7b6d1b2f07 Manager-Lizenzierung integrieren
Tests / test (push) Successful in 1m7s
2026-09-20 14:21:11 +00:00
dh cf1ae5e348 Fix native demo tiles
Tests / test (push) Successful in 44s
2026-09-17 14:34:24 +00:00
dh 3edf724df7 Use explicit demo timer action
Tests / test (push) Successful in 39s
2026-09-17 14:11:00 +00:00
dh 7cf67d4392 Erweitere explizite EMS-Modultests
Tests / test (push) Successful in 42s
2026-09-17 14:07:52 +00:00
dh abd8c7edeb Enhance demo visualization and charts
Tests / test (push) Successful in 47s
2026-09-17 14:06:12 +00:00
dh 312b76aee5 Add interactive Enelix demo system
Tests / test (push) Successful in 42s
2026-09-17 13:41:46 +00:00
dh 4a01e9df0f Standardisiere Symcon-Modultests
Tests / test (push) Successful in 40s
2026-09-17 12:36:37 +00:00
dh 66966875e7 docs: falsch abgelegten VGT-Punkt entfernen
Tests / test (push) Successful in 42s
2026-09-17 12:14:50 +00:00
dh 51ce9b339e docs: VGT-Verfügbarkeit für Beta vormerken
Tests / test (push) Successful in 41s
2026-09-17 12:05:39 +00:00
dh 37822f0ed0 docs: add intake quality gate
Tests / test (push) Successful in 41s
2026-09-17 11:53:22 +00:00
dh cda579ab86 docs: simplify license server decision
Tests / test (push) Successful in 42s
2026-09-17 11:45:48 +00:00
dh d874ec0a3b docs: add central open decisions list
Tests / test (push) Successful in 43s
2026-09-17 11:23:54 +00:00
dh 5c5ef73fb8 Verbraucher 1-Stufig um Mindestzeiten erweitern
Tests / test (push) Successful in 42s
2026-09-17 10:23:39 +00:00
dh 49fe18c2eb Wassererwaermer umfassend dokumentieren
Tests / test (push) Failing after 41s
2026-09-17 09:55:28 +00:00
dh 052685401f Automatische Verbrauchererkennung absichern
Tests / test (push) Failing after 37s
2026-09-17 09:46:01 +00:00
dh ecbd0a3baf Doppelte Verbraucher-Modul-ID entfernen
Tests / test (push) Failing after 39s
2026-09-17 09:44:44 +00:00
dh b404de4095 Modulstatus des einstufigen Verbrauchers testen
Tests / test (push) Failing after 38s
2026-09-17 09:43:43 +00:00
dh 9598a287f8 Modulstatus und Anleitung vervollstaendigen 2026-09-17 09:41:46 +00:00
dh 6c2d6ddcde Lastwechselsperre nur ueber Vertrag melden
Tests / test (push) Failing after 39s
2026-09-17 09:33:40 +00:00
dh d4828f1ee5 Verbraucher 1-Stufig eventbasiert implementieren
Tests / test (push) Failing after 39s
2026-09-17 09:32:17 +00:00
dh 29cfd8ceaa Manager-Diagnose und Verbrauchersuche verbessern
Tests / test (push) Failing after 40s
2026-09-17 09:30:26 +00:00
dh 92e8ec59df Leistungsangebot nach Lastwechsel sperren
Tests / test (push) Failing after 47s
2026-09-17 09:16:52 +00:00
dh 355e7163fb Lastwechsel ereignisbasiert sperren
Tests / test (push) Failing after 39s
2026-09-17 09:14:15 +00:00
dh 1b4a788f22 Manager-Konfiguration neu strukturieren
Tests / test (push) Failing after 46s
2026-09-17 08:55:43 +00:00
dh 9e6a675b25 Diagnosevariablen bedarfsgesteuert anlegen
Tests / test (push) Successful in 43s
2026-09-17 08:54:17 +00:00
dh 4514ccb557 Wassererwaermer-Konfiguration vereinfachen
Tests / test (push) Successful in 46s
2026-09-17 08:29:49 +00:00
dh 9869d99641 Modulalias für Wassererwärmer bereinigen
Tests / test (push) Successful in 40s
2026-09-17 08:06:08 +00:00
dh 98fb926fbd Modulhersteller und Anzeigenamen vereinheitlichen
Tests / test (push) Successful in 1m7s
2026-09-17 07:38:09 +00:00
dh 7a3c436039 Verbraucher Warmwassererwaermer implementieren
Tests / test (push) Canceled after 0s
2026-09-16 05:18:01 +00:00
dh 0a3ece3a81 Enelix-2-Manager implementieren
Tests / test (push) Canceled after 0s
Installierbares IP-Symcon-Manager-Modul mit testbarer Verteilungslogik, Verbraucher-Timeouts, Sollwertversand und aktualisierter Dokumentation.
2026-09-15 16:23:45 +00:00
dh 21a6466ca5 Dokumentation aktualisieren: libs/VerbraucherSchnittstelle.php
Tests / test (push) Canceled after 0s
2026-09-10 08:03:55 +00:00
dh 37304691eb Dokumentation aktualisieren: libs/ManagerSchnittstelle.php
Tests / test (push) Canceled after 0s
2026-09-10 08:03:49 +00:00
dh 8af7776f33 Dokumentation aktualisieren: docs/Schnittstelle.md
Tests / test (push) Canceled after 0s
2026-09-10 08:03:44 +00:00
dh 0c9d4d920a Dokumentation aktualisieren: README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:03:38 +00:00
dh dcd310498d Entwurf: tests/VerbraucherBasisTraitTest.php
Tests / test (push) Canceled after 0s
2026-09-10 08:03:05 +00:00
dh c1fdd37b2b Entwurf: tests/DokumentationsstrukturTest.php
Tests / test (push) Canceled after 0s
2026-09-10 08:02:59 +00:00
dh 8439213234 Entwurf: libs/VerbraucherBasisTrait.php
Tests / test (push) Canceled after 0s
2026-09-10 08:02:53 +00:00
dh efda77ccad Entwurf: docs/module/Easee-Gateway/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:02:48 +00:00
dh 69da93270e Entwurf: docs/module/Ladestation-Gateway/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:02:42 +00:00
dh b2779148ee Entwurf: docs/module/Ladestation-Stand-Alone/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:02:37 +00:00
dh f10d454eb7 Entwurf: docs/module/Waermepumpe/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:02:31 +00:00
dh 9426b0f041 Entwurf: docs/module/Verbraucher-1-Stufig/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:02:26 +00:00
dh 1541482bf6 Entwurf: docs/module/Pufferspeicher/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:02:20 +00:00
dh 9acbff610d Entwurf: docs/module/Wassererwaermer/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:02:15 +00:00
dh 9aab4abdb3 Entwurf: Batterie
Tests / test (push) Canceled after 0s
2026-09-10 08:01:36 +00:00
dh 0f9860356d Entwurf: docs/module/Manager/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:00:35 +00:00
dh bd47faa593 Entwurf: docs/module/README.md
Tests / test (push) Canceled after 0s
2026-09-10 07:59:49 +00:00
dh 578285b0d6 Obere Manager-Anschlüsse dokumentieren
Tests / test (push) Canceled after 0s
2026-09-10 07:58:55 +00:00
130 changed files with 28368 additions and 50 deletions
+7 -1
View File
@@ -21,6 +21,12 @@ jobs:
- name: Abhaengigkeiten installieren - name: Abhaengigkeiten installieren
run: composer install --no-interaction --no-progress --prefer-dist run: composer install --no-interaction --no-progress --prefer-dist
- name: Pruefungen ausfuehren - name: Composer-Metadaten pruefen
run: composer validate --strict
- name: Abhaengigkeiten auf Sicherheitsmeldungen pruefen
run: composer audit --no-interaction
- name: Unit-Tests und Syntaxpruefung ausfuehren
run: composer check run: composer check
+1
View File
@@ -1,3 +1,4 @@
/vendor/ /vendor/
/.phpunit.cache/ /.phpunit.cache/
/.phpunit.result.cache /.phpunit.result.cache
/build/
+174
View File
@@ -0,0 +1,174 @@
# 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
- stufenlose Leistungsbereiche in ganzen Watt innerhalb der dynamischen Grenzen
- 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 der durchgaengige Bereich von der
maximalen Entlade- bis zur maximalen Ladeleistung zur Verfuegung.
- Unterhalb der Reserve wird die maximale Ladeleistung als fester Wert angeboten.
- Bei vollem Speicher wird der durchgaengige Bereich von der maximalen
Entladeleistung bis 0 W 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)
+229
View File
@@ -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"}
]
}
+14
View File
@@ -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"
}
+1176
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
# Changelog
Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
## 0.1 Build 1 - Erste Beta - 2026-09-29
### Enthalten
- Enelix Manager mit PV- und Peak-Betrieb, Priorisierung und Diagnose.
- Anlagenweite Einspeisebegrenzung, Netzfahrplan und Prognosetopologie.
- Batterie, Warmwassererwaermer, Pufferspeicher und Waermepumpe.
- Verbraucher 1-Stufig sowie Easee Gateway und beide Ladestationsvarianten.
- Lizenzpruefung fuer Manager und einzeln freischaltbare Verbrauchermodule.
- Idempotente Demoanlage fuer Installation und Integrationstests.
### Beta-Aenderungen
- Stufenlose Leistungsbereiche fuer Batteriespeicher.
- Ladefreigabe neuer Ladestationsinstanzen standardmaessig aktiviert.
- Easee-Ladestationen laden einen fehlenden Initialstatus ueber die Observations-API nach und starten bei noch unbekannter Ausgangsphase konservativ einphasig.
- Fehlende oder veraltete Verbraucher blockieren die Verteilung an aktuelle Verbraucher nicht mehr.
- Die Stoerungsueberwachung fordert einen fehlenden Geraetezugang innerhalb ihres Retry-Zyklus automatisch neu an.
- Vollstaendige PHP-, Struktur- und Symcon-Modultests fuer alle Module.
### Hinweise
- Diese Version ist fuer kontrollierte Anlagen- und Feldtests bestimmt.
- Vor produktivem Einsatz sind Sicherung und anlagenspezifische Abnahme erforderlich.
- Korrekturen werden vorwaerts ueber `develop` und anschliessend `beta` verteilt.
+11
View File
@@ -0,0 +1,11 @@
# Easee Gateway
Gemeinsames IP-Symcon-Splittermodul fuer ein Easee-Nutzerkonto. Es verwaltet
Anmeldung, Token, Ereignisverbindung und REST-Stromvorgaben fuer mehrere
Ladestationen.
Konfiguration, Betrieb und Fehlerbehandlung:
[Moduldokumentation](../docs/module/Easee-Gateway/README.md)
Kind-Gateway-Vertrag:
[Schnittstellenbeschreibung](../docs/Schnittstelle-Easee-Gateway.md)
+52
View File
@@ -0,0 +1,52 @@
{
"elements": [
{
"type": "CheckBox",
"name": "Active",
"caption": "Gateway aktiv"
},
{
"type": "ValidationTextBox",
"name": "Username",
"caption": "Easee Benutzername"
},
{
"type": "PasswordTextBox",
"name": "Password",
"caption": "Easee Passwort"
},
{
"type": "CheckBox",
"name": "VerifyCertificate",
"caption": "TLS-Zertifikat pruefen"
},
{
"type": "Label",
"caption": "Ein Gateway wird von allen Easee-Ladestationen desselben Kontos gemeinsam verwendet."
}
],
"actions": [
{
"type": "Button",
"caption": "Verbindung neu aufbauen",
"onClick": "IPS_RequestAction($id, \"Reconnect\", false);"
}
],
"status": [
{
"code": 201,
"icon": "error",
"caption": "Easee-Zugangsdaten fehlen"
},
{
"code": 202,
"icon": "error",
"caption": "Easee-Anmeldung fehlgeschlagen"
},
{
"code": 203,
"icon": "error",
"caption": "Easee-Ereignisverbindung fehlgeschlagen"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"id": "{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}",
"name": "EaseeGateway",
"type": 2,
"vendor": "Enelix",
"aliases": [
"Easee Gateway"
],
"parentRequirements": [
"{79827379-F36E-4ADA-8A95-5F8D1DC92FA9}"
],
"childRequirements": [
"{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}"
],
"implemented": [
"{018EF6B5-AB94-40C6-AA53-46943E824ACF}",
"{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}"
],
"prefix": "ENELIXEASEE",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/EaseeGateway"
}
+855
View File
@@ -0,0 +1,855 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/EaseeGatewayProtokoll.php';
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
class EaseeGateway extends IPSModule
{
private const WEBSOCKET_MODULE_ID = '{D68FD31F-0E90-7019-F16C-1949BD3079EF}';
private const SIMPLE_RX_DATA_ID = '{018EF6B5-AB94-40C6-AA53-46943E824ACF}';
private const SIMPLE_TX_DATA_ID = '{79827379-F36E-4ADA-8A95-5F8D1DC92FA9}';
private const CHILD_REQUEST_DATA_ID = '{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}';
private const CHILD_EVENT_DATA_ID = '{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}';
private const SIGNALR_BASE_URL = 'https://streams.easee.com/hubs/chargers';
private const API_BASE_URL = 'https://api.easee.com';
private const RECORD_SEPARATOR = "\x1e";
public function Create(): void
{
parent::Create();
$this->RegisterPropertyBoolean('Active', true);
$this->RegisterPropertyString('Username', '');
$this->RegisterPropertyString('Password', '');
$this->RegisterPropertyBoolean('VerifyCertificate', true);
// Nur fuer den automatisierten IP-Symcon-Funktionstest.
$this->RegisterPropertyBoolean('Testmodus', false);
$this->RegisterAttributeString('ObservationCache', '{}');
$this->RegisterVariableBoolean('Connected', 'Easee verbunden', '~Switch', 10);
$this->RegisterVariableInteger(
'SubscriptionCount',
'Angemeldete Ladestationen',
'',
20
);
$this->RegisterVariableString('LastError', 'Letzter Fehler', '', 30);
$this->RegisterTimer(
'MaintainConnectionTimer',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'MaintainConnection', false);"
);
$this->RegisterTimer(
'TokenRefreshTimer',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'RefreshToken', false);"
);
$this->RequireParent(self::WEBSOCKET_MODULE_ID);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->SetTimerInterval('MaintainConnectionTimer', 0);
$this->SetTimerInterval('TokenRefreshTimer', 0);
$this->SetBuffer('SignalRReady', '0');
$this->SetBuffer('ReceiveBuffer', '');
$this->SetBuffer('Subscriptions', '{}');
$this->SetBuffer('SubscribedThisConnection', '{}');
$this->SetBuffer('PendingInvocations', '{}');
$this->SetValue('SubscriptionCount', 0);
$this->setzeVerbunden(false);
if (!$this->ReadPropertyBoolean('Active')) {
$this->SetStatus(104);
return;
}
if ($this->ReadPropertyBoolean('Testmodus')) {
$this->SetStatus(102);
$this->setzeLetztenFehler('');
$this->setzeVerbunden(true);
return;
}
if (
trim($this->ReadPropertyString('Username')) === ''
|| $this->ReadPropertyString('Password') === ''
) {
$this->SetStatus(201);
$this->setzeLetztenFehler('Easee-Benutzername oder Passwort fehlt.');
return;
}
if (!$this->stelleWebSocketParentSicher()) {
$this->SetStatus(203);
return;
}
$this->SetTimerInterval('MaintainConnectionTimer', 10000);
$this->SetTimerInterval('TokenRefreshTimer', 1800000);
if (!$this->erneuereZugangsdaten(false)) {
$this->SetStatus(202);
return;
}
$this->verbindeSignalR();
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'MaintainConnection':
$this->pflegeVerbindung();
return;
case 'RefreshToken':
if ($this->erneuereZugangsdaten(false)) {
$this->verbindeSignalR();
}
return;
case 'Reconnect':
$this->SetBuffer('AccessToken', '');
$this->SetBuffer('RefreshToken', '');
if (
$this->ReadPropertyBoolean('Testmodus')
|| $this->erneuereZugangsdaten(false)
) {
$this->verbindeSignalR();
}
return;
case 'TestObservation':
if (!$this->ReadPropertyBoolean('Testmodus') || !is_string($wert)) {
throw new InvalidArgumentException('TestObservation ist nur im Testmodus zulaessig.');
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException('TestObservation erwartet ein JSON-Objekt.');
}
$this->veroeffentlicheBeobachtung(
EaseeGatewayProtokoll::seriennummer((string) ($daten['serialNumber'] ?? '')),
(int) ($daten['id'] ?? 0),
$daten['value'] ?? null
);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
public function GetConfigurationForParent(): string
{
return json_encode([
'Active' => $this->ReadPropertyBoolean('Active')
&& !$this->ReadPropertyBoolean('Testmodus'),
'URL' => $this->GetBuffer('WebSocketURL'),
'VerifyCertificate' => $this->ReadPropertyBoolean('VerifyCertificate'),
'Headers' => '[]',
], JSON_THROW_ON_ERROR);
}
public function ReceiveData($jsonString): void
{
$paket = json_decode((string) $jsonString, true);
if (!is_array($paket) || !isset($paket['Buffer'])) {
return;
}
$this->SetBuffer('LastReceive', (string) time());
$puffer = $this->GetBuffer('ReceiveBuffer') . (string) $paket['Buffer'];
$rahmen = explode(self::RECORD_SEPARATOR, $puffer);
$this->SetBuffer('ReceiveBuffer', (string) array_pop($rahmen));
foreach ($rahmen as $eintrag) {
if ($eintrag !== '') {
$this->verarbeiteSignalRRahmen($eintrag);
}
}
}
public function ForwardData($jsonString): string
{
$paket = json_decode((string) $jsonString, true);
if (!is_array($paket) || !isset($paket['Buffer'])) {
return json_encode([
'success' => false,
'error' => 'Ungueltiges Datenpaket.',
], JSON_THROW_ON_ERROR);
}
return $this->ProcessStationRequest((string) $paket['Buffer']);
}
public function ProcessStationRequest($jsonString): string
{
$anfrage = json_decode((string) $jsonString, true);
if (!is_array($anfrage) || !isset($anfrage['action'])) {
return json_encode([
'success' => false,
'error' => 'Ungueltige Gateway-Anfrage.',
], JSON_THROW_ON_ERROR);
}
$seriennummer = EaseeGatewayProtokoll::seriennummer(
(string) ($anfrage['serialNumber'] ?? '')
);
switch ($anfrage['action']) {
case 'Subscribe':
case 'GetState':
if ($seriennummer === '') {
return json_encode([
'success' => false,
'error' => 'Seriennummer fehlt.',
], JSON_THROW_ON_ERROR);
}
$this->registriereAbonnement($seriennummer);
return $this->erstelleStatusantwort($seriennummer);
case 'SetDynamicChargerCurrent':
return json_encode($this->setzeDynamischenLadestrom(
$seriennummer,
(float) ($anfrage['amps'] ?? -1)
), JSON_THROW_ON_ERROR);
}
return json_encode([
'success' => false,
'error' => 'Unbekannte Gateway-Aktion.',
], JSON_THROW_ON_ERROR);
}
private function stelleWebSocketParentSicher(): bool
{
$instanz = IPS_GetInstance($this->InstanceID);
$parentID = (int) $instanz['ConnectionID'];
if ($parentID <= 0 && !$this->RequireParent(self::WEBSOCKET_MODULE_ID)) {
$this->setzeLetztenFehler('WebSocket-Client konnte nicht erstellt werden.');
return false;
}
$instanz = IPS_GetInstance($this->InstanceID);
$parentID = (int) $instanz['ConnectionID'];
if ($parentID <= 0 || !IPS_InstanceExists($parentID)) {
$this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.');
return false;
}
$parent = IPS_GetInstance($parentID);
if ($parent['ModuleInfo']['ModuleID'] !== self::WEBSOCKET_MODULE_ID) {
$this->setzeLetztenFehler('Ungueltige WebSocket-Schnittstelle.');
return false;
}
return true;
}
private function pflegeVerbindung(): void
{
if (!$this->ReadPropertyBoolean('Active') || $this->ReadPropertyBoolean('Testmodus')) {
return;
}
$jetzt = time();
$letzteAushandlung = (int) $this->GetBuffer('LastNegotiation');
$letzterEmpfang = (int) $this->GetBuffer('LastReceive');
$bereit = $this->GetBuffer('SignalRReady') === '1';
if ($bereit) {
$this->sendeSignalRRahmen(['type' => 6]);
if ($letzterEmpfang > 0 && ($jetzt - $letzterEmpfang) <= 90) {
return;
}
$this->SetBuffer('SignalRReady', '0');
$this->setzeVerbunden(false);
}
if (($jetzt - $letzteAushandlung) >= 45) {
$this->verbindeSignalR();
return;
}
$this->sendeHandshake();
}
private function verbindeSignalR(): bool
{
if ($this->ReadPropertyBoolean('Testmodus')) {
$this->SetStatus(102);
$this->setzeVerbunden(true);
return true;
}
if (!$this->stelleAccessTokenSicher()) {
$this->SetStatus(202);
return false;
}
$this->SetBuffer('LastNegotiation', (string) time());
$token = $this->GetBuffer('AccessToken');
$antwort = $this->httpAnfrage(
'POST',
self::SIGNALR_BASE_URL . '/negotiate?negotiateVersion=1',
'',
$token
);
if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) {
$token = $this->GetBuffer('AccessToken');
$antwort = $this->httpAnfrage(
'POST',
self::SIGNALR_BASE_URL . '/negotiate?negotiateVersion=1',
'',
$token
);
}
if (!$antwort['success']) {
$this->SetStatus(203);
$this->setzeLetztenFehler(
'Easee-Ereignisverbindung fehlgeschlagen: HTTP '
. $antwort['httpCode']
. ($antwort['error'] !== '' ? ' / ' . $antwort['error'] : '')
);
return false;
}
$daten = json_decode($antwort['body'], true);
$verbindungstoken = is_array($daten)
? (string) ($daten['connectionToken'] ?? $daten['connectionId'] ?? '')
: '';
if ($verbindungstoken === '') {
$this->SetStatus(203);
$this->setzeLetztenFehler('Easee liefert kein Verbindungstoken.');
return false;
}
$url = 'wss://streams.easee.com/hubs/chargers?id='
. rawurlencode($verbindungstoken)
. '&access_token=' . rawurlencode($token);
$this->SetBuffer('WebSocketURL', $url);
$this->SetBuffer('SignalRReady', '0');
$this->SetBuffer('ReceiveBuffer', '');
$this->SetBuffer('SubscribedThisConnection', '{}');
$this->SetBuffer('PendingInvocations', '{}');
$this->WriteAttributeString('ObservationCache', '{}');
$this->setzeVerbunden(false);
if (!$this->konfiguriereWebSocketParent($url)) {
$this->SetStatus(203);
return false;
}
$this->sendeHandshake();
return true;
}
private function konfiguriereWebSocketParent(string $url): bool
{
$parentID = (int) IPS_GetInstance($this->InstanceID)['ConnectionID'];
if ($parentID <= 0) {
$this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.');
return false;
}
IPS_SetProperty($parentID, 'Active', true);
IPS_SetProperty($parentID, 'URL', $url);
IPS_SetProperty(
$parentID,
'VerifyCertificate',
$this->ReadPropertyBoolean('VerifyCertificate')
);
IPS_SetProperty($parentID, 'Headers', '[]');
IPS_ApplyChanges($parentID);
return true;
}
private function sendeHandshake(): void
{
$this->sendeRohdaten(
json_encode(['protocol' => 'json', 'version' => 1], JSON_THROW_ON_ERROR)
. self::RECORD_SEPARATOR
);
}
/** @param array<string, mixed> $rahmen */
private function sendeSignalRRahmen(array $rahmen): void
{
$this->sendeRohdaten(
json_encode($rahmen, JSON_THROW_ON_ERROR) . self::RECORD_SEPARATOR
);
}
private function sendeRohdaten(string $nutzdaten): void
{
if ($this->ReadPropertyBoolean('Testmodus')) {
return;
}
$this->SendDataToParent(json_encode([
'DataID' => self::SIMPLE_TX_DATA_ID,
'Buffer' => $nutzdaten,
], JSON_THROW_ON_ERROR));
}
private function verarbeiteSignalRRahmen(string $rahmen): void
{
if ($rahmen === '{}') {
$this->SetBuffer('SignalRReady', '1');
$this->SetStatus(102);
$this->setzeLetztenFehler('');
$this->setzeVerbunden(true);
$this->sendeAlleAbonnements();
return;
}
$nachricht = json_decode($rahmen, true);
if (!is_array($nachricht)) {
$this->setzeLetztenFehler('Ungueltige Easee-Ereignisnachricht.');
return;
}
if (isset($nachricht['error'])) {
$this->SetStatus(203);
$this->setzeLetztenFehler('Easee-Ereignisfehler: ' . $nachricht['error']);
return;
}
$typ = (int) ($nachricht['type'] ?? 0);
if ($typ === 6) {
return;
}
if ($typ === 7) {
$this->SetBuffer('SignalRReady', '0');
$this->setzeVerbunden(false);
$this->setzeLetztenFehler('Easee-Ereignisverbindung wurde beendet.');
return;
}
$seriennummer = '';
if ($typ === 3 && isset($nachricht['invocationId'])) {
$offen = $this->lesePufferArray('PendingInvocations');
$aufrufID = (string) $nachricht['invocationId'];
$seriennummer = (string) ($offen[$aufrufID] ?? '');
unset($offen[$aufrufID]);
$this->SetBuffer(
'PendingInvocations',
json_encode($offen, JSON_THROW_ON_ERROR)
);
}
foreach (['arguments', 'result'] as $feld) {
if (!isset($nachricht[$feld])) {
continue;
}
$abonnements = array_keys($this->lesePufferArray('Subscriptions'));
foreach (EaseeGatewayProtokoll::extrahiereBeobachtungen(
$nachricht[$feld],
$abonnements,
$seriennummer
) as $beobachtung) {
$this->veroeffentlicheBeobachtung(
$beobachtung['Seriennummer'],
$beobachtung['ID'],
$beobachtung['Wert']
);
}
}
}
private function registriereAbonnement(string $seriennummer): void
{
$abonnements = $this->lesePufferArray('Subscriptions');
if (!isset($abonnements[$seriennummer])) {
$abonnements[$seriennummer] = true;
$this->SetBuffer(
'Subscriptions',
json_encode($abonnements, JSON_THROW_ON_ERROR)
);
$this->SetValue('SubscriptionCount', count($abonnements));
}
if ($this->GetBuffer('SignalRReady') === '1') {
$this->sendeAbonnement($seriennummer);
}
}
private function sendeAlleAbonnements(): void
{
foreach (array_keys($this->lesePufferArray('Subscriptions')) as $seriennummer) {
$this->sendeAbonnement((string) $seriennummer);
}
}
private function sendeAbonnement(string $seriennummer): void
{
$gesendet = $this->lesePufferArray('SubscribedThisConnection');
if (isset($gesendet[$seriennummer])) {
return;
}
$aufrufID = (string) (((int) $this->GetBuffer('InvocationID')) + 1);
$this->SetBuffer('InvocationID', $aufrufID);
$offen = $this->lesePufferArray('PendingInvocations');
$offen[$aufrufID] = $seriennummer;
$this->SetBuffer(
'PendingInvocations',
json_encode($offen, JSON_THROW_ON_ERROR)
);
$this->sendeSignalRRahmen([
'type' => 1,
'invocationId' => $aufrufID,
'target' => 'SubscribeWithCurrentState',
'arguments' => [$seriennummer, true],
]);
$gesendet[$seriennummer] = true;
$this->SetBuffer(
'SubscribedThisConnection',
json_encode($gesendet, JSON_THROW_ON_ERROR)
);
}
private function veroeffentlicheBeobachtung(
string $seriennummer,
int $id,
$wert
): void {
if (
$seriennummer === ''
|| !in_array($id, EaseeGatewayProtokoll::BEOBACHTUNGEN, true)
) {
return;
}
$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');
if (!isset($cache[$seriennummer]) || !is_array($cache[$seriennummer])) {
$cache[$seriennummer] = [];
}
if ($id === 109 && (int) $wert === 1) {
foreach ([110, 120, 182, 183, 184, 185] as $sessionID) {
$cache[$seriennummer][(string) $sessionID] = 0;
}
}
$cache[$seriennummer][(string) $id] = $wert;
$cache[$seriennummer]['updated'] = time();
$this->WriteAttributeString(
'ObservationCache',
json_encode($cache, JSON_THROW_ON_ERROR)
);
}
private function stelleAktuellenStatusSicher(string $seriennummer): void
{
$cache = $this->leseAttributArray('ObservationCache');
$zustand = $cache[$seriennummer] ?? [];
if (
(is_array($zustand) && array_key_exists('109', $zustand))
|| $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
{
$this->stelleAktuellenStatusSicher($seriennummer);
$cache = $this->leseAttributArray('ObservationCache');
return json_encode([
'success' => true,
'connected' => $this->GetBuffer('SignalRReady') === '1'
|| $this->ReadPropertyBoolean('Testmodus'),
'state' => $cache[$seriennummer] ?? [],
], JSON_THROW_ON_ERROR);
}
/** @return array<string, mixed> */
private function setzeDynamischenLadestrom(string $seriennummer, float $ampere): array
{
if ($seriennummer === '') {
return ['success' => false, 'error' => 'Seriennummer fehlt.'];
}
if (
!is_finite($ampere)
|| floor($ampere) !== $ampere
|| $ampere < 0
|| $ampere > 32
|| ($ampere > 0 && $ampere < 6)
) {
return [
'success' => false,
'error' => 'Strom muss 0 A oder eine ganze Zahl zwischen 6 und 32 A sein.',
];
}
if ($this->ReadPropertyBoolean('Testmodus')) {
$this->SetBuffer('LetzterTestbefehl', json_encode([
'serialNumber' => $seriennummer,
'amps' => $ampere,
], JSON_THROW_ON_ERROR));
return ['success' => true, 'httpCode' => 200, 'body' => '{}'];
}
return $this->autorisierteApiAnfrage(
'POST',
'/api/chargers/' . rawurlencode($seriennummer)
. '/commands/set_dynamic_charger_current',
json_encode([
'amps' => (int) round($ampere),
'minutes' => 0,
], JSON_THROW_ON_ERROR)
);
}
/** @return array<string, mixed> */
private function autorisierteApiAnfrage(
string $methode,
string $pfad,
string $inhalt
): array {
if (!$this->stelleAccessTokenSicher()) {
return ['success' => false, 'error' => 'Kein Easee-Access-Token.'];
}
$antwort = $this->httpAnfrage(
$methode,
self::API_BASE_URL . $pfad,
$inhalt,
$this->GetBuffer('AccessToken')
);
if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) {
$antwort = $this->httpAnfrage(
$methode,
self::API_BASE_URL . $pfad,
$inhalt,
$this->GetBuffer('AccessToken')
);
}
if (!$antwort['success']) {
return [
'success' => false,
'error' => $antwort['error'] !== ''
? $antwort['error']
: 'Easee-HTTP-Fehler ' . $antwort['httpCode'] . '.',
'httpCode' => $antwort['httpCode'],
];
}
return [
'success' => true,
'httpCode' => $antwort['httpCode'],
'body' => $antwort['body'],
];
}
private function erneuereZugangsdaten(bool $neuVerbinden = true): bool
{
$tokenpaar = null;
$refreshToken = $this->GetBuffer('RefreshToken');
if ($refreshToken !== '') {
$tokenpaar = $this->fordereTokenpaarAn(
self::API_BASE_URL . '/api/accounts/refresh_token',
['refreshToken' => $refreshToken]
);
}
if ($tokenpaar === null) {
$tokenpaar = $this->fordereTokenpaarAn(
self::API_BASE_URL . '/api/accounts/login',
[
'userName' => $this->ReadPropertyString('Username'),
'password' => $this->ReadPropertyString('Password'),
]
);
}
if ($tokenpaar === null || !isset($tokenpaar['accessToken'])) {
$this->setzeLetztenFehler('Easee-Anmeldung oder Token-Erneuerung fehlgeschlagen.');
$this->SetStatus(202);
return false;
}
$this->SetBuffer('AccessToken', (string) $tokenpaar['accessToken']);
if (isset($tokenpaar['refreshToken'])) {
$this->SetBuffer('RefreshToken', (string) $tokenpaar['refreshToken']);
}
if ($neuVerbinden) {
return $this->verbindeSignalR();
}
return true;
}
private function stelleAccessTokenSicher(): bool
{
return $this->GetBuffer('AccessToken') !== ''
|| $this->erneuereZugangsdaten(false);
}
/** @param array<string, string> $nutzdaten
* @return array<string, mixed>|null
*/
private function fordereTokenpaarAn(string $url, array $nutzdaten): ?array
{
$antwort = $this->httpAnfrage(
'POST',
$url,
json_encode($nutzdaten, JSON_THROW_ON_ERROR)
);
if (!$antwort['success']) {
return null;
}
$daten = json_decode($antwort['body'], true);
return is_array($daten) ? $daten : null;
}
/**
* @return array{success: bool, body: string, error: string, httpCode: int}
*/
private function httpAnfrage(
string $methode,
string $url,
string $inhalt = '',
string $bearerToken = ''
): array {
$kopf = ['Accept: application/json', 'Content-Type: application/json'];
if ($bearerToken !== '') {
$kopf[] = 'Authorization: Bearer ' . $bearerToken;
}
$curl = curl_init($url);
if ($curl === false) {
return [
'success' => false,
'body' => '',
'error' => 'HTTP-Anfrage konnte nicht initialisiert werden.',
'httpCode' => 0,
];
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $methode,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 30,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HTTPHEADER => $kopf,
CURLOPT_POSTFIELDS => $inhalt,
CURLOPT_SSL_VERIFYPEER => $this->ReadPropertyBoolean('VerifyCertificate'),
CURLOPT_SSL_VERIFYHOST => $this->ReadPropertyBoolean('VerifyCertificate') ? 2 : 0,
]);
$antwort = curl_exec($curl);
$fehler = curl_error($curl);
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
return [
'success' => $antwort !== false
&& $fehler === ''
&& $httpStatus >= 200
&& $httpStatus < 300,
'body' => $antwort === false ? '' : (string) $antwort,
'error' => $fehler,
'httpCode' => $httpStatus,
];
}
private function setzeVerbunden(bool $verbunden): void
{
$this->SetValue('Connected', $verbunden);
$this->SendDataToChildren(json_encode([
'DataID' => self::CHILD_EVENT_DATA_ID,
'Buffer' => json_encode([
'type' => 'GatewayStatus',
'connected' => $verbunden,
], JSON_THROW_ON_ERROR),
], JSON_THROW_ON_ERROR));
}
private function setzeLetztenFehler(string $nachricht): void
{
$this->SetValue('LastError', $nachricht);
}
/** @return array<string, mixed> */
private function leseAttributArray(string $name): array
{
$wert = json_decode($this->ReadAttributeString($name), true);
return is_array($wert) ? $wert : [];
}
/** @return array<string, mixed> */
private function lesePufferArray(string $name): array
{
$wert = json_decode($this->GetBuffer($name), true);
return is_array($wert) ? $wert : [];
}
}
+11
View File
@@ -0,0 +1,11 @@
# Ladestation Gateway
Eventbasiertes Enelix-EMS-Verbrauchermodul fuer eine Easee-Ladestation. Das
Modul liest Fahrzeugstatus und aktive 1-/3-Phasenladung direkt aus Easee
Observations und sendet Stromvorgaben ueber das verbundene Easee Gateway.
Konfiguration, Betriebsarten und Tests:
[Moduldokumentation](../docs/module/Ladestation-Gateway/README.md)
Gateway-Vertrag:
[Schnittstellenbeschreibung](../docs/Schnittstelle-Easee-Gateway.md)
+116
View File
@@ -0,0 +1,116 @@
{
"elements": [
{
"type": "Select",
"name": "Betriebsmodus",
"caption": "Variante",
"options": [
{
"caption": "Easee",
"value": 0
},
{
"caption": "Easee - Nur Solarladen",
"value": 1
}
]
},
{
"type": "ValidationTextBox",
"name": "Ladestationskennung",
"caption": "Easee Seriennummer"
},
{
"type": "NumberSpinner",
"name": "MaximalerLadestrom",
"caption": "Maximaler Ladestrom",
"minimum": 6,
"maximum": 32,
"suffix": " A"
},
{
"type": "CheckBox",
"name": "Ladefreigabe",
"caption": "Ladefreigabe beim Start"
},
{
"type": "CheckBox",
"name": "Solarladen",
"caption": "Solarladen beim Start"
},
{
"type": "NumberSpinner",
"name": "PrioritaetPV",
"caption": "Prioritaet PV",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "PrioritaetPeak",
"caption": "Prioritaet Peak",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Meldeintervall",
"caption": "Meldeintervall",
"minimum": 1,
"suffix": " s"
},
{
"type": "NumberSpinner",
"name": "VorgabeTimeout",
"caption": "Vorgabe-Timeout",
"minimum": 1,
"suffix": " s"
},
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Einstellungen in Visualisierung"
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Diagnoseprotokoll aktivieren"
},
{
"type": "Label",
"caption": "Easee-Zugangsdaten werden ausschliesslich im verbundenen Easee Gateway gespeichert."
}
],
"actions": [
{
"type": "Button",
"caption": "Status neu anfordern",
"onClick": "IPS_RequestAction($id, \"StatusAnfordern\", false);"
}
],
"status": [
{
"code": 201,
"icon": "error",
"caption": "Konfiguration ungueltig"
},
{
"code": 202,
"icon": "error",
"caption": "Easee Gateway nicht verbunden"
},
{
"code": 203,
"icon": "inactive",
"caption": "Warte auf Easee-Status"
},
{
"code": 204,
"icon": "error",
"caption": "Easee Ladestation meldet Fehler"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"id": "{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}",
"name": "LadestationGateway",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Ladestation Gateway"
],
"parentRequirements": [
"{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}"
],
"childRequirements": [],
"implemented": [
"{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}"
],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/LadestationGateway"
}
+987
View File
@@ -0,0 +1,987 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/LadestationRegler.php';
require_once __DIR__ . '/../libs/EaseeLadestatus.php';
require_once __DIR__ . '/../libs/EaseeGatewayProtokoll.php';
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
use Belevo\EnelixEMS\EaseeLadestatus;
use Belevo\EnelixEMS\LadestationRegler;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
{
use VerbraucherBasisTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const EASEE_GATEWAY_MODULE_ID = '{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}';
private const GATEWAY_REQUEST_DATA_ID = '{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}';
private const STATUS_AKTIV = 102;
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
private const STATUS_GATEWAY_GETRENNT = 202;
private const STATUS_WARTET_AUF_DATEN = 203;
private const STATUS_LADEFEHLER = 204;
/** @var list<string> */
private const DIAGNOSE_VARIABLEN = [
'Istleistung',
'Leistungsquelle',
'Sollleistung',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
'Stoertext',
'GatewayVerbunden',
'ApiMaximalstrom',
'LetzterGeraetebefehl',
'LeistungsangebotDiagnose',
];
public function Create(): void
{
parent::Create();
$this->registriereVerbraucherBasis();
$this->RegisterPropertyInteger('Betriebsmodus', EaseeLadestatus::BETRIEBSMODUS_EASEE);
$this->RegisterPropertyString('Ladestationskennung', '');
$this->RegisterPropertyInteger('MaximalerLadestrom', 16);
$this->RegisterPropertyBoolean('Ladefreigabe', true);
$this->RegisterPropertyBoolean('Solarladen', true);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
$this->RegisterVariableBoolean(
'FahrzeugVerbunden',
'Fahrzeug verbunden',
'~Switch',
100
);
$this->RegisterVariableBoolean(
'FahrzeugGeladen',
'Fahrzeug geladen',
'~Switch',
110
);
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 120);
$this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 130);
$this->RegisterVariableInteger('Phasenzahl', 'Phasenzahl', '', 140);
$this->RegisterAttributeBoolean('InitialwerteGesetzt', false);
$this->RegisterAttributeBoolean('KonfigurationLadefreigabe', false);
$this->RegisterAttributeBoolean('KonfigurationSolarladen', true);
$this->RegisterAttributeBoolean('ZustandLadefreigabe', false);
$this->RegisterAttributeBoolean('ZustandSolarladen', true);
$this->RegisterAttributeBoolean('GatewayVerbunden', false);
$this->RegisterAttributeString('EaseeBeobachtungen', '{}');
$this->RegisterAttributeInteger('LetzteBeobachtung', 0);
$this->RegisterAttributeInteger('ApiMaximalstrom', 16);
$this->RegisterAttributeBoolean('ZustandFahrzeugVerbunden', false);
$this->RegisterAttributeBoolean('ZustandFahrzeugGeladen', false);
$this->RegisterAttributeInteger('ZustandFahrzeugstatus', 0);
$this->RegisterAttributeFloat('ZustandLadestrom', 0.0);
$this->RegisterAttributeInteger('ZustandPhasenzahl', 0);
$this->RegisterAttributeFloat('ZustandIstleistung', 0.0);
$this->RegisterAttributeInteger('ZustandSollleistung', 0);
$this->RegisterAttributeBoolean('ZustandSollwertGueltig', false);
$this->RegisterAttributeBoolean('ZustandVerfuegbar', false);
$this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false);
$this->RegisterAttributeBoolean('ZustandStoerung', false);
$this->RegisterAttributeString('ZustandStoertext', '');
$this->RegisterAttributeString('Leistungsangebot', '[0]');
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
$this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzterGesetzterStrom', -1);
$this->RegisterAttributeString('LetzterGeraetebefehl', '');
$this->RegisterTimer(
'Meldezyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
);
$this->RegisterTimer(
'RueckmeldungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
);
$this->RegisterTimer(
'VorgabeTimeout',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', false);"
);
$this->ConnectParent(self::EASEE_GATEWAY_MODULE_ID);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->initialisiereLokaleEinstellungen();
$this->aktualisiereVariablen();
$this->WriteAttributeBoolean('GatewayVerbunden', false);
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->deaktiviereTimer();
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->SetTimerInterval(
'Meldezyklus',
$this->ReadPropertyInteger('Meldeintervall') * 1000
);
$this->statusAnfordern();
$this->aktualisiere(true);
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false);
}
$this->aktualisiere(true);
return;
case 'Ladefreigabe':
$this->WriteAttributeBoolean('ZustandLadefreigabe', (bool) $wert);
$this->setzeSichtbareVariable('Ladefreigabe', (bool) $wert);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'Solarladen':
$solarladen = EaseeLadestatus::solarladenErzwungen(
$this->ReadPropertyInteger('Betriebsmodus')
) ? true : (bool) $wert;
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
$this->setzeSichtbareVariable('Solarladen', $solarladen);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'StatusAnfordern':
$this->statusAnfordern();
return;
case 'VorgabeTimeout':
$this->SetTimerInterval('VorgabeTimeout', 0);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'Melden':
if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
}
$this->sendeVerbraucherdaten();
return;
case 'ManagerdatenEmpfangen':
if (!is_string($wert)) {
throw new InvalidArgumentException(
'Managerdaten muessen als JSON uebergeben werden.'
);
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException(
'Managerdaten muessen ein JSON-Objekt sein.'
);
}
$this->ManagerdatenEmpfangen($daten);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
public function ReceiveData($jsonString): void
{
$paket = json_decode((string) $jsonString, true);
if (!is_array($paket) || !isset($paket['Buffer'])) {
return;
}
$ereignis = json_decode((string) $paket['Buffer'], true);
if (!is_array($ereignis) || !isset($ereignis['type'])) {
return;
}
if ($ereignis['type'] === 'GatewayStatus') {
$verbunden = (bool) ($ereignis['connected'] ?? false);
$this->WriteAttributeBoolean('GatewayVerbunden', $verbunden);
$this->setzeSichtbareVariable('GatewayVerbunden', $verbunden);
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
if ($verbunden) {
$this->statusAnfordern();
} else {
$this->verwerfeBeobachtungen();
$this->aktualisiere(true);
}
return;
}
$seriennummer = EaseeGatewayProtokoll::seriennummer(
(string) ($ereignis['serialNumber'] ?? '')
);
if (
$ereignis['type'] !== 'Observation'
|| $seriennummer !== $this->seriennummer()
) {
return;
}
$this->speichereBeobachtung(
(int) ($ereignis['id'] ?? 0),
$ereignis['value'] ?? null,
(int) ($ereignis['timestamp'] ?? time())
);
$this->aktualisiere(true);
}
/** @param array<string, mixed> $daten */
public function ManagerdatenEmpfangen(array $daten): void
{
Nachrichtenvertrag::pruefeManagerdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
throw new InvalidArgumentException(
'Managerdaten sind an eine andere Instanz adressiert.'
);
}
$managerID = $daten['Kopf']['AbsenderID'];
if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) {
throw new InvalidArgumentException(
'Der Manager hat diese Ladestation nicht aktiv zugeordnet.'
);
}
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->aktualisiere(false);
$sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) {
if (
(bool) $this->leseZustand('SollwertGueltig')
&& !in_array(
(int) $this->leseZustand('Sollleistung'),
$this->leseLeistungsangebot(),
true
)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
return;
}
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) {
throw new InvalidArgumentException(
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
);
}
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
$this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true);
$this->SetTimerInterval(
'VorgabeTimeout',
$this->ReadPropertyInteger('VorgabeTimeout') * 1000
);
$this->aktualisiere(false);
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
private function statusAnfordern(): void
{
try {
$antwort = $this->gatewayAnfrage([
'action' => 'Subscribe',
'serialNumber' => $this->seriennummer(),
]);
if (!($antwort['success'] ?? false)) {
throw new RuntimeException(
(string) ($antwort['error'] ?? 'Easee Gateway nicht erreichbar.')
);
}
$this->WriteAttributeBoolean(
'GatewayVerbunden',
(bool) ($antwort['connected'] ?? false)
);
$this->setzeSichtbareVariable(
'GatewayVerbunden',
$this->ReadAttributeBoolean('GatewayVerbunden')
);
$this->verwerfeBeobachtungen();
if (is_array($antwort['state'] ?? null)) {
$zustand = $antwort['state'];
$zeitpunkt = (int) ($zustand['updated'] ?? time());
foreach (EaseeGatewayProtokoll::BEOBACHTUNGEN as $id) {
if (array_key_exists((string) $id, $zustand)) {
$this->speichereBeobachtung(
$id,
$zustand[(string) $id],
$zeitpunkt
);
}
}
}
$this->aktualisiere(true);
} catch (Throwable $fehler) {
$this->WriteAttributeBoolean('GatewayVerbunden', false);
$this->aktualisiere(true);
$this->protokolliere('Gateway', $fehler->getMessage());
}
}
/** @param array<string, mixed> $anfrage
* @return array<string, mixed>
*/
private function gatewayAnfrage(array $anfrage): array
{
$antwort = $this->SendDataToParent(json_encode([
'DataID' => self::GATEWAY_REQUEST_DATA_ID,
'Buffer' => json_encode($anfrage, JSON_THROW_ON_ERROR),
], JSON_THROW_ON_ERROR));
if (!is_string($antwort) || $antwort === '') {
throw new RuntimeException('Easee Gateway liefert keine Antwort.');
}
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new RuntimeException('Easee Gateway liefert ungueltige Daten.');
}
return $daten;
}
private function speichereBeobachtung(int $id, $wert, int $zeitpunkt): void
{
if (!in_array($id, EaseeGatewayProtokoll::BEOBACHTUNGEN, true)) {
return;
}
$beobachtungen = $this->leseBeobachtungen();
if ($id === 109 && (int) $wert === 1) {
foreach ([110, 120, 182, 183, 184, 185] as $sessionID) {
$beobachtungen[(string) $sessionID] = 0;
}
}
$beobachtungen[(string) $id] = $wert;
$this->WriteAttributeString(
'EaseeBeobachtungen',
json_encode($beobachtungen, JSON_THROW_ON_ERROR)
);
$this->WriteAttributeInteger('LetzteBeobachtung', $zeitpunkt);
}
private function aktualisiere(bool $meldungPlanen): void
{
try {
$status = EaseeLadestatus::ausBeobachtungen(
$this->leseBeobachtungen(),
$this->ReadPropertyInteger('MaximalerLadestrom')
);
foreach ([
'FahrzeugVerbunden',
'FahrzeugGeladen',
'Fahrzeugstatus',
'Phasenzahl',
'Istleistung_W',
'Ladestrom_A',
] as $feld) {
$this->setzeZustand($feld, $status[$feld]);
}
$this->WriteAttributeInteger(
'ApiMaximalstrom',
$status['ApiMaximalstrom_A']
);
$this->setzeSichtbareVariable(
'ApiMaximalstrom',
$status['ApiMaximalstrom_A']
);
$gatewayVerbunden = $this->ReadAttributeBoolean('GatewayVerbunden');
$phaseGueltig = in_array($status['Phasenzahl'], [1, 3], true);
$regelPhasenzahl = $phaseGueltig ? $status['Phasenzahl'] : 1;
$phaseErforderlich = $status['Fahrzeugstatus'] === 3;
$steuerbar = $gatewayVerbunden
&& $status['StatusGueltig']
&& !$status['Stoerung']
&& (!$phaseErforderlich || $phaseGueltig);
$solarladen = $this->ReadAttributeBoolean('ZustandSolarladen');
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
=== Nachrichtenvertrag::BETRIEBSART_PEAK;
$angebot = LadestationRegler::leistungsangebot(
(bool) $this->GetValue('Aktiv'),
$this->ReadAttributeBoolean('ZustandLadefreigabe'),
$steuerbar && $status['FahrzeugVerbunden'],
$status['FahrzeugGeladen'],
$regelPhasenzahl,
$status['ApiMaximalstrom_A'],
$solarladen,
$peakbetrieb
);
$verfuegbar = $steuerbar
&& $status['FahrzeugVerbunden']
&& !$status['FahrzeugGeladen']
&& $angebot !== [0];
$angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR);
$this->WriteAttributeString('Leistungsangebot', $angebotJson);
$this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson);
$this->setzeZustand('Verfuegbar', $verfuegbar);
$this->setzeZustand(
'AenderungMoeglich',
$verfuegbar && count($angebot) > 1
);
$stoertext = '';
if (!$gatewayVerbunden) {
$stoertext = 'Easee Gateway ist nicht verbunden.';
$this->SetStatus(self::STATUS_GATEWAY_GETRENNT);
} elseif (!$status['StatusGueltig']) {
$stoertext = 'Warte auf den aktuellen Easee-Ladestatus.';
$this->SetStatus(self::STATUS_WARTET_AUF_DATEN);
} elseif ($status['Stoerung']) {
$stoertext = $status['Stoertext'];
$this->SetStatus(self::STATUS_LADEFEHLER);
} elseif ($phaseErforderlich && !$phaseGueltig) {
$stoertext = 'Easee meldet beim Laden keine unterstuetzte aktive Ausgangsphase.';
$this->SetStatus(self::STATUS_WARTET_AUF_DATEN);
} else {
$this->SetStatus(self::STATUS_AKTIV);
}
$this->setzeStoerung($stoertext);
if ($gatewayVerbunden && $status['StatusGueltig']) {
$this->setzeLadestrom(
$this->bestimmeWirksameSollleistung($angebot),
$regelPhasenzahl
);
} else {
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
}
} catch (Throwable $fehler) {
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage());
$this->SetStatus(self::STATUS_GATEWAY_GETRENNT);
$this->protokolliere('Easee', $fehler->getMessage());
}
if ($meldungPlanen) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
}
/** @param list<int> $angebot */
private function bestimmeWirksameSollleistung(array $angebot): int
{
if ((bool) $this->leseZustand('SollwertGueltig')) {
$sollleistung = (int) $this->leseZustand('Sollleistung');
if (in_array($sollleistung, $angebot, true)) {
return $sollleistung;
}
$this->setzeZustand('SollwertGueltig', false);
}
if (
!$this->ReadAttributeBoolean('ZustandSolarladen')
&& $angebot !== [0]
) {
return $angebot[count($angebot) - 1];
}
return 0;
}
private function setzeLadestrom(int $leistung, int $phasenzahl): void
{
$strom = $leistung === 0
? 0
: LadestationRegler::stromFuerLeistung($leistung, $phasenzahl);
if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) {
return;
}
$antwort = $this->gatewayAnfrage([
'action' => 'SetDynamicChargerCurrent',
'serialNumber' => $this->seriennummer(),
'amps' => $strom,
]);
if (!($antwort['success'] ?? false)) {
throw new RuntimeException(
(string) ($antwort['error'] ?? 'Easee-Stromvorgabe fehlgeschlagen.')
);
}
$befehl = json_encode([
'Aktion' => 'SetDynamicChargerCurrent',
'Seriennummer' => $this->seriennummer(),
'Ampere' => $strom,
], JSON_THROW_ON_ERROR);
$this->WriteAttributeString('LetzterGeraetebefehl', $befehl);
$this->setzeSichtbareVariable('LetzterGeraetebefehl', $befehl);
$this->WriteAttributeInteger('LetzterGesetzterStrom', $strom);
}
private function seriennummer(): string
{
return EaseeGatewayProtokoll::seriennummer(
$this->ReadPropertyString('Ladestationskennung')
);
}
private function verwerfeBeobachtungen(): void
{
$this->WriteAttributeString('EaseeBeobachtungen', '{}');
$this->WriteAttributeInteger('LetzteBeobachtung', 0);
}
/** @return array<string, mixed> */
private function leseBeobachtungen(): array
{
$daten = json_decode($this->ReadAttributeString('EaseeBeobachtungen'), true);
return is_array($daten) ? $daten : [];
}
private function sendeVerbraucherdaten(): void
{
foreach ($this->zugeordneteManagerIDs() as $managerID) {
$daten = $this->baueVerbraucherdaten($managerID);
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
try {
IPS_RequestAction(
$managerID,
'VerbraucherdatenEmpfangen',
json_encode($daten, JSON_THROW_ON_ERROR)
);
} catch (Throwable $fehler) {
$this->protokolliere('Managerkommunikation', $fehler->getMessage());
}
}
}
/** @return array<string, mixed> */
private function baueVerbraucherdaten(int $managerID): array
{
$stoerung = (bool) $this->leseZustand('Stoerung');
return [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $this->InstanceID,
'EmpfaengerID' => $managerID,
'Zeitpunkt' => time(),
],
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN,
'Zustand' => [
[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung')
: null,
'Einheit' => 'W',
],
[
'Kennung' => 'FahrzeugVerbunden',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('FahrzeugVerbunden'),
'Einheit' => '',
],
[
'Kennung' => 'FahrzeugGeladen',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('FahrzeugGeladen'),
'Einheit' => '',
],
[
'Kennung' => 'Fahrzeugstatus',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('Fahrzeugstatus'),
'Einheit' => '',
],
[
'Kennung' => 'Phasenzahl',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('Phasenzahl'),
'Einheit' => '',
],
[
'Kennung' => 'Ladestrom_A',
'Art' => 'Istwert',
'Wert' => (float) $this->leseZustand('Ladestrom'),
'Einheit' => 'A',
],
[
'Kennung' => 'Ladefreigabe',
'Art' => 'Status',
'Wert' => $this->ReadAttributeBoolean('ZustandLadefreigabe'),
'Einheit' => '',
],
[
'Kennung' => 'Solarladen',
'Art' => 'Status',
'Wert' => $this->ReadAttributeBoolean('ZustandSolarladen'),
'Einheit' => '',
],
[
'Kennung' => 'Ladefehler',
'Art' => 'Stoerung',
'Wert' => $stoerung,
'Einheit' => '',
'Text' => (string) $this->leseZustand('Stoertext'),
],
],
];
}
/** @return list<int> */
private function zugeordneteManagerIDs(): array
{
$ergebnis = [];
foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) {
try {
$automatisch = (bool) IPS_GetProperty(
$managerID,
'AutomatischeSuche'
);
$property = $automatisch
? 'AutomatischeVerbraucherZuordnung'
: 'VerbraucherZuordnung';
$zuordnung = $this->leseManagerZuordnung($managerID, $property);
if ($automatisch && $zuordnung === []) {
$zuordnung = $this->leseManagerZuordnung(
$managerID,
'VerbraucherZuordnung'
);
}
} catch (Throwable $fehler) {
continue;
}
foreach ($zuordnung as $eintrag) {
if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) {
continue;
}
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
if ($instanzID === $this->InstanceID) {
$ergebnis[] = (int) $managerID;
break;
}
}
}
return array_values(array_unique($ergebnis));
}
/** @return array<mixed> */
private function leseManagerZuordnung(int $managerID, string $property): array
{
$zuordnung = json_decode(
(string) IPS_GetProperty($managerID, $property),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($zuordnung) ? $zuordnung : [];
}
private function pruefeKonfiguration(): void
{
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException(
$property . ' muss mindestens 0 sein.'
);
}
}
foreach (['Meldeintervall', 'VorgabeTimeout'] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException(
$property . ' muss groesser als 0 sein.'
);
}
}
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
if ($maximalstrom < 6 || $maximalstrom > 32) {
throw new InvalidArgumentException(
'MaximalerLadestrom muss zwischen 6 und 32 A liegen.'
);
}
EaseeLadestatus::solarladenErzwungen(
$this->ReadPropertyInteger('Betriebsmodus')
);
if ($this->seriennummer() === '') {
throw new InvalidArgumentException('Easee Seriennummer fehlt.');
}
}
private function initialisiereLokaleEinstellungen(): void
{
$initialisiert = $this->ReadAttributeBoolean('InitialwerteGesetzt');
$ladefreigabe = $this->ReadPropertyBoolean('Ladefreigabe');
$solarladen = EaseeLadestatus::solarladenErzwungen(
$this->ReadPropertyInteger('Betriebsmodus')
) ? true : $this->ReadPropertyBoolean('Solarladen');
if (
!$initialisiert
|| $ladefreigabe
!== $this->ReadAttributeBoolean('KonfigurationLadefreigabe')
) {
$this->WriteAttributeBoolean('ZustandLadefreigabe', $ladefreigabe);
}
if (
!$initialisiert
|| $solarladen
!== $this->ReadAttributeBoolean('KonfigurationSolarladen')
) {
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
}
$this->WriteAttributeBoolean('KonfigurationLadefreigabe', $ladefreigabe);
$this->WriteAttributeBoolean('KonfigurationSolarladen', $solarladen);
$this->WriteAttributeBoolean('InitialwerteGesetzt', true);
}
private function aktualisiereVariablen(): void
{
if ($this->ReadPropertyBoolean('EinstellungenInVisu')) {
$this->RegisterVariableBoolean(
'Ladefreigabe',
'Ladefreigabe',
'~Switch',
150
);
$this->RegisterVariableBoolean(
'Solarladen',
'Solarladen',
'~Switch',
160
);
$this->EnableAction('Ladefreigabe');
$this->EnableAction('Solarladen');
$this->SetValue(
'Ladefreigabe',
$this->ReadAttributeBoolean('ZustandLadefreigabe')
);
$this->SetValue(
'Solarladen',
$this->ReadAttributeBoolean('ZustandSolarladen')
);
} else {
$this->entferneVariable('Ladefreigabe');
$this->entferneVariable('Solarladen');
}
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->registriereVerbraucherDiagnose();
$this->RegisterVariableBoolean(
'GatewayVerbunden',
'Easee Gateway verbunden',
'~Switch',
200
);
$this->RegisterVariableInteger(
'ApiMaximalstrom',
'API-Maximalstrom',
'',
210
);
$this->RegisterVariableString(
'LetzterGeraetebefehl',
'Letzter Geraetebefehl',
'',
220
);
$this->RegisterVariableString(
'LeistungsangebotDiagnose',
'Leistungsangebot',
'',
230
);
$this->SetValue(
'LeistungsangebotDiagnose',
$this->ReadAttributeString('Leistungsangebot')
);
$this->SetValue(
'LetzterGeraetebefehl',
$this->ReadAttributeString('LetzterGeraetebefehl')
);
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if (!in_array($ident, [
'GatewayVerbunden',
'ApiMaximalstrom',
'LetzterGeraetebefehl',
'LeistungsangebotDiagnose',
], true)) {
$this->setzeSichtbareVariable($ident, $this->leseZustand($ident));
}
}
$this->setzeSichtbareVariable(
'GatewayVerbunden',
$this->ReadAttributeBoolean('GatewayVerbunden')
);
$this->setzeSichtbareVariable(
'ApiMaximalstrom',
$this->ReadAttributeInteger('ApiMaximalstrom')
);
} else {
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$this->entferneVariable($ident);
}
}
}
private function entferneVariable(string $ident): void
{
$id = @$this->GetIDForIdent($ident);
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
$this->UnregisterVariable($ident);
}
}
/** @param mixed $wert */
private function setzeZustand(string $ident, $wert): void
{
switch ($ident) {
case 'FahrzeugVerbunden':
case 'FahrzeugGeladen':
case 'SollwertGueltig':
case 'Verfuegbar':
case 'AenderungMoeglich':
case 'Stoerung':
$this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert);
break;
case 'Fahrzeugstatus':
case 'Phasenzahl':
case 'Sollleistung':
$this->WriteAttributeInteger('Zustand' . $ident, (int) $wert);
break;
case 'Ladestrom_A':
$ident = 'Ladestrom';
$this->WriteAttributeFloat('ZustandLadestrom', (float) $wert);
break;
case 'Istleistung_W':
$ident = 'Istleistung';
$this->WriteAttributeFloat('ZustandIstleistung', (float) $wert);
break;
case 'Stoertext':
$this->WriteAttributeString('ZustandStoertext', (string) $wert);
break;
default:
throw new LogicException('Unbekannter Zustand: ' . $ident);
}
$this->setzeSichtbareVariable($ident, $wert);
if ($ident === 'Istleistung') {
$this->setzeSichtbareVariable(
'Leistungsquelle',
Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN
);
}
}
/** @return mixed */
private function leseZustand(string $ident)
{
if ($ident === 'Ladefreigabe' || $ident === 'Solarladen') {
return $this->ReadAttributeBoolean('Zustand' . $ident);
}
if (in_array($ident, [
'FahrzeugVerbunden',
'FahrzeugGeladen',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
], true)) {
return $this->ReadAttributeBoolean('Zustand' . $ident);
}
if (in_array($ident, [
'Fahrzeugstatus',
'Phasenzahl',
'Sollleistung',
], true)) {
return $this->ReadAttributeInteger('Zustand' . $ident);
}
if ($ident === 'Ladestrom' || $ident === 'Istleistung') {
return $this->ReadAttributeFloat('Zustand' . $ident);
}
if ($ident === 'Stoertext') {
return $this->ReadAttributeString('ZustandStoertext');
}
if ($ident === 'Leistungsquelle') {
return Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN;
}
throw new LogicException('Unbekannter Zustand: ' . $ident);
}
/** @param mixed $wert */
private function setzeSichtbareVariable(string $ident, $wert): void
{
$id = @$this->GetIDForIdent($ident);
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
$this->SetValue($ident, $wert);
}
}
/** @return list<int> */
private function leseLeistungsangebot(): array
{
$angebot = json_decode(
$this->ReadAttributeString('Leistungsangebot'),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($angebot) ? array_map('intval', $angebot) : [0];
}
private function setzeStoerung(string $text): void
{
$this->setzeZustand('Stoerung', $text !== '');
$this->setzeZustand('Stoertext', $text);
}
private function deaktiviereTimer(): void
{
foreach (['Meldezyklus', 'RueckmeldungVerzoegert', 'VorgabeTimeout'] as $timer) {
$this->SetTimerInterval($timer, 0);
}
}
private function protokolliere(string $bereich, string $nachricht): void
{
if ($this->ReadPropertyBoolean('LoggingEin')) {
$this->SendDebug($bereich, $nachricht, 0);
}
}
}
+155
View File
@@ -0,0 +1,155 @@
{
"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": "Abfrageintervall",
"caption": "Geraetestatus abfragen",
"suffix": " s",
"minimum": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Ladestation",
"expanded": true,
"items": [
{
"type": "Select",
"name": "Geraetetyp",
"caption": "Geraetetyp",
"options": [
{
"caption": "Nicht konfiguriert",
"value": 0
},
{
"caption": "go-e Charger (alte API)",
"value": 1
},
{
"caption": "go-e Charger Gemini / Gemini flex",
"value": 2
},
{
"caption": "smart-me Pico",
"value": 3
}
]
},
{
"type": "ValidationTextBox",
"name": "Geraeteadresse",
"caption": "IP-Adresse oder Hostname (go-e)"
},
{
"type": "ValidationTextBox",
"name": "GeraeteID",
"caption": "Geraete-ID (Pico)"
},
{
"type": "ValidationTextBox",
"name": "Seriennummer",
"caption": "Seriennummer (Pico)"
},
{
"type": "ValidationTextBox",
"name": "Benutzername",
"caption": "Benutzername (Pico)"
},
{
"type": "PasswordTextBox",
"name": "Passwort",
"caption": "Passwort (Pico)"
},
{
"type": "NumberSpinner",
"name": "MaximalerLadestrom",
"caption": "Maximaler Ladestrom",
"suffix": " A",
"minimum": 6,
"maximum": 32
},
{
"type": "CheckBox",
"name": "Ladefreigabe",
"caption": "Ladefreigabe beim Start"
},
{
"type": "CheckBox",
"name": "Solarladen",
"caption": "Solarladen beim Start"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Darstellung und Diagnose",
"items": [
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Ladefreigabe und Solarladen in der Visualisierung anzeigen"
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Diagnoseprotokoll aktivieren"
}
]
}
],
"status": [
{
"code": 102,
"icon": "active",
"caption": "Aktiv"
},
{
"code": 201,
"icon": "error",
"caption": "Konfiguration ungueltig"
},
{
"code": 202,
"icon": "error",
"caption": "Geraetekommunikation fehlgeschlagen"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{0D94913C-0F31-4C29-A685-6EB4AE58E55D}",
"name": "LadestationStandAlone",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Ladestation Stand-Alone"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/LadestationStandAlone"
}
+769
View File
@@ -0,0 +1,769 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/LadestationAdapter.php';
require_once __DIR__ . '/../libs/LadestationRegler.php';
use Belevo\EnelixEMS\LadestationAdapter;
use Belevo\EnelixEMS\LadestationRegler;
use Belevo\EnelixEMS\LadestationTransport;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstelle
{
use VerbraucherBasisTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const STATUS_AKTIV = 102;
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
private const STATUS_KOMMUNIKATIONSFEHLER = 202;
/** @var list<string> */
private const DIAGNOSE_VARIABLEN = [
'Istleistung',
'Leistungsquelle',
'Sollleistung',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
'Stoertext',
'LetzterGeraetebefehl',
'LeistungsangebotDiagnose',
];
public function Create(): void
{
parent::Create();
$this->registriereVerbraucherBasis();
$this->RegisterPropertyInteger('Geraetetyp', 0);
$this->RegisterPropertyString('Geraeteadresse', '');
$this->RegisterPropertyString('GeraeteID', '');
$this->RegisterPropertyString('Seriennummer', '');
$this->RegisterPropertyString('Benutzername', '');
$this->RegisterPropertyString('Passwort', '');
$this->RegisterPropertyInteger('MaximalerLadestrom', 16);
$this->RegisterPropertyInteger('Abfrageintervall', 5);
$this->RegisterPropertyBoolean('Ladefreigabe', true);
$this->RegisterPropertyBoolean('Solarladen', true);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
// Ausschliesslich fuer den automatisierten Symcon-Funktionstest.
$this->RegisterPropertyBoolean('Testmodus', false);
$this->RegisterPropertyString('Testantwort', '');
$this->RegisterVariableBoolean('FahrzeugVerbunden', 'Fahrzeug verbunden', '~Switch', 100);
$this->RegisterVariableBoolean('FahrzeugGeladen', 'Fahrzeug geladen', '~Switch', 110);
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 120);
$this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 130);
$this->RegisterVariableInteger('Phasenzahl', 'Phasenzahl', '', 140);
$this->RegisterAttributeBoolean('InitialwerteGesetzt', false);
$this->RegisterAttributeBoolean('KonfigurationLadefreigabe', false);
$this->RegisterAttributeBoolean('KonfigurationSolarladen', true);
$this->RegisterAttributeBoolean('ZustandLadefreigabe', false);
$this->RegisterAttributeBoolean('ZustandSolarladen', true);
$this->RegisterAttributeBoolean('ZustandFahrzeugVerbunden', false);
$this->RegisterAttributeBoolean('ZustandFahrzeugGeladen', false);
$this->RegisterAttributeInteger('ZustandFahrzeugstatus', 0);
$this->RegisterAttributeFloat('ZustandLadestrom', 0.0);
$this->RegisterAttributeInteger('ZustandPhasenzahl', 0);
$this->RegisterAttributeFloat('ZustandIstleistung', 0.0);
$this->RegisterAttributeInteger('ZustandSollleistung', 0);
$this->RegisterAttributeBoolean('ZustandSollwertGueltig', false);
$this->RegisterAttributeBoolean('ZustandVerfuegbar', false);
$this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false);
$this->RegisterAttributeBoolean('ZustandStoerung', false);
$this->RegisterAttributeString('ZustandStoertext', '');
$this->RegisterAttributeString('Leistungsangebot', '[0]');
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
$this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzterGesetzterStrom', -1);
$this->RegisterTimer(
'Statuszyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Statuszyklus', false);"
);
$this->RegisterTimer(
'Meldezyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
);
$this->RegisterTimer(
'RueckmeldungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
);
$this->RegisterTimer(
'VorgabeTimeout',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', false);"
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->initialisiereLokaleEinstellungen();
$this->aktualisiereVariablen();
// Ein persistiertes Ladeende nach Neueinrichtung oder Modulupdate einmal neu pruefen.
$this->setzeZustand('FahrzeugGeladen', false);
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->deaktiviereTimer();
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->SetTimerInterval('Statuszyklus', $this->ReadPropertyInteger('Abfrageintervall') * 1000);
$this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000);
$this->aktualisiere(true);
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false);
}
$this->aktualisiere(true);
return;
case 'Ladefreigabe':
case 'Solarladen':
$this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert);
$this->setzeSichtbareVariable($ident, (bool) $wert);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'Statuszyklus':
$this->aktualisiere(true);
return;
case 'VorgabeTimeout':
$this->SetTimerInterval('VorgabeTimeout', 0);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'Melden':
if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
}
$this->aktualisiere(false);
$this->sendeVerbraucherdaten();
return;
case 'ManagerdatenEmpfangen':
if (!is_string($wert)) {
throw new InvalidArgumentException('Managerdaten muessen als JSON uebergeben werden.');
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException('Managerdaten muessen ein JSON-Objekt sein.');
}
$this->ManagerdatenEmpfangen($daten);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
/** @param array<string, mixed> $daten */
public function ManagerdatenEmpfangen(array $daten): void
{
Nachrichtenvertrag::pruefeManagerdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
throw new InvalidArgumentException('Managerdaten sind an eine andere Instanz adressiert.');
}
$managerID = $daten['Kopf']['AbsenderID'];
if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) {
throw new InvalidArgumentException('Der Manager hat diese Ladestation nicht aktiv zugeordnet.');
}
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->aktualisiere(false, 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, false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
return;
}
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) {
throw new InvalidArgumentException(
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
);
}
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
$this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true);
$this->SetTimerInterval('VorgabeTimeout', $this->ReadPropertyInteger('VorgabeTimeout') * 1000);
$this->aktualisiere(false, false);
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
private function aktualisiere(bool $meldungPlanen, bool $statusLesen = true): void
{
try {
if ($statusLesen) {
$this->leseGeraetestatus();
}
$aktiv = (bool) $this->GetValue('Aktiv');
$ladefreigabe = $this->ReadAttributeBoolean('ZustandLadefreigabe');
$verbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden');
$geladen = $this->ReadAttributeBoolean('ZustandFahrzeugGeladen');
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
$solarladen = $this->ReadAttributeBoolean('ZustandSolarladen');
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
=== Nachrichtenvertrag::BETRIEBSART_PEAK;
$verfuegbar = LadestationRegler::leistungsangebot(
$aktiv,
$ladefreigabe,
$verbunden,
$geladen,
$phasenzahl,
$maximalstrom,
$solarladen,
false
) !== [0];
$angebot = LadestationRegler::leistungsangebot(
$aktiv,
$ladefreigabe,
$verbunden,
$geladen,
$phasenzahl,
$maximalstrom,
$solarladen,
$peakbetrieb
);
$angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR);
$this->WriteAttributeString('Leistungsangebot', $angebotJson);
$this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson);
$this->setzeZustand('Verfuegbar', $verfuegbar);
$this->setzeZustand('AenderungMoeglich', $verfuegbar && count($angebot) > 1);
$sollleistung = $this->bestimmeWirksameSollleistung($angebot);
$this->setzeLadestrom($sollleistung);
$this->setzeStoerung('');
$this->SetStatus(self::STATUS_AKTIV);
} catch (Throwable $fehler) {
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage());
$this->SetStatus(self::STATUS_KOMMUNIKATIONSFEHLER);
$this->protokolliere('Geraetekommunikation', $fehler->getMessage());
}
if ($meldungPlanen) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
}
private function leseGeraetestatus(): void
{
$transport = new LadestationTransport(
fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage)
);
$antwort = $transport->statusLesen(
$this->ReadPropertyInteger('Geraetetyp'),
$this->geraetekonfiguration()
);
$rohstatus = LadestationAdapter::dekodiereStatus(
$this->ReadPropertyInteger('Geraetetyp'),
$antwort
);
$status = LadestationRegler::normalisiereStatus(
$rohstatus,
$this->ReadAttributeBoolean('ZustandFahrzeugVerbunden'),
$this->ReadAttributeBoolean('ZustandFahrzeugGeladen'),
$this->ReadAttributeInteger('LetzterGesetzterStrom') >= 6
);
foreach ($status as $ident => $wert) {
$this->setzeZustand($ident, $wert);
}
}
/** @param list<int> $angebot */
private function bestimmeWirksameSollleistung(array $angebot): int
{
if ((bool) $this->leseZustand('SollwertGueltig')) {
$sollleistung = (int) $this->leseZustand('Sollleistung');
if (in_array($sollleistung, $angebot, true)) {
return $sollleistung;
}
$this->setzeZustand('SollwertGueltig', false);
}
if (!$this->ReadAttributeBoolean('ZustandSolarladen') && $angebot !== [0]) {
return $angebot[count($angebot) - 1];
}
return 0;
}
private function setzeLadestrom(int $leistung): void
{
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
$strom = $leistung === 0
? 0
: LadestationRegler::stromFuerLeistung($leistung, $phasenzahl);
if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) {
return;
}
$transport = new LadestationTransport(
fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage)
);
$transport->ladestromSetzen(
$this->ReadPropertyInteger('Geraetetyp'),
$this->geraetekonfiguration(),
$strom
);
$this->WriteAttributeInteger('LetzterGesetzterStrom', $strom);
}
/**
* @param array<string, mixed> $anfrage
*/
private function fuehreHttpAnfrageAus(array $anfrage): string
{
$protokoll = [
'Methode' => $anfrage['Methode'],
'URL' => $anfrage['URL'],
];
$this->setzeSichtbareVariable(
'LetzterGeraetebefehl',
json_encode($protokoll, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
);
if ($this->ReadPropertyBoolean('Testmodus')) {
$url = (string) $anfrage['URL'];
if (str_ends_with($url, '/api/status')
|| str_ends_with($url, '/mqtt?payload=')
|| str_contains($url, '/pico/charging/')
) {
return $this->ReadPropertyString('Testantwort');
}
return '{}';
}
$curl = curl_init();
if ($curl === false) {
throw new RuntimeException('HTTP-Anfrage konnte nicht initialisiert werden.');
}
curl_setopt($curl, CURLOPT_URL, (string) $anfrage['URL']);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($curl, CURLOPT_TIMEOUT, 10);
if ($anfrage['Methode'] === 'POST') {
curl_setopt($curl, CURLOPT_POST, true);
}
if (isset($anfrage['Benutzername'], $anfrage['Passwort'])) {
curl_setopt($curl, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt(
$curl,
CURLOPT_USERPWD,
(string) $anfrage['Benutzername'] . ':' . (string) $anfrage['Passwort']
);
}
$antwort = curl_exec($curl);
if ($antwort === false) {
$fehler = curl_error($curl);
curl_close($curl);
throw new RuntimeException('HTTP-Anfrage fehlgeschlagen: ' . $fehler);
}
$status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($status >= 400) {
throw new RuntimeException('Geraet antwortet mit HTTP-Status ' . $status . '.');
}
return (string) $antwort;
}
/** @return array<string, string> */
private function geraetekonfiguration(): array
{
return [
'Geraeteadresse' => $this->ReadPropertyString('Geraeteadresse'),
'GeraeteID' => $this->ReadPropertyString('GeraeteID'),
'Seriennummer' => $this->ReadPropertyString('Seriennummer'),
'Benutzername' => $this->ReadPropertyString('Benutzername'),
'Passwort' => $this->ReadPropertyString('Passwort'),
];
}
private function sendeVerbraucherdaten(): void
{
foreach ($this->zugeordneteManagerIDs() as $managerID) {
$daten = $this->baueVerbraucherdaten($managerID);
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
try {
IPS_RequestAction(
$managerID,
'VerbraucherdatenEmpfangen',
json_encode($daten, JSON_THROW_ON_ERROR)
);
} catch (Throwable $fehler) {
$this->protokolliere('Managerkommunikation', $fehler->getMessage());
}
}
}
/** @return array<string, mixed> */
private function baueVerbraucherdaten(int $managerID): array
{
$stoerung = (bool) $this->leseZustand('Stoerung');
return [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $this->InstanceID,
'EmpfaengerID' => $managerID,
'Zeitpunkt' => time(),
],
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN,
'Zustand' => [
[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung')
: null,
'Einheit' => 'W',
],
[
'Kennung' => 'FahrzeugVerbunden',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('FahrzeugVerbunden'),
'Einheit' => '',
],
[
'Kennung' => 'FahrzeugGeladen',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('FahrzeugGeladen'),
'Einheit' => '',
],
[
'Kennung' => 'Fahrzeugstatus',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('Fahrzeugstatus'),
'Einheit' => '',
],
[
'Kennung' => 'Phasenzahl',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('Phasenzahl'),
'Einheit' => '',
],
[
'Kennung' => 'Ladestrom_A',
'Art' => 'Istwert',
'Wert' => (float) $this->leseZustand('Ladestrom'),
'Einheit' => 'A',
],
[
'Kennung' => 'Ladefreigabe',
'Art' => 'Status',
'Wert' => $this->ReadAttributeBoolean('ZustandLadefreigabe'),
'Einheit' => '',
],
[
'Kennung' => 'Solarladen',
'Art' => 'Status',
'Wert' => $this->ReadAttributeBoolean('ZustandSolarladen'),
'Einheit' => '',
],
[
'Kennung' => 'Ladefehler',
'Art' => 'Stoerung',
'Wert' => $stoerung,
'Einheit' => '',
'Text' => (string) $this->leseZustand('Stoertext'),
],
],
];
}
/** @return list<int> */
private function zugeordneteManagerIDs(): array
{
$ergebnis = [];
foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) {
try {
$automatisch = (bool) IPS_GetProperty($managerID, 'AutomatischeSuche');
$property = $automatisch ? 'AutomatischeVerbraucherZuordnung' : 'VerbraucherZuordnung';
$zuordnung = $this->leseManagerZuordnung($managerID, $property);
if ($automatisch && $zuordnung === []) {
$zuordnung = $this->leseManagerZuordnung($managerID, 'VerbraucherZuordnung');
}
} catch (Throwable $fehler) {
continue;
}
foreach ($zuordnung as $eintrag) {
if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) {
continue;
}
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
if ($instanzID === $this->InstanceID) {
$ergebnis[] = (int) $managerID;
break;
}
}
}
return array_values(array_unique($ergebnis));
}
/** @return array<mixed> */
private function leseManagerZuordnung(int $managerID, string $property): array
{
$zuordnung = json_decode(
(string) IPS_GetProperty($managerID, $property),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($zuordnung) ? $zuordnung : [];
}
private function pruefeKonfiguration(): void
{
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException($property . ' muss mindestens 0 sein.');
}
}
foreach (['Meldeintervall', 'VorgabeTimeout', 'Abfrageintervall'] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
}
}
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
if ($maximalstrom < 6 || $maximalstrom > 32) {
throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.');
}
$geraetetyp = $this->ReadPropertyInteger('Geraetetyp');
$konfiguration = $this->geraetekonfiguration();
LadestationAdapter::statusAnfrage($geraetetyp, $konfiguration);
LadestationAdapter::steuerAnfragen($geraetetyp, $konfiguration, 0);
}
private function initialisiereLokaleEinstellungen(): void
{
$initialisiert = $this->ReadAttributeBoolean('InitialwerteGesetzt');
$ladefreigabe = $this->ReadPropertyBoolean('Ladefreigabe');
$solarladen = $this->ReadPropertyBoolean('Solarladen');
if (!$initialisiert
|| $ladefreigabe !== $this->ReadAttributeBoolean('KonfigurationLadefreigabe')
) {
$this->WriteAttributeBoolean('ZustandLadefreigabe', $ladefreigabe);
}
if (!$initialisiert
|| $solarladen !== $this->ReadAttributeBoolean('KonfigurationSolarladen')
) {
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
}
$this->WriteAttributeBoolean('KonfigurationLadefreigabe', $ladefreigabe);
$this->WriteAttributeBoolean('KonfigurationSolarladen', $solarladen);
$this->WriteAttributeBoolean('InitialwerteGesetzt', true);
}
private function aktualisiereVariablen(): void
{
if ($this->ReadPropertyBoolean('EinstellungenInVisu')) {
$this->RegisterVariableBoolean('Ladefreigabe', 'Ladefreigabe', '~Switch', 150);
$this->RegisterVariableBoolean('Solarladen', 'Solarladen', '~Switch', 160);
$this->EnableAction('Ladefreigabe');
$this->EnableAction('Solarladen');
$this->SetValue('Ladefreigabe', $this->ReadAttributeBoolean('ZustandLadefreigabe'));
$this->SetValue('Solarladen', $this->ReadAttributeBoolean('ZustandSolarladen'));
} else {
$this->entferneVariable('Ladefreigabe');
$this->entferneVariable('Solarladen');
}
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->registriereVerbraucherDiagnose();
$this->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 200);
$this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 210);
$this->SetValue(
'LeistungsangebotDiagnose',
$this->ReadAttributeString('Leistungsangebot')
);
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if (!in_array($ident, ['LetzterGeraetebefehl', 'LeistungsangebotDiagnose'], true)) {
$this->setzeSichtbareVariable($ident, $this->leseZustand($ident));
}
}
} else {
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$this->entferneVariable($ident);
}
}
}
private function entferneVariable(string $ident): void
{
$id = @$this->GetIDForIdent($ident);
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
$this->UnregisterVariable($ident);
}
}
/** @param mixed $wert */
private function setzeZustand(string $ident, $wert): void
{
switch ($ident) {
case 'FahrzeugVerbunden':
case 'FahrzeugGeladen':
case 'SollwertGueltig':
case 'Verfuegbar':
case 'AenderungMoeglich':
case 'Stoerung':
$this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert);
break;
case 'Fahrzeugstatus':
case 'Phasenzahl':
case 'Sollleistung':
$this->WriteAttributeInteger('Zustand' . $ident, (int) $wert);
break;
case 'Ladestrom_A':
$ident = 'Ladestrom';
$this->WriteAttributeFloat('ZustandLadestrom', (float) $wert);
break;
case 'Istleistung_W':
$ident = 'Istleistung';
$this->WriteAttributeFloat('ZustandIstleistung', (float) $wert);
break;
case 'Stoertext':
$this->WriteAttributeString('ZustandStoertext', (string) $wert);
break;
default:
throw new LogicException('Unbekannter Zustand: ' . $ident);
}
$this->setzeSichtbareVariable($ident, $wert);
if ($ident === 'Istleistung') {
$this->setzeSichtbareVariable(
'Leistungsquelle',
Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN
);
}
}
/** @return mixed */
private function leseZustand(string $ident)
{
if ($ident === 'Ladefreigabe' || $ident === 'Solarladen') {
return $this->ReadAttributeBoolean('Zustand' . $ident);
}
if (in_array($ident, [
'FahrzeugVerbunden', 'FahrzeugGeladen', 'SollwertGueltig',
'Verfuegbar', 'AenderungMoeglich', 'Stoerung',
], true)) {
return $this->ReadAttributeBoolean('Zustand' . $ident);
}
if (in_array($ident, ['Fahrzeugstatus', 'Phasenzahl', 'Sollleistung'], true)) {
return $this->ReadAttributeInteger('Zustand' . $ident);
}
if ($ident === 'Ladestrom' || $ident === 'Istleistung') {
return $this->ReadAttributeFloat('Zustand' . $ident);
}
if ($ident === 'Stoertext') {
return $this->ReadAttributeString('ZustandStoertext');
}
if ($ident === 'Leistungsquelle') {
return Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN;
}
throw new LogicException('Unbekannter Zustand: ' . $ident);
}
/** @param mixed $wert */
private function setzeSichtbareVariable(string $ident, $wert): void
{
$id = @$this->GetIDForIdent($ident);
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
$this->SetValue($ident, $wert);
}
}
/** @return list<int> */
private function leseLeistungsangebot(): array
{
$angebot = json_decode(
$this->ReadAttributeString('Leistungsangebot'),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($angebot) ? array_map('intval', $angebot) : [0];
}
private function setzeStoerung(string $text): void
{
$this->setzeZustand('Stoerung', $text !== '');
$this->setzeZustand('Stoertext', $text);
}
private function deaktiviereTimer(): void
{
foreach (['Statuszyklus', 'Meldezyklus', 'RueckmeldungVerzoegert', 'VorgabeTimeout'] as $timer) {
$this->SetTimerInterval($timer, 0);
}
}
private function protokolliere(string $bereich, string $nachricht): void
{
if (!$this->ReadPropertyBoolean('LoggingEin')) {
return;
}
$this->SendDebug($bereich, $nachricht, 0);
}
}
+830
View File
@@ -0,0 +1,830 @@
{
"elements": [
{
"type": "ExpansionPanel",
"caption": "Lizenzierung",
"expanded": true,
"items": [
{
"type": "ValidationTextBox",
"name": "Lizenzcode",
"caption": "Lizenzcode",
"placeholder": "ENX-XXXX-XXXX-XXXX-XXXX"
},
{
"type": "Label",
"name": "LizenzInstallationID",
"caption": "Installations-ID wird erzeugt"
},
{
"type": "Label",
"name": "LizenzInformation",
"caption": "Lizenzcode fehlt."
},
{
"type": "Button",
"name": "LizenzPruefen",
"caption": "Lizenz pruefen und binden",
"onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);"
},
{
"type": "Button",
"name": "LizenzVerwalten",
"caption": "Lizenz verwalten",
"link": true,
"onClick": "echo 'https://license.enelix.ch';"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Grundeinstellungen",
"expanded": true,
"items": [
{
"type": "Select",
"name": "Rolle",
"caption": "Rolle",
"options": [
{"caption": "Alleine", "value": 0},
{"caption": "Hauptmanager", "value": 1},
{"caption": "Untermanager", "value": 2}
]
},
{
"type": "SelectVariable",
"name": "NetzleistungVariableID",
"caption": "Netzleistung"
},
{
"type": "NumberSpinner",
"name": "SollwertSolarladen",
"caption": "Sollwert Solarladen",
"suffix": " W",
"digits": 1
},
{
"type": "Select",
"name": "Lastspitzenmodus",
"caption": "Lastspitzenmodus",
"onChange": "IPS_RequestAction($id, 'FormLastspitzenmodus', $Lastspitzenmodus);",
"options": [
{"caption": "Aus", "value": 0},
{"caption": "Konstant", "value": 1},
{"caption": "Monatlich", "value": 2}
]
},
{
"type": "NumberSpinner",
"name": "Lastspitzengrenze",
"caption": "Lastspitzengrenze",
"suffix": " W",
"digits": 1,
"visible": false
},
{
"type": "Button",
"name": "MonatsgrenzenUmschalten",
"caption": "Monatsgrenzen anzeigen",
"visible": false,
"onClick": "IPS_RequestAction($id, 'FormMonatsgrenzenUmschalten', true);"
},
{
"type": "List",
"name": "Monatsgrenzen",
"caption": "Monatliche Lastspitzengrenzen",
"rowCount": 12,
"add": false,
"delete": false,
"sortable": false,
"loadValuesFromConfiguration": false,
"visible": false,
"columns": [
{
"caption": "Monat",
"name": "Monat",
"width": "auto",
"save": true
},
{
"caption": "Grenze",
"name": "Grenze_W",
"width": "180px",
"save": true,
"edit": {
"type": "NumberSpinner",
"suffix": " W",
"digits": 1
}
},
{
"caption": "Monatsnummer",
"name": "MonatIndex",
"width": "0px",
"visible": false,
"save": true
}
],
"values": []
},
{
"type": "Label",
"name": "PeakShavingTitel",
"caption": "Peak Shaving am Netzanschlusspunkt"
},
{
"type": "CheckBox",
"name": "EinspeisebegrenzungAktiv",
"caption": "Anlagenweite Einspeisebegrenzung aktivieren"
},
{
"type": "NumberSpinner",
"name": "Einspeisegrenze",
"caption": "Maximale Einspeisung der Gesamtanlage",
"suffix": " W",
"minimum": 0.0,
"digits": 1
},
{
"type": "CheckBox",
"name": "AutomatischeSuche",
"caption": "Verbraucher automatisch suchen",
"onChange": "IPS_RequestAction($id, 'FormAutomatischeSuche', $AutomatischeSuche);"
},
{
"type": "SelectObject",
"name": "SuchbereichID",
"caption": "Suchbereich",
"visible": true
},
{
"type": "Button",
"name": "VerbraucherAktualisieren",
"caption": "Verbraucher aktualisieren",
"visible": true,
"onClick": "IPS_RequestAction($id, 'FormVerbraucherAktualisieren', json_encode(['SuchbereichID' => $SuchbereichID, 'Zuordnung' => $AutomatischeVerbraucherZuordnung]));"
},
{
"type": "List",
"name": "VerbraucherZuordnung",
"caption": "Verbraucher manuell auswaehlen",
"rowCount": 6,
"add": true,
"delete": true,
"sortable": true,
"visible": false,
"columns": [
{
"caption": "Instanz",
"name": "InstanzID",
"width": "auto",
"add": 0,
"edit": {"type": "SelectInstance"}
},
{
"caption": "Aktiv",
"name": "Aktiv",
"width": "100px",
"add": false,
"edit": {"type": "CheckBox"}
}
]
},
{
"type": "List",
"name": "AutomatischeVerbraucherZuordnung",
"caption": "Gefundene Verbraucher",
"rowCount": 8,
"add": false,
"delete": false,
"loadValuesFromConfiguration": false,
"visible": true,
"columns": [
{
"caption": "Verbraucher",
"name": "Name",
"width": "auto",
"save": true
},
{
"caption": "Instanz-ID",
"name": "InstanzID",
"width": "100px",
"save": true
},
{
"caption": "Verwenden",
"name": "Aktiv",
"width": "100px",
"save": true,
"edit": {"type": "CheckBox"}
}
],
"values": []
}
]
},
{
"type": "ExpansionPanel",
"caption": "Anlagentopologie",
"expanded": true,
"items": [
{
"type": "RowLayout",
"items": [
{
"type": "SelectVariable",
"name": "NetzbezugEnergieVariableID",
"caption": "Netzbezugszaehler"
},
{
"type": "NumberSpinner",
"name": "NetzbezugEnergiefaktor",
"caption": "Faktor",
"digits": 6
},
{
"type": "SelectVariable",
"name": "NetzeinspeisungEnergieVariableID",
"caption": "Einspeisezaehler"
},
{
"type": "NumberSpinner",
"name": "NetzeinspeisungEnergiefaktor",
"caption": "Faktor",
"digits": 6
}
]
},
{
"type": "Label",
"caption": "Energiezaehler werden in kWh erwartet; ueber den Faktor koennen beispielsweise Wh (0,001) umgerechnet werden."
},
{
"type": "List",
"name": "AnlagenWechselrichter",
"caption": "Wechselrichter",
"rowCount": 4,
"add": true,
"delete": true,
"sortable": true,
"columns": [
{
"caption": "ID",
"name": "ID",
"width": "130px",
"add": "wr-1",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Name",
"name": "Name",
"width": "auto",
"add": "Wechselrichter",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Typ",
"name": "Typ",
"width": "130px",
"add": "pv",
"edit": {
"type": "Select",
"options": [
{"caption": "PV", "value": "pv"},
{"caption": "Batterie", "value": "battery"},
{"caption": "Hybrid", "value": "hybrid"}
]
}
},
{
"caption": "AC Nennleistung",
"name": "ACNennleistung_kW",
"width": "150px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kW",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Istleistung",
"name": "IstleistungVariableID",
"width": "140px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Messfaktor",
"name": "Istleistungsfaktor",
"width": "110px",
"add": 1.0,
"edit": {"type": "NumberSpinner", "digits": 4}
},
{
"caption": "Erzeugungsenergie",
"name": "ErzeugungsenergieVariableID",
"width": "160px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Energiefaktor",
"name": "Erzeugungsenergiefaktor",
"width": "110px",
"add": 1.0,
"edit": {"type": "NumberSpinner", "digits": 6}
},
{
"caption": "PV-Begrenzung",
"name": "BegrenzungVariableID",
"width": "150px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Stellwert",
"name": "Begrenzungsart",
"width": "120px",
"add": "percent",
"edit": {
"type": "Select",
"options": [
{"caption": "Prozent", "value": "percent"},
{"caption": "Watt", "value": "watt"}
]
}
}
]
},
{
"type": "List",
"name": "AnlagenPVFlaechen",
"caption": "PV-Flaechen",
"rowCount": 6,
"add": true,
"delete": true,
"sortable": true,
"columns": [
{
"caption": "ID",
"name": "ID",
"width": "120px",
"add": "pv-1",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Name",
"name": "Name",
"width": "auto",
"add": "PV-Flaeche",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "DC-Leistung",
"name": "DCLeistung_kWp",
"width": "130px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kWp",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Neigung",
"name": "Neigung_Grad",
"width": "110px",
"add": 30.0,
"edit": {
"type": "NumberSpinner",
"suffix": " Grad",
"minimum": 0.0,
"maximum": 90.0,
"digits": 1
}
},
{
"caption": "Azimut",
"name": "Azimut_Grad",
"width": "110px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " Grad",
"minimum": -180.0,
"maximum": 180.0,
"digits": 1
}
},
{
"caption": "Wechselrichter-ID",
"name": "WechselrichterID",
"width": "150px",
"add": "wr-1",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "MPPT",
"name": "MPPT",
"width": "90px",
"add": "",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Module",
"name": "Modulanzahl",
"width": "90px",
"add": 0,
"edit": {
"type": "NumberSpinner",
"minimum": 0,
"digits": 0
}
},
{
"caption": "Modulleistung",
"name": "Modulleistung_Wp",
"width": "130px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " Wp",
"minimum": 0.0,
"digits": 1
}
}
]
},
{
"type": "List",
"name": "AnlagenBatterien",
"caption": "Batteriespeicher",
"rowCount": 4,
"add": true,
"delete": true,
"sortable": true,
"columns": [
{
"caption": "ID",
"name": "ID",
"width": "120px",
"add": "bat-1",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Name",
"name": "Name",
"width": "auto",
"add": "Batteriespeicher",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Nennkapazitaet",
"name": "Nennkapazitaet_kWh",
"width": "150px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kWh",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Nutzkapazitaet",
"name": "Nutzkapazitaet_kWh",
"width": "150px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kWh",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Max. Laden",
"name": "MaxLadeleistung_kW",
"width": "130px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kW",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Max. Entladen",
"name": "MaxEntladeleistung_kW",
"width": "140px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kW",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Wechselrichter-ID",
"name": "WechselrichterID",
"width": "150px",
"add": "wr-1",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Kopplung",
"name": "Kopplung",
"width": "130px",
"add": "ac",
"edit": {
"type": "Select",
"options": [
{"caption": "AC", "value": "ac"},
{"caption": "DC", "value": "dc"},
{"caption": "Hybrid", "value": "hybrid"}
]
}
},
{
"caption": "Leistung",
"name": "LeistungVariableID",
"width": "140px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Messfaktor",
"name": "Leistungsfaktor",
"width": "110px",
"add": 1.0,
"edit": {"type": "NumberSpinner", "digits": 4}
},
{
"caption": "Ladezustand",
"name": "SOCVariableID",
"width": "140px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Ladeenergie",
"name": "LadeenergieVariableID",
"width": "140px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Entladeenergie",
"name": "EntladeenergieVariableID",
"width": "150px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Energiefaktor",
"name": "Energiefaktor",
"width": "110px",
"add": 1.0,
"edit": {"type": "NumberSpinner", "digits": 6}
}
]
}
]
},
{
"type": "ExpansionPanel",
"caption": "Erweiterte Einstellungen",
"items": [
{
"type": "NumberSpinner",
"name": "KeepAlive",
"caption": "Keep alive",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "VerbraucherTimeout",
"caption": "Verbraucher-Timeout",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "MesswertMaxAlter",
"caption": "Maximales Messwertalter",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "Umschaltdifferenz",
"caption": "Mindestdifferenz vor Umschaltung",
"suffix": " %",
"minimum": 0,
"maximum": 100,
"digits": 1
},
{
"type": "NumberSpinner",
"name": "Netzleistungsfaktor",
"caption": "Netzleistungsfaktor",
"digits": 4
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anlegen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Laufendes Debug-Logging aktivieren"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Energieaufzeichnung",
"expanded": true,
"items": [
{
"type": "CheckBox",
"name": "EnergieaufzeichnungAktiv",
"caption": "Leistungs- und Energiewerte automatisch aufzeichnen"
},
{
"type": "Label",
"caption": "PV und Batterie werden aus der Anlagentopologie summiert. Der Hausverbrauch wird aus PV + Netz - Batterie berechnet. Netz: positiv Bezug; Batterie: positiv Laden."
},
{
"type": "CheckBox",
"name": "MesswerteAnzeigen",
"caption": "Einzelne Leistungs- und Energiewerte unter dem Manager anzeigen"
},
{
"type": "RowLayout",
"items": [
{
"type": "Select",
"name": "LeistungsaufzeichnungMinuten",
"caption": "Leistung verdichten",
"options": [
{"caption": "1 Minute", "value": 1},
{"caption": "5 Minuten", "value": 5},
{"caption": "1 Stunde", "value": 60}
]
},
{
"type": "NumberSpinner",
"name": "LeistungLoeschenMonate",
"caption": "Leistung loeschen nach",
"minimum": 0,
"suffix": " Monaten (0 = nie)"
}
]
},
{
"type": "RowLayout",
"items": [
{
"type": "Select",
"name": "EnergieaufzeichnungMinuten",
"caption": "Energie verdichten",
"options": [
{"caption": "1 Minute", "value": 1},
{"caption": "5 Minuten", "value": 5},
{"caption": "1 Stunde", "value": 60}
]
},
{
"type": "NumberSpinner",
"name": "EnergieVerdichtenMonate",
"caption": "Auf Tageswerte verdichten nach",
"minimum": 0,
"suffix": " Monaten"
},
{
"type": "NumberSpinner",
"name": "EnergieLoeschenMonate",
"caption": "Energie loeschen nach",
"minimum": 0,
"suffix": " Monaten (0 = nie)"
}
]
},
{
"type": "CheckBox",
"name": "EnergyPieAnzeigen",
"caption": "Energy Pie unter dem Manager anzeigen",
"onChange": "IPS_RequestAction($id, 'FormEnergyPieAnzeigen', $EnergyPieAnzeigen);"
},
{
"type": "CheckBox",
"name": "EnergiediagrammeAnzeigen",
"caption": "Leistungs- und Energiediagramm unter dem Manager anzeigen",
"onChange": "IPS_RequestAction($id, 'FormEnergiediagrammeAnzeigen', $EnergiediagrammeAnzeigen);"
},
{
"type": "CheckBox",
"name": "FunFactsAnzeigen",
"caption": "Energy Facts unter dem Manager anzeigen",
"onChange": "IPS_RequestAction($id, 'FormFunFactsAnzeigen', $FunFactsAnzeigen);"
},
{
"type": "CheckBox",
"name": "EnergieflussAnzeigen",
"caption": "Energiefluss unter dem Manager anzeigen",
"onChange": "IPS_RequestAction($id, 'FormEnergieflussAnzeigen', $EnergieflussAnzeigen);"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Prognose / Forecast",
"items": [
{
"type": "CheckBox",
"name": "PrognoseAktiv",
"caption": "Prognosen aktivieren",
"onChange": "IPS_RequestAction($id, 'FormPrognoseAktiv', $PrognoseAktiv);"
},
{
"type": "Label",
"name": "PrognoseLizenzInformation",
"caption": "Prognoselizenz wird geprueft."
},
{
"type": "Label",
"caption": "Forecast verwendet dieselben Messquellen aus der Anlagentopologie und den daraus berechneten Hausverbrauch."
},
{
"type": "CheckBox",
"name": "NetzfahrplanAktiv",
"caption": "Intelligenten Netzfahrplan zur Vermeidung von PV-Abregelung verwenden"
},
{
"type": "NumberSpinner",
"name": "PrognoseSendeintervall",
"caption": "Aktuelle Messwerte an den Prognosedienst senden alle",
"minimum": 60,
"maximum": 3600,
"suffix": " Sekunden"
}
]
},
{
"type": "ExpansionPanel",
"caption": "SDL / VGT",
"items": [
{
"type": "ValidationTextBox",
"name": "SDLAnschluss",
"caption": "Konfiguration als JSON"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Stoerueberwachung",
"items": [
{
"type": "CheckBox",
"name": "StoerueberwachungAktiv",
"caption": "Stoerungen sicher an license.enelix.ch uebertragen"
},
{
"type": "NumberSpinner",
"name": "StoerungsSendeintervall",
"caption": "Heartbeat-Intervall",
"minimum": 60,
"maximum": 3600,
"suffix": " Sekunden"
},
{
"type": "Label",
"caption": "Die Freischaltung als jaehrlich erneuerbare Lizenz ist vorbereitet und wird spaeter aktiviert."
}
]
}
],
"actions": [
{
"type": "Button",
"caption": "Regelung jetzt ausfuehren",
"onClick": "IPS_RequestAction($id, 'Regeln', true);"
}
],
"status": [
{"code": 102, "icon": "active", "caption": "Aktiv"},
{"code": 201, "icon": "inactive", "caption": "Netzleistungsmessung fehlt oder ist veraltet"},
{"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"},
{"code": 203, "icon": "error", "caption": "Lizenz nicht freigegeben oder Verbraucherkontingent ueberschritten"}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}",
"name": "Manager",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Manager"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Manager"
}
+3048
View File
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
{
"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": "LastwechselSperrzeit", "caption": "Lastwechsel-Sperrzeit", "suffix": " s", "minimum": 0}
]
},
{
"type": "ExpansionPanel",
"caption": "Pufferspeicher",
"expanded": true,
"items": [
{
"type": "List",
"name": "LeistungsStufen",
"caption": "Leistungsstufen",
"add": true,
"delete": true,
"columns": [
{
"caption": "Stufe",
"name": "Stufe",
"width": "100px",
"add": 1,
"edit": {"type": "NumberSpinner", "minimum": 1}
},
{
"caption": "Leistung",
"name": "Leistung",
"width": "180px",
"add": 0,
"edit": {"type": "NumberSpinner", "minimum": 1, "suffix": " W"}
},
{
"caption": "Schaltkontakt",
"name": "Schaltkontakt_Stufe",
"width": "300px",
"add": 0,
"edit": {"type": "SelectVariable", "validVariableTypes": [0]}
}
]
},
{
"type": "SelectVariable",
"name": "PufferfuehlerVariableID",
"caption": "Puffertemperatur",
"validVariableTypes": [1, 2]
},
{
"type": "SelectVariable",
"name": "AussentemperaturVariableID",
"caption": "Aussentemperatur",
"validVariableTypes": [1, 2]
}
]
},
{
"type": "ExpansionPanel",
"caption": "Heizkurve",
"expanded": true,
"items": [
{
"type": "NumberSpinner",
"name": "FusspunktVorlauftemperatur",
"caption": "Vorlauftemperatur bei 20 Grad C aussen",
"suffix": " Grad C",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "HeizkurvenSteigung",
"caption": "Heizkurvensteigung",
"minimum": 0,
"digits": 2
},
{
"type": "NumberSpinner",
"name": "HeizkurveMinimaltemperatur",
"caption": "Untere Begrenzung der Solltemperatur",
"suffix": " Grad C",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "HeizkurveMaximaltemperatur",
"caption": "Obere Begrenzung der Solltemperatur",
"suffix": " Grad C",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "Hysterese",
"caption": "Hysterese",
"suffix": " K",
"minimum": 0.1,
"digits": 1
},
{
"type": "Select",
"name": "MindesttemperaturModus",
"caption": "Optionale Mindesttemperatur",
"options": [
{"caption": "Aus", "value": 0},
{"caption": "Statisch", "value": 1},
{"caption": "Differenz zur Solltemperatur", "value": 2}
]
},
{
"type": "NumberSpinner",
"name": "Mindesttemperatur",
"caption": "Statische Mindesttemperatur",
"suffix": " Grad C",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "MindesttemperaturDifferenz",
"caption": "Differenz zur Solltemperatur",
"suffix": " K",
"minimum": 0,
"digits": 1
},
{
"type": "SelectVariable",
"name": "WaermepumpenSolltemperaturVariableID",
"caption": "Optionale Solltemperatur der Waermepumpe",
"validVariableTypes": [1, 2]
},
{
"type": "Button",
"name": "HeizkurveVonWaermepumpeUebernehmen",
"caption": "Wert von Waermepumpe uebernehmen",
"onClick": "IPS_RequestAction($id, 'HeizkurveVonWaermepumpeUebernehmen', 0);"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Erweiterte Einstellungen",
"items": [
{"type": "NumberSpinner", "name": "TemperaturMaxAlter", "caption": "Maximales Temperaturalter", "suffix": " s", "minimum": 1},
{"type": "CheckBox", "name": "PuffertemperaturGlaetten", "caption": "Puffertemperatur glaetten"},
{"type": "NumberSpinner", "name": "ZeitKonstante", "caption": "PT1-Zeitkonstante", "suffix": " s", "minimum": 1},
{"type": "CheckBox", "name": "EinstellungenInVisu", "caption": "Einstellungen in der Visualisierung anzeigen"},
{"type": "CheckBox", "name": "DiagnosevariablenAnzeigen", "caption": "Diagnosevariablen anzeigen"},
{"type": "CheckBox", "name": "LoggingEin", "caption": "Diagnoseprotokoll aktivieren"}
]
}
],
"status": [
{"code": 102, "icon": "active", "caption": "Aktiv"},
{"code": 201, "icon": "error", "caption": "Temperaturmessung ungueltig"},
{"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"},
{"code": 203, "icon": "error", "caption": "Schaltfehler"}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}",
"name": "VerbraucherPufferspeicher",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Pufferspeicher"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Pufferspeicher"
}
+899
View File
@@ -0,0 +1,899 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/PufferspeicherRegler.php';
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\PufferspeicherRegler;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnittstelle
{
use VerbraucherBasisTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const STATUS_AKTIV = 102;
private const STATUS_TEMPERATUR_UNGUELTIG = 201;
private const STATUS_KONFIGURATION_UNGUELTIG = 202;
private const STATUS_SCHALTFEHLER = 203;
private const VM_UPDATE = 10603;
/** @var list<string> */
private const DIAGNOSE_VARIABLEN = [
'Istleistung',
'Leistungsquelle',
'Sollleistung',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
'Stoertext',
'AktiveStufe',
'BezogeneEnergie',
'TemperaturenGueltig',
];
public function Create(): void
{
parent::Create();
$this->registriereVerbraucherBasis();
$this->RegisterPropertyString('LeistungsStufen', '[]');
$this->RegisterPropertyInteger('PufferfuehlerVariableID', 0);
$this->RegisterPropertyInteger('AussentemperaturVariableID', 0);
$this->RegisterPropertyInteger('WaermepumpenSolltemperaturVariableID', 0);
$this->RegisterPropertyFloat('FusspunktVorlauftemperatur', 35.0);
$this->RegisterPropertyFloat('HeizkurvenSteigung', 1.0);
$this->RegisterPropertyFloat('HeizkurveMinimaltemperatur', 20.0);
$this->RegisterPropertyFloat('HeizkurveMaximaltemperatur', 80.0);
$this->RegisterPropertyFloat('Hysterese', 5.0);
$this->RegisterPropertyInteger('MindesttemperaturModus', PufferspeicherRegler::MINIMUM_AUS);
$this->RegisterPropertyFloat('Mindesttemperatur', 20.0);
$this->RegisterPropertyFloat('MindesttemperaturDifferenz', 5.0);
$this->RegisterPropertyInteger('LastwechselSperrzeit', 5);
$this->RegisterPropertyInteger('TemperaturMaxAlter', 120);
$this->RegisterPropertyBoolean('PuffertemperaturGlaetten', false);
$this->RegisterPropertyInteger('ZeitKonstante', 120);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
$this->RegisterVariableFloat('Puffertemperatur', 'Puffertemperatur', '~Temperature', 100);
$this->RegisterVariableFloat('Aussentemperatur', 'Aussentemperatur', '~Temperature', 110);
$this->RegisterVariableFloat('Solltemperatur', 'Solltemperatur', '~Temperature', 120);
$this->RegisterVariableBoolean('Heizbedarf', 'Heizbedarf', '~Switch', 130);
$this->RegisterAttributeInteger('RegistriertePufferID', 0);
$this->RegisterAttributeInteger('RegistrierteAussenID', 0);
$this->RegisterAttributeInteger('LetzteBerechnung', 0);
$this->RegisterAttributeInteger('LetzteTemperaturberechnung', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzterLastwechsel', 0);
$this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeFloat('Glaettungswert', 0.0);
$this->RegisterAttributeBoolean('GlaettungInitialisiert', false);
$this->RegisterAttributeString('Leistungsangebot', '[0]');
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
$this->RegisterAttributeString('Schaltfehler', '');
$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->RegisterAttributeInteger('ZustandAktiveStufe', 0);
$this->RegisterAttributeFloat('ZustandBezogeneEnergie', 0.0);
$this->RegisterAttributeBoolean('ZustandTemperaturenGueltig', false);
$this->RegisterTimer(
'LastwechselFreigabe',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'LastwechselFreigabe', false);"
);
$this->RegisterTimer(
'Meldezyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
);
$this->RegisterTimer(
'RueckmeldungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->aktualisiereVariablen();
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->deaktiviereTimer();
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeZustand('Stoerung', true);
$this->setzeZustand('Stoertext', $fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->registriereTemperaturmeldungen();
$this->SetTimerInterval('LastwechselFreigabe', 0);
$this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000);
$this->regelzyklus(true);
}
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
{
if ((int) $nachricht !== self::VM_UPDATE) {
return;
}
if (in_array((int) $senderID, [
$this->ReadPropertyInteger('PufferfuehlerVariableID'),
$this->ReadPropertyInteger('AussentemperaturVariableID'),
], true)) {
$this->regelzyklus(true);
}
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false);
}
$this->regelzyklus(true);
return;
case 'HeizkurveVonWaermepumpeUebernehmen':
$this->uebernehmeWaermepumpenwert();
return;
case 'LastwechselFreigabe':
$this->SetTimerInterval('LastwechselFreigabe', 0);
$this->regelzyklus(true);
return;
case 'Melden':
if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
}
$this->regelzyklus(false);
$this->sendeVerbraucherdaten();
return;
case 'ManagerdatenEmpfangen':
if (!is_string($wert)) {
throw new InvalidArgumentException('Managerdaten muessen als JSON uebergeben werden.');
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException('Managerdaten muessen ein JSON-Objekt sein.');
}
$this->ManagerdatenEmpfangen($daten);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
/** @param array<string, mixed> $daten */
public function ManagerdatenEmpfangen(array $daten): void
{
Nachrichtenvertrag::pruefeManagerdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
throw new InvalidArgumentException('Managerdaten sind an eine andere Instanz adressiert.');
}
$managerID = $daten['Kopf']['AbsenderID'];
if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) {
throw new InvalidArgumentException('Der Manager hat diesen Pufferspeicher nicht aktiv zugeordnet.');
}
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->regelzyklus(false);
$angebot = $this->leseLeistungsangebot();
$sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) {
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->regelzyklus(false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
return;
}
if (!in_array($sollleistung, $angebot, true)) {
throw new InvalidArgumentException('Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.');
}
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
$this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true);
$this->regelzyklus(false);
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
private function regelzyklus(bool $meldungPlanen): void
{
try {
$jetzt = time();
$this->aktualisiereEnergie($jetzt);
$temperaturenGueltig = $this->aktualisiereTemperaturen();
$stufen = PufferspeicherRegler::dekodiereLeistungsstufen(
$this->ReadPropertyString('LeistungsStufen')
);
$solltemperatur = 0.0;
$einschaltschwelle = 0.0;
if ($temperaturenGueltig) {
$solltemperatur = PufferspeicherRegler::solltemperatur(
(float) $this->GetValue('Aussentemperatur'),
$this->ReadPropertyFloat('FusspunktVorlauftemperatur'),
$this->ReadPropertyFloat('HeizkurvenSteigung'),
$this->ReadPropertyFloat('HeizkurveMinimaltemperatur'),
$this->ReadPropertyFloat('HeizkurveMaximaltemperatur')
);
$einschaltschwelle = PufferspeicherRegler::einschaltschwelle(
$solltemperatur,
$this->ReadPropertyFloat('Hysterese'),
$this->ReadPropertyInteger('MindesttemperaturModus'),
$this->ReadPropertyFloat('Mindesttemperatur'),
$this->ReadPropertyFloat('MindesttemperaturDifferenz')
);
}
$this->SetValue('Solltemperatur', $solltemperatur);
$aktiv = (bool) $this->GetValue('Aktiv');
$peak = $this->ReadAttributeString('Betriebsart') === Nachrichtenvertrag::BETRIEBSART_PEAK;
$angebot = PufferspeicherRegler::leistungsangebot(
$aktiv,
$peak,
$temperaturenGueltig,
(float) $this->GetValue('Puffertemperatur'),
$einschaltschwelle,
$stufen
);
$heizbedarf = count($angebot) > 1;
$this->SetValue('Heizbedarf', $heizbedarf);
$verfuegbar = $aktiv && $temperaturenGueltig && $stufen !== [];
$aenderungMoeglich = $verfuegbar && count($angebot) > 1;
$zwangsleistung = (!$verfuegbar || !$heizbedarf) ? 0 : null;
$aktuelleLeistung = (int) round((float) $this->leseZustand('Istleistung'));
$restzeit = PufferspeicherRegler::restzeitBisLastwechsel(
$this->ReadAttributeInteger('LetzterLastwechsel'),
$jetzt,
$this->ReadPropertyInteger('LastwechselSperrzeit')
);
$lastwechselGesperrt = $restzeit > 0 && $verfuegbar && $zwangsleistung !== 0;
$this->SetTimerInterval('LastwechselFreigabe', $lastwechselGesperrt ? $restzeit * 1000 : 0);
if ($lastwechselGesperrt) {
$angebot = [$aktuelleLeistung];
$aenderungMoeglich = false;
}
$letzteVorgabe = $this->ReadAttributeInteger('LetzteVorgabeZeit');
if ($letzteVorgabe <= 0
|| $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout')
) {
$this->setzeZustand('SollwertGueltig', false);
}
$ziel = 0;
if ($lastwechselGesperrt) {
$ziel = $aktuelleLeistung;
} elseif ($zwangsleistung !== null) {
$ziel = $zwangsleistung;
} elseif ((bool) $this->leseZustand('SollwertGueltig')) {
$ziel = (int) $this->leseZustand('Sollleistung');
if (!in_array($ziel, $angebot, true)) {
$this->setzeZustand('SollwertGueltig', false);
$ziel = 0;
}
}
$lastwechsel = $this->schalteLeistung($ziel, $jetzt);
if ($lastwechsel) {
$angebot = [$ziel];
$aenderungMoeglich = false;
}
$this->WriteAttributeString('Leistungsangebot', json_encode($angebot, JSON_THROW_ON_ERROR));
$this->setzeZustand('Sollleistung', $ziel);
$this->setzeZustand('Verfuegbar', $verfuegbar);
$this->setzeZustand('AenderungMoeglich', $aenderungMoeglich);
$this->aktualisiereStoerungen($temperaturenGueltig);
if ($meldungPlanen) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
} catch (Throwable $fehler) {
$this->WriteAttributeString('Schaltfehler', $fehler->getMessage());
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeZustand('Stoerung', true);
$this->setzeZustand('Stoertext', $fehler->getMessage());
$this->SetStatus(self::STATUS_SCHALTFEHLER);
$this->protokolliere('Regelzyklus', $fehler->getMessage());
}
}
private function aktualisiereTemperaturen(): bool
{
$pufferID = $this->ReadPropertyInteger('PufferfuehlerVariableID');
$aussenID = $this->ReadPropertyInteger('AussentemperaturVariableID');
if (!$this->temperaturwertGueltig($pufferID) || !$this->temperaturwertGueltig($aussenID)) {
$this->setzeZustand('TemperaturenGueltig', false);
return false;
}
$puffertemperatur = (float) GetValue($pufferID);
$aussentemperatur = (float) GetValue($aussenID);
$jetzt = time();
$letzteBerechnung = $this->ReadAttributeInteger('LetzteTemperaturberechnung');
$this->WriteAttributeInteger('LetzteTemperaturberechnung', $jetzt);
if ($this->ReadPropertyBoolean('PuffertemperaturGlaetten')) {
if (!$this->ReadAttributeBoolean('GlaettungInitialisiert')) {
$this->WriteAttributeFloat('Glaettungswert', $puffertemperatur);
$this->WriteAttributeBoolean('GlaettungInitialisiert', true);
} elseif ($letzteBerechnung > 0 && $jetzt > $letzteBerechnung) {
$puffertemperatur = PufferspeicherRegler::pt1(
$this->ReadAttributeFloat('Glaettungswert'),
$puffertemperatur,
$jetzt - $letzteBerechnung,
$this->ReadPropertyInteger('ZeitKonstante')
);
$this->WriteAttributeFloat('Glaettungswert', $puffertemperatur);
} else {
$puffertemperatur = $this->ReadAttributeFloat('Glaettungswert');
}
} else {
$this->WriteAttributeBoolean('GlaettungInitialisiert', false);
}
$this->SetValue('Puffertemperatur', $puffertemperatur);
$this->SetValue('Aussentemperatur', $aussentemperatur);
$this->setzeZustand('TemperaturenGueltig', true);
return true;
}
private function temperaturwertGueltig(int $variablenID): bool
{
if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) {
return false;
}
$variable = IPS_GetVariable($variablenID);
if (!in_array((int) $variable['VariableType'], [1, 2], true)) {
return false;
}
if (time() - (int) $variable['VariableUpdated'] > $this->ReadPropertyInteger('TemperaturMaxAlter')) {
return false;
}
$wert = GetValue($variablenID);
return is_int($wert) || is_float($wert);
}
private function uebernehmeWaermepumpenwert(): void
{
$wpID = $this->ReadPropertyInteger('WaermepumpenSolltemperaturVariableID');
$aussenID = $this->ReadPropertyInteger('AussentemperaturVariableID');
if (!$this->temperaturwertGueltig($wpID) || !$this->temperaturwertGueltig($aussenID)) {
throw new RuntimeException('Waermepumpen-Solltemperatur oder Aussentemperatur ist ungueltig.');
}
$fusspunkt = PufferspeicherRegler::fusspunktAusWaermepumpenwert(
(float) GetValue($wpID),
(float) GetValue($aussenID),
$this->ReadPropertyFloat('HeizkurvenSteigung')
);
IPS_SetProperty($this->InstanceID, 'FusspunktVorlauftemperatur', $fusspunkt);
IPS_ApplyChanges($this->InstanceID);
$this->protokolliere('Heizkurve', ['FusspunktVorlauftemperatur' => $fusspunkt]);
}
private function schalteLeistung(int $zielLeistung, int $jetzt): bool
{
$stufen = PufferspeicherRegler::dekodiereLeistungsstufen(
$this->ReadPropertyString('LeistungsStufen')
);
$zielStufe = PufferspeicherRegler::stufeFuerLeistung($stufen, $zielLeistung);
$bisher = (int) round((float) $this->leseZustand('Istleistung'));
if ($zielLeistung === $bisher && $this->kontakteEntsprechenZiel($stufen, $zielLeistung)) {
$this->WriteAttributeString('Schaltfehler', '');
return false;
}
try {
foreach ($stufen as $stufe) {
RequestAction($stufe['KontaktID'], false);
}
foreach ($stufen as $stufe) {
if ($stufe['Leistung_W'] === $zielLeistung && $zielLeistung > 0) {
RequestAction($stufe['KontaktID'], true);
break;
}
}
} catch (Throwable $fehler) {
foreach ($stufen as $stufe) {
try {
RequestAction($stufe['KontaktID'], false);
} catch (Throwable $ignoriert) {
}
}
$this->setzeZustand('Istleistung', 0.0);
$this->setzeZustand('AktiveStufe', 0);
throw new RuntimeException('Leistungsstufe konnte nicht sicher geschaltet werden: ' . $fehler->getMessage());
}
$this->WriteAttributeString('Schaltfehler', '');
$this->setzeZustand('Istleistung', (float) $zielLeistung);
$this->setzeZustand('Leistungsquelle', Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET);
$this->setzeZustand('AktiveStufe', $zielStufe);
$this->WriteAttributeInteger('LetzterLastwechsel', $jetzt);
$this->protokolliere('Leistungsstufe', ['Leistung_W' => $zielLeistung, 'Stufe' => $zielStufe]);
return true;
}
/** @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen */
private function kontakteEntsprechenZiel(array $stufen, int $zielLeistung): bool
{
foreach ($stufen as $stufe) {
if (!IPS_VariableExists($stufe['KontaktID'])) {
return false;
}
$sollEin = $zielLeistung > 0 && $stufe['Leistung_W'] === $zielLeistung;
if ((bool) GetValue($stufe['KontaktID']) !== $sollEin) {
return false;
}
}
return true;
}
private function aktualisiereEnergie(int $jetzt): void
{
$letzteBerechnung = $this->ReadAttributeInteger('LetzteBerechnung');
$this->WriteAttributeInteger('LetzteBerechnung', $jetzt);
if ($letzteBerechnung <= 0 || $jetzt <= $letzteBerechnung) {
return;
}
$zuwachs = PufferspeicherRegler::energieKWh(
(float) $this->leseZustand('Istleistung'),
$jetzt - $letzteBerechnung
);
$this->setzeZustand(
'BezogeneEnergie',
(float) $this->leseZustand('BezogeneEnergie') + $zuwachs
);
}
private function aktualisiereStoerungen(bool $temperaturenGueltig): void
{
$stoerungen = [];
if (!$temperaturenGueltig) {
$stoerungen[] = 'Puffer- oder Aussentemperatur fehlt oder ist veraltet.';
}
if ($this->ReadAttributeString('Schaltfehler') !== '') {
$stoerungen[] = $this->ReadAttributeString('Schaltfehler');
}
$this->setzeZustand('Stoerung', $stoerungen !== []);
$this->setzeZustand('Stoertext', implode("
", array_values(array_unique($stoerungen))));
if ($this->ReadAttributeString('Schaltfehler') !== '') {
$this->SetStatus(self::STATUS_SCHALTFEHLER);
} elseif (!$temperaturenGueltig) {
$this->SetStatus(self::STATUS_TEMPERATUR_UNGUELTIG);
} else {
$this->SetStatus(self::STATUS_AKTIV);
}
}
private function sendeVerbraucherdaten(): void
{
foreach ($this->zugeordneteManagerIDs() as $managerID) {
$daten = $this->baueVerbraucherdaten($managerID);
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
try {
IPS_RequestAction(
$managerID,
'VerbraucherdatenEmpfangen',
json_encode($daten, JSON_THROW_ON_ERROR)
);
} catch (Throwable $fehler) {
$this->protokolliere('Managerkommunikation', $fehler->getMessage());
}
}
}
/** @return array<string, mixed> */
private function baueVerbraucherdaten(int $managerID): array
{
$temperaturenGueltig = (bool) $this->leseZustand('TemperaturenGueltig');
$schaltfehler = $this->ReadAttributeString('Schaltfehler');
return [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $this->InstanceID,
'EmpfaengerID' => $managerID,
'Zeitpunkt' => time(),
],
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET,
'Zustand' => [
[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung')
: null,
'Einheit' => 'W',
],
[
'Kennung' => 'Puffertemperatur_C',
'Art' => 'Istwert',
'Wert' => $temperaturenGueltig ? (float) $this->GetValue('Puffertemperatur') : null,
'Einheit' => 'C',
],
[
'Kennung' => 'Aussentemperatur_C',
'Art' => 'Istwert',
'Wert' => $temperaturenGueltig ? (float) $this->GetValue('Aussentemperatur') : null,
'Einheit' => 'C',
],
[
'Kennung' => 'Solltemperatur_C',
'Art' => 'Sollwert',
'Wert' => $temperaturenGueltig ? (float) $this->GetValue('Solltemperatur') : null,
'Einheit' => 'C',
],
[
'Kennung' => 'Heizbedarf',
'Art' => 'Status',
'Wert' => (bool) $this->GetValue('Heizbedarf'),
'Einheit' => '',
],
[
'Kennung' => 'AktiveStufe',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('AktiveStufe'),
'Einheit' => '',
],
[
'Kennung' => 'Fuehlerfehler',
'Art' => 'Stoerung',
'Wert' => !$temperaturenGueltig,
'Einheit' => '',
'Text' => $temperaturenGueltig
? ''
: 'Puffer- oder Aussentemperatur fehlt oder ist veraltet.',
],
[
'Kennung' => 'Schaltfehler',
'Art' => 'Stoerung',
'Wert' => $schaltfehler !== '',
'Einheit' => '',
'Text' => $schaltfehler,
],
],
];
}
/** @return list<int> */
private function zugeordneteManagerIDs(): array
{
$ergebnis = [];
foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) {
try {
$automatisch = (bool) IPS_GetProperty($managerID, 'AutomatischeSuche');
$property = $automatisch
? 'AutomatischeVerbraucherZuordnung'
: 'VerbraucherZuordnung';
$zuordnung = $this->leseManagerZuordnung($managerID, $property);
if ($automatisch && $zuordnung === []) {
$zuordnung = $this->leseManagerZuordnung($managerID, 'VerbraucherZuordnung');
}
} catch (Throwable $fehler) {
continue;
}
foreach ($zuordnung as $eintrag) {
if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) {
continue;
}
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
if ($instanzID === $this->InstanceID) {
$ergebnis[] = (int) $managerID;
break;
}
}
}
return array_values(array_unique($ergebnis));
}
/** @return array<mixed> */
private function leseManagerZuordnung(int $managerID, string $property): array
{
$zuordnung = json_decode(
(string) IPS_GetProperty($managerID, $property),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($zuordnung) ? $zuordnung : [];
}
private function pruefeKonfiguration(): void
{
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException($property . ' muss mindestens 0 sein.');
}
}
foreach ([
'Meldeintervall',
'VorgabeTimeout',
'TemperaturMaxAlter',
'ZeitKonstante',
] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
}
}
if ($this->ReadPropertyInteger('LastwechselSperrzeit') < 0) {
throw new InvalidArgumentException('LastwechselSperrzeit darf nicht negativ sein.');
}
if ($this->ReadPropertyFloat('Hysterese') <= 0.0) {
throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.');
}
PufferspeicherRegler::solltemperatur(
20.0,
$this->ReadPropertyFloat('FusspunktVorlauftemperatur'),
$this->ReadPropertyFloat('HeizkurvenSteigung'),
$this->ReadPropertyFloat('HeizkurveMinimaltemperatur'),
$this->ReadPropertyFloat('HeizkurveMaximaltemperatur')
);
PufferspeicherRegler::einschaltschwelle(
50.0,
$this->ReadPropertyFloat('Hysterese'),
$this->ReadPropertyInteger('MindesttemperaturModus'),
$this->ReadPropertyFloat('Mindesttemperatur'),
$this->ReadPropertyFloat('MindesttemperaturDifferenz')
);
foreach (['PufferfuehlerVariableID', 'AussentemperaturVariableID'] as $property) {
$variablenID = $this->ReadPropertyInteger($property);
if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) {
throw new InvalidArgumentException($property . ' ist nicht eingerichtet.');
}
$variable = IPS_GetVariable($variablenID);
if (!in_array((int) $variable['VariableType'], [1, 2], true)) {
throw new InvalidArgumentException($property . ' muss eine Integer- oder Floatvariable sein.');
}
}
$wpID = $this->ReadPropertyInteger('WaermepumpenSolltemperaturVariableID');
if ($wpID > 0) {
if (!IPS_VariableExists($wpID)) {
throw new InvalidArgumentException('WaermepumpenSolltemperaturVariableID existiert nicht.');
}
$wpVariable = IPS_GetVariable($wpID);
if (!in_array((int) $wpVariable['VariableType'], [1, 2], true)) {
throw new InvalidArgumentException(
'WaermepumpenSolltemperaturVariableID muss eine Integer- oder Floatvariable sein.'
);
}
}
$stufen = PufferspeicherRegler::dekodiereLeistungsstufen(
$this->ReadPropertyString('LeistungsStufen')
);
if ($stufen === []) {
throw new InvalidArgumentException('Mindestens eine Leistungsstufe ist erforderlich.');
}
foreach ($stufen as $stufe) {
if (!IPS_VariableExists($stufe['KontaktID'])) {
throw new InvalidArgumentException('Schaltkontakt ' . $stufe['KontaktID'] . ' existiert nicht.');
}
$kontakt = IPS_GetVariable($stufe['KontaktID']);
if ((int) $kontakt['VariableType'] !== 0) {
throw new InvalidArgumentException('Alle Schaltkontakte muessen Booleanvariablen sein.');
}
}
}
private function registriereTemperaturmeldungen(): void
{
$zuordnungen = [
'RegistriertePufferID' => $this->ReadPropertyInteger('PufferfuehlerVariableID'),
'RegistrierteAussenID' => $this->ReadPropertyInteger('AussentemperaturVariableID'),
];
foreach ($zuordnungen as $attribut => $neueID) {
$alteID = $this->ReadAttributeInteger($attribut);
if ($alteID > 0 && $alteID !== $neueID) {
$this->UnregisterMessage($alteID, self::VM_UPDATE);
}
if ($neueID > 0 && $neueID !== $alteID) {
$this->RegisterMessage($neueID, self::VM_UPDATE);
}
$this->WriteAttributeInteger($attribut, $neueID);
}
}
private function aktualisiereVariablen(): void
{
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if ($ident === 'Leistungsquelle') {
continue;
}
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
$this->setzeZustand($ident, GetValue($variablenID));
}
}
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->registriereVerbraucherDiagnose();
$this->RegisterVariableInteger('AktiveStufe', 'Aktive Stufe', '', 200);
$this->RegisterVariableFloat('BezogeneEnergie', 'Bezogene Energie', '~Electricity', 210);
$this->RegisterVariableBoolean(
'TemperaturenGueltig',
'Temperaturen gueltig',
'~Switch',
220
);
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$wert = $ident === 'Leistungsquelle'
? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET
: $this->leseZustand($ident);
$this->setzeZustand($ident, $wert);
}
} else {
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
$this->UnregisterVariable($ident);
}
}
}
}
/** @param bool|int|float|string $wert */
private function setzeZustand(string $ident, $wert): void
{
$booleanAttribute = [
'SollwertGueltig' => 'ZustandSollwertGueltig',
'Verfuegbar' => 'ZustandVerfuegbar',
'AenderungMoeglich' => 'ZustandAenderungMoeglich',
'Stoerung' => 'ZustandStoerung',
'TemperaturenGueltig' => 'ZustandTemperaturenGueltig',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'AktiveStufe' => 'ZustandAktiveStufe',
];
$floatAttribute = [
'Istleistung' => 'ZustandIstleistung',
'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);
} elseif ($ident !== 'Leistungsquelle') {
throw new LogicException('Unbekannter interner Zustand: ' . $ident);
}
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
SetValue($variablenID, $wert);
}
}
/** @return bool|int|float|string */
private function leseZustand(string $ident)
{
$booleanAttribute = [
'SollwertGueltig' => 'ZustandSollwertGueltig',
'Verfuegbar' => 'ZustandVerfuegbar',
'AenderungMoeglich' => 'ZustandAenderungMoeglich',
'Stoerung' => 'ZustandStoerung',
'TemperaturenGueltig' => 'ZustandTemperaturenGueltig',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'AktiveStufe' => 'ZustandAktiveStufe',
];
$floatAttribute = [
'Istleistung' => 'ZustandIstleistung',
'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 Zustand: ' . $ident);
}
/** @return list<int> */
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 deaktiviereTimer(): void
{
foreach (['LastwechselFreigabe', 'Meldezyklus', 'RueckmeldungVerzoegert'] 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
);
}
}
+93 -14
View File
@@ -4,21 +4,69 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
## Status ## Status
Das Repository befindet sich im Aufbau. Aktuell ist ausschliesslich der gemeinsame Nachrichtenvertrag implementiert. Noch nicht implementierte Module werden nicht als leere Platzhalter angelegt. Version 0.1 Build 1 ist die erste Beta fuer kontrollierte Anlagen- und
Feldtests. Der gemeinsame Nachrichtenvertrag, die Verbraucherbasis, der Manager
und alle unten aufgefuehrten Module sind implementiert. Produktiver Einsatz
setzt eine anlagenspezifische Pruefung, Sicherung und Rueckfallplanung voraus.
## Geplante Module ## Module
- Manager - Manager (implementiert)
- Batterie - Batterie (implementiert)
- Wassererwaermer - Wassererwaermer (implementiert)
- Pufferspeicher - Pufferspeicher (implementiert)
- Verbraucher 1-Stufig - Verbraucher 1-Stufig (implementiert)
- Waermepumpe - Waermepumpe (implementiert)
- Ladestation Stand-Alone - Ladestation Stand-Alone (implementiert)
- Ladestation Gateway - Ladestation Gateway (implementiert)
- Easee Gateway - Easee Gateway (implementiert)
Die Verbraucher kommunizieren mit dem Manager ueber den gemeinsamen Vertrag in `libs/`. Der Manager waehlt die Verbraucher aus; in einem Verbraucher gibt es keine Manager-ID-Property. Die Properties, Variablen, Zustandsdaten und offenen Punkte stehen in der
[Modulübersicht](docs/module/README.md). Gemeinsame Felder werden dort nicht
abweichend neu definiert, sondern verweisen auf den zentralen Vertrag.
Die Verbraucher kommunizieren mit dem Manager ueber den gemeinsamen Vertrag
in `libs/`. Der Manager waehlt die Verbraucher aus; in einem Verbraucher gibt
es keine Manager-ID-Property.
## Betriebsartabhaengige Leistungsangebote
Der Nachrichtenvertrag `4.0` uebermittelt in beiden Richtungen verpflichtend
die aktuelle `Betriebsart`. Zulaessige Werte sind `PV` fuer Solarbetrieb und
`Peak` fuer die Lastspitzenbegrenzung. Dadurch kann jeder Verbraucher fuer
beide Betriebsarten unterschiedliche PowerSteps melden.
Ein Betriebsartwechsel wird synchronisiert:
1. Der Manager bestimmt die neue Betriebsart.
2. Er sendet sie mit `Sollleistung_W: null` an alle aktiven Verbraucher.
3. `null` ist nur eine Ankuendigung und kein Schaltbefehl.
4. Jeder Verbraucher berechnet sein zustandsabhaengiges Leistungsangebot neu
und meldet es mit derselben Betriebsart zurueck.
5. Der Manager verteilt konkrete Sollleistungen an alle bereits
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. Ein
nicht antwortender Verbraucher blockiert die Regelung der uebrigen Anlage
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 1-Stufig | Normal `[0, Nennleistung]`; bei faelliger Tagesmindestlaufzeit `[Nennleistung]` | Normal `[0]`; bei faelliger Tagesmindestlaufzeit konfigurierbar `[0, Nennleistung]` oder `[Nennleistung]` |
| Ladestation Stand-Alone | Mit Solarladen `[0, ...Ladestufen]`, sonst maximale Ladeleistung | Mit Solarladen `[0]`, sonst `[0, ...Ladestufen]` |
| Ladestation Gateway | Mit Solarladen `[0, ...Ladestufen]`, sonst maximale Ladeleistung | Mit Solarladen `[0]`, sonst `[0, ...Ladestufen]` |
| Warmwassererwaermer | Unter Mindesttemperatur maximale Stufe, sonst zustandsabhaengig | Unter wirksamer Mindesttemperatur `[0, ...Leistungsstufen]`, sonst `[0]` |
| Pufferspeicher | Unter Einschaltschwelle `[0, ...Leistungsstufen]`, sonst `[0]` | Unabhaengig vom Zustand `[0]` |
| Batterie | SoC-abhaengiges Lade-/Entladeraster | SoC-, Reserve- und netzabhaengiges Angebot |
Die vollstaendigen Nachrichtenfelder und JSON-Beispiele stehen in der
[Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md). Hintergrund,
Alternativen und Folgen beschreibt
[ADR 0004](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md).
## Voraussetzungen ## Voraussetzungen
@@ -32,7 +80,18 @@ composer install
composer check composer check
``` ```
Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingecheckt. Die CI-Pruefung muss vor der Uebernahme in einen Freigabebranch erfolgreich sein. Der Gesamttest schliesst alle Modulsuiten ein. Der einstufige Verbraucher kann
zusaetzlich unabhaengig ausgefuehrt werden:
```bash
composer check:verbraucher-einstufig
```
Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingecheckt.
Jedes Modul besitzt zusätzlich einen standardisierten Laufzeittest für IP-Symcon
8.x. Einzel-, Änderungs- und Gesamtläufe sowie Cleanup und Berichte sind im
[Testleitfaden](docs/testing/README.md) beschrieben. Die CI-Pruefung muss vor
der Uebernahme in einen Freigabebranch erfolgreich sein.
| Git-Branch | IP-Symcon-Kanal | | Git-Branch | IP-Symcon-Kanal |
| --- | --- | | --- | --- |
@@ -40,5 +99,25 @@ Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingeche
| `beta` | Beta | | `beta` | Beta |
| `develop` | Testing | | `develop` | Testing |
Die ausfuehrliche Schnittstellenbeschreibung steht unter [docs/Schnittstelle.md](docs/Schnittstelle.md). ## Dokumentation
- [Changelog](CHANGELOG.md)
- [Zentrale Liste offener Teamentscheidungen](docs/Offene-Punkte.md)
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
- [Batterieschnittstelle](docs/Schnittstelle-Batterie.md)
- [ADR 0004: Betriebsartabhaengige Leistungsangebote](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md)
- [ADR 0005: Anlagentopologie fuer Prognosen](docs/adr/0005-anlagentopologie-fuer-prognosen.md)
- [Manager-Modul inklusive Lizenzierung](docs/module/Manager/README.md)
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md)
- [Pufferspeicher-Modul](docs/module/Pufferspeicher/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)
- [Ladestation-Gateway-Modul](docs/module/Ladestation-Gateway/README.md)
- [Easee-Gateway-Modul](docs/module/Easee-Gateway/README.md)
- [Easee-Gateway-Schnittstelle](docs/Schnittstelle-Easee-Gateway.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)
+172
View File
@@ -0,0 +1,172 @@
# Verbraucher 1-Stufig
IP-Symcon-Modul fuer einen elektrischen Verbraucher, der genau zwei
Leistungszustaende kennt: aus (`0 W`) und ein (`Nennleistung`).
Das Modul ist fuer IP-Symcon ab Version 8.0 und den Enelix-Nachrichtenvertrag
`4.0` ausgelegt. Es arbeitet ereignisbasiert und besitzt weder einen
Regelzyklus noch eine konfigurierbare Zyklusanzahl oder Zykluszeit.
## Funktionen
- Schalten eines Boolean-Aktors auf `0 W` oder die konfigurierte Nennleistung
- optionale separate Schaltzustands-Rueckmeldung
- Mindest-Einschalt- und Mindest-Ausschaltdauer
- taegliche Mindestlaufzeit
- getrennte Prioritaeten fuer PV- und Peakbetrieb
- ereignisbasierte Zustandsmeldung an den Enelix Manager
- Diagnosevariablen und optionales Debug-Logging
- sichere lokale Deaktivierung ueber die Variable `Aktiv`
## Installation
1. Im IP-Symcon Module Control die Bibliothek
`https://git.belevo.ch/ENELIX/Enelix-EMS.git` installieren.
2. Fuer Entwicklung und Tests den Kanal beziehungsweise Branch `develop`
verwenden.
3. Unter **Instanz hinzufuegen** nach **Verbraucher 1-Stufig** suchen.
4. Eine Instanz anlegen und mindestens Nennleistung und Schaltkontakt
konfigurieren.
## Konfiguration
| Einstellung | Standard | Beschreibung |
| --- | ---: | --- |
| `PrioritaetPV` | `0` | Reihenfolge bei der PV-Leistungsverteilung. |
| `PrioritaetPeak` | `0` | Reihenfolge bei der Lastspitzenregelung. |
| `Meldeintervall` | `10 s` | Periodische Vollmeldung an zugeordnete Manager. |
| `VorgabeTimeout` | `120 s` | Gueltigkeitsdauer einer Manager-Vorgabe. |
| `Mindesteinschaltdauer` | `5 s` | Mindestdauer eines bestaetigten Ein-Zustands. |
| `Mindestausschaltdauer` | `5 s` | Mindestdauer eines bestaetigten Aus-Zustands. |
| `Nennleistung` | `0 W` | Leistungsaufnahme im eingeschalteten Zustand. |
| `SchaltkontaktVariableID` | `0` | Booleanvariable des zu schaltenden Aktors. |
| `SchaltkontaktInvertiert` | `false` | Invertiert die Aktorlogik. |
| `RueckmeldungVariableID` | `0` | Optionale Booleanvariable fuer den physischen Zustand. |
| `Mindestlaufzeit` | `0 s` | Geforderte Laufzeit pro lokalem Kalendertag. |
| `PeakSperreBeiMindestlaufzeitAnbieten` | `true` | Erlaubt im Peakbetrieb eine Sperre trotz faelliger Tagesmindestlaufzeit. |
| `DiagnosevariablenAnzeigen` | `false` | Blendet technische Diagnosevariablen ein. |
| `LoggingEin` | `false` | Aktiviert zusaetzliche Debug-Ausgaben. |
`Nennleistung` muss groesser als `0` sein. Der Schaltkontakt muss eine
Booleanvariable mit funktionsfaehiger Standard- oder benutzerdefinierter
Aktion sein. Beide Mindestzeiten duerfen auf `0` gesetzt werden.
Der fruehere allgemeine `Umschaltabstand` sowie Properties fuer Zyklusanzahl
und Zykluszeit existieren nicht.
## Verhalten ohne separate Rueckmeldung
Ist keine `RueckmeldungVariableID` konfiguriert, wird die Aktorvariable als
unmittelbare Schaltbestaetigung verwendet.
Nach einem erfolgreichen Schaltbefehl beginnt ab dem uebernommenen
Aktorzustand:
- beim Einschalten die `Mindesteinschaltdauer`,
- beim Ausschalten die `Mindestausschaltdauer`.
Uebernimmt die Aktorvariable den angeforderten Wert nicht, meldet das Modul
einen Schaltfehler und stellt sich dem Manager nicht als schaltbereit dar.
## Verhalten mit separater Rueckmeldung
Ist eine `RueckmeldungVariableID` konfiguriert, bestimmt ausschliesslich deren
Booleanwert den bestaetigten Schaltzustand. `true` muss dabei physisch
eingeschaltet bedeuten.
Zwischen Aktorbefehl und passender Rueckmeldung meldet das Modul:
- den bisherigen bestaetigten Schaltzustand und die bisherige Istleistung,
- `SchaltbefehlAusstehend=true`,
- `Schaltbereit=false`,
- `AenderungMoeglich=false`.
Die jeweilige Mindestzeit beginnt erst, sobald die Rueckmeldung den neuen
Zustand bestaetigt. Bleibt die Rueckmeldung aus, bleibt der Schaltbefehl
sichtbar ausstehend. Eine abweichende Rueckmeldung ohne laufenden
Schaltvorgang wird als Rueckmeldefehler gemeldet.
## Mindestzeiten und Leistungsangebot
Waerend einer Mindest-Ein- oder Mindest-Aus-Zeit bietet das Modul nur die
bestaetigte aktuelle Leistung an. Ein regulaerer Lastwechsel ist erst nach
Ablauf der Mindestzeit wieder moeglich.
Der Manager erhaelt dazu unter anderem:
| Zustand | Bedeutung |
| --- | --- |
| `Schaltzustand` | Bestaetigter Ein-/Aus-Zustand. |
| `SchaltbefehlAusstehend` | Aktorbefehl wartet auf physische Bestaetigung. |
| `Schaltbereit` | Ein weiterer regulaerer Lastwechsel ist moeglich. |
| `RestMindestzeit_s` | Verbleibende Mindestzeit in Sekunden. |
| `Tageslaufzeit_s` | Bestaetigte Laufzeit des aktuellen Tages. |
| `Rueckmeldefehler` | Aktor und Rueckmeldung widersprechen sich unerwartet. |
Die lokale Aktion `Aktiv=false` ist ein bewusster Sicherheits-Override. Sie
schaltet den Verbraucher auch waehrend einer laufenden
Mindesteinschaltdauer aus.
## Sichtbare Variablen
Immer vorhanden sind:
- `Aktiv`: lokale Freigabe fuer das Energiemanagement
- `Schaltzustand`: bestaetigter oder aus dem Aktor abgeleiteter Zustand
- `Tageslaufzeit`: bestaetigte Laufzeit des aktuellen Tages in Sekunden
Bei aktivierter Diagnose werden zusaetzlich Soll- und Istleistung,
Verfuegbarkeit, Schaltbereitschaft, Stoerung, Rueckmeldefehler,
ausstehender Schaltbefehl und verbleibende Mindestzeit angezeigt.
## Inbetriebnahme
1. Nennleistung und Schaltkontakt konfigurieren.
2. Falls vorhanden, die separate Rueckmeldung auswaehlen und ihre
`true`-Semantik pruefen.
3. Mindest-Ein- und Mindest-Ausschaltdauer passend zum angeschlossenen
Geraet festlegen.
4. Den Verbraucher im Enelix Manager manuell oder automatisch aktiv
zuordnen.
5. Fuer die Erstpruefung Diagnosevariablen und bei Bedarf Logging aktivieren.
6. Die Variable `Aktiv` einschalten.
7. Unter Aufsicht je eine Ein- und Aus-Vorgabe durch den Manager ausfuehren.
8. Kontrollieren, dass Rueckmeldung, Mindestzeiten und `Schaltbereit`
erwartungsgemaess wechseln.
## Fehlersuche
- **Konfiguration ungueltig:** Nennleistung, Variablentyp und Aktoraktion
pruefen.
- **Schaltbefehl bleibt ausstehend:** Separate Rueckmeldung und deren
`true`-Semantik pruefen.
- **Rueckmeldefehler:** Aktor- und Rueckmeldewert stimmen ausserhalb eines
laufenden Schaltvorgangs nicht ueberein.
- **Kein Lastwechsel moeglich:** `RestMindestzeit`, `Aktiv`,
`SollwertGueltig` und die Managerzuordnung kontrollieren.
- **Keine Manager-Vorgabe akzeptiert:** Der Verbraucher muss beim sendenden
Manager aktiv zugeordnet sein.
## Tests
Nur dieses Modul:
~~~bash
composer check:verbraucher-einstufig
~~~
Gesamtes Repository inklusive dieser Testsuite:
~~~bash
composer check
~~~
Die Modulsuite kann damit unabhaengig weiterentwickelt werden und bleibt
gleichzeitig Bestandteil des allgemeinen Tests.
## Weiterfuehrende Dokumentation
- [Ausfuehrliche Modulbeschreibung](../docs/module/Verbraucher-1-Stufig/README.md)
- [Manager-Verbraucher-Schnittstelle](../docs/Schnittstelle.md)
- [Migration von Enelix 1](../docs/migration/Verbraucher-1-Stufig.md)
- [Aufbau der separaten Testsuite](../tests/VerbraucherEinStufig/README.md)
+137
View File
@@ -0,0 +1,137 @@
{
"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": "Mindesteinschaltdauer",
"caption": "Mindest-Einschaltdauer",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Mindestausschaltdauer",
"caption": "Mindest-Ausschaltdauer",
"suffix": " s",
"minimum": 0
}
]
},
{
"type": "ExpansionPanel",
"caption": "Verbraucher",
"expanded": true,
"items": [
{
"type": "NumberSpinner",
"name": "Nennleistung",
"caption": "Nennleistung",
"suffix": " W",
"minimum": 1
},
{
"type": "SelectVariable",
"name": "SchaltkontaktVariableID",
"caption": "Schaltkontakt",
"validVariableTypes": [
0
]
},
{
"type": "CheckBox",
"name": "SchaltkontaktInvertiert",
"caption": "Schaltkontakt invertieren"
},
{
"type": "SelectVariable",
"name": "RueckmeldungVariableID",
"caption": "Optionale Schaltzustands-Rueckmeldung",
"validVariableTypes": [
0
]
},
{
"type": "NumberSpinner",
"name": "Mindestlaufzeit",
"caption": "Taegliche Mindestlaufzeit",
"suffix": " s",
"minimum": 0,
"maximum": 86400
},
{
"type": "CheckBox",
"name": "PeakSperreBeiMindestlaufzeitAnbieten",
"caption": "Bei Peak Sperre waehrend der Mindestlaufzeit anbieten"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Darstellung und Diagnose",
"items": [
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Lokale Einstellungen in der Visualisierung anzeigen"
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Diagnoseprotokoll aktivieren"
}
]
}
],
"status": [
{
"code": 102,
"icon": "active",
"caption": "Aktiv"
},
{
"code": 201,
"icon": "error",
"caption": "Konfiguration ungueltig"
},
{
"code": 202,
"icon": "error",
"caption": "Schalt- oder Rueckmeldefehler"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{15879A4E-D0C2-4495-83DE-46E1E462591E}",
"name": "VerbraucherEinStufig",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Verbraucher 1-Stufig"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/VerbraucherEinStufig"
}
+986
View File
@@ -0,0 +1,986 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/EinStufigRegler.php';
use Belevo\EnelixEMS\EinStufigRegler;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
{
use VerbraucherBasisTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const STATUS_AKTIV = 102;
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
private const STATUS_SCHALTFEHLER = 202;
private const VM_UPDATE = 10603;
/** @var list<string> */
private const DIAGNOSE_VARIABLEN = [
'Istleistung',
'Leistungsquelle',
'Sollleistung',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
'Stoertext',
'Rueckmeldefehler',
'SchaltbefehlAusstehend',
'RestMindestzeit',
];
public function Create(): void
{
parent::Create();
$this->registriereVerbraucherBasis();
$this->RegisterPropertyInteger('Nennleistung', 0);
$this->RegisterPropertyInteger('SchaltkontaktVariableID', 0);
$this->RegisterPropertyBoolean('SchaltkontaktInvertiert', false);
$this->RegisterPropertyInteger('RueckmeldungVariableID', 0);
$this->RegisterPropertyInteger('Mindestlaufzeit', 0);
$this->RegisterPropertyBoolean('PeakSperreBeiMindestlaufzeitAnbieten', true);
$this->RegisterPropertyInteger('Mindesteinschaltdauer', 5);
$this->RegisterPropertyInteger('Mindestausschaltdauer', 5);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
$this->RegisterVariableBoolean('Schaltzustand', 'Schaltzustand', '~Switch', 100);
$this->RegisterVariableInteger('Tageslaufzeit', 'Tageslaufzeit', '', 110);
$this->RegisterAttributeInteger('RegistrierterSchaltkontakt', 0);
$this->RegisterAttributeInteger('RegistrierteRueckmeldung', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeInteger('BestaetigtSeit', 0);
$this->RegisterAttributeInteger('FreigabeAb', 0);
$this->RegisterAttributeInteger('LaufzeitStandZeit', 0);
$this->RegisterAttributeString('LaufzeitTag', '');
$this->RegisterAttributeString('Leistungsangebot', '[0]');
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
$this->RegisterAttributeBoolean('Initialisiert', false);
$this->RegisterAttributeBoolean('SchaltvorgangLaeuft', false);
$this->RegisterAttributeBoolean('SchaltpruefungAusstehend', false);
$this->RegisterAttributeBoolean('Schaltziel', false);
$this->RegisterAttributeString('Schaltfehler', '');
$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->RegisterAttributeBoolean('ZustandSchaltzustand', false);
$this->RegisterAttributeInteger('ZustandTageslaufzeit', 0);
$this->RegisterAttributeBoolean('ZustandRueckmeldefehler', false);
$this->RegisterAttributeBoolean('ZustandSchaltbefehlAusstehend', false);
$this->RegisterAttributeInteger('ZustandRestMindestzeit', 0);
$this->RegisterTimer(
'Meldezyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
);
$this->RegisterTimer(
'RueckmeldungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
);
// Bestehende Instanzen behalten den alten Timer deaktiviert.
$this->RegisterTimer('Umschaltfreigabe', 0, '');
$this->RegisterTimer(
'MindestzeitAbgelaufen',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'MindestzeitAbgelaufen', 0);"
);
$this->RegisterTimer(
'VorgabeTimeout',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', 0);"
);
$this->RegisterTimer(
'Tagesplanung',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Tagesplanung', 0);"
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->aktualisiereVariablen();
$this->registriereMeldungen();
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->deaktiviereTimer();
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage(), false);
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000);
$this->initialisiereZustand();
$this->aktualisiere(true);
}
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
{
if ((int) $nachricht !== self::VM_UPDATE || $this->ReadAttributeBoolean('SchaltvorgangLaeuft')) {
return;
}
$relevanteIDs = array_filter([
$this->ReadPropertyInteger('SchaltkontaktVariableID'),
$this->ReadPropertyInteger('RueckmeldungVariableID'),
]);
if (in_array((int) $senderID, $relevanteIDs, 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 'MindestzeitAbgelaufen':
case 'VorgabeTimeout':
case 'Tagesplanung':
$this->SetTimerInterval((string) $ident, 0);
$this->aktualisiere(true);
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<string, mixed> $daten */
public function ManagerdatenEmpfangen(array $daten): void
{
Nachrichtenvertrag::pruefeManagerdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
throw new InvalidArgumentException('Managerdaten sind an eine andere Instanz adressiert.');
}
$managerID = $daten['Kopf']['AbsenderID'];
if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) {
throw new InvalidArgumentException('Der Manager hat diesen Verbraucher 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->planeMeldung();
return;
}
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) {
throw new InvalidArgumentException(
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
);
}
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
$this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true);
$this->planeVorgabeTimeout(time());
$this->aktualisiere(false);
$this->planeMeldung();
}
private function aktualisiere(bool $meldungPlanen): void
{
try {
$jetzt = time();
$this->aktualisiereTageslaufzeit($jetzt);
$vorherigerSchaltzustand = (bool) $this->leseZustand('Schaltzustand');
$schaltzustand = $this->leseSchaltzustand();
if ($schaltzustand !== $vorherigerSchaltzustand) {
$this->bestaetigeZustandswechsel($schaltzustand, $jetzt);
}
$this->pruefeAusstehendenSchaltvorgang($schaltzustand, $jetzt);
$this->pruefeVorgabeTimeout($jetzt);
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array(
(int) $this->leseZustand('Sollleistung'),
[0, $this->ReadPropertyInteger('Nennleistung')],
true
)
) {
$this->setzeZustand('SollwertGueltig', false);
}
$aktiv = (bool) $this->GetValue('Aktiv');
$wartetAufRueckmeldung = $this->ReadAttributeBoolean('SchaltpruefungAusstehend');
$rueckmeldefehler = $this->ReadPropertyInteger('RueckmeldungVariableID') > 0
&& !$wartetAufRueckmeldung
&& $schaltzustand !== $this->leseAktorSchaltzustand();
$this->setzeZustand('Rueckmeldefehler', $rueckmeldefehler);
$fehlerfrei = $this->ReadAttributeString('Schaltfehler') === ''
&& !$rueckmeldefehler;
$verfuegbar = $aktiv && $fehlerfrei;
$mindestlaufzeitErzwungen = $verfuegbar
&& $this->mussMindestlaufzeitErzwingen($jetzt);
$restMindestzeit = $wartetAufRueckmeldung
? 0
: EinStufigRegler::restMindestzeit(
$this->ReadAttributeInteger('FreigabeAb'),
$jetzt
);
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
=== Nachrichtenvertrag::BETRIEBSART_PEAK;
$peakSperreBeiMindestlaufzeitAnbieten = $this->ReadPropertyBoolean(
'PeakSperreBeiMindestlaufzeitAnbieten'
);
$mindestlaufzeitLokalErzwingen = $mindestlaufzeitErzwungen
&& (!$peakbetrieb || !$peakSperreBeiMindestlaufzeitAnbieten);
$ziel = 0;
if ($verfuegbar) {
if ($mindestlaufzeitLokalErzwingen) {
$ziel = $this->ReadPropertyInteger('Nennleistung');
} elseif ((bool) $this->leseZustand('SollwertGueltig')) {
$ziel = (int) $this->leseZustand('Sollleistung');
}
}
$istleistung = $this->leistungFuerSchaltzustand($schaltzustand);
$zielzustand = $ziel > 0;
$befehlNoetig = $ziel !== $istleistung
|| ($wartetAufRueckmeldung
&& $zielzustand !== $this->ReadAttributeBoolean('Schaltziel'));
// Die lokale Deaktivierung bleibt der einzige bewusste Mindestzeit-Override.
$sicheresAusschalten = !$zielzustand && !$aktiv;
if ($befehlNoetig
&& ($sicheresAusschalten
|| ($restMindestzeit === 0 && !$wartetAufRueckmeldung && $fehlerfrei))
) {
$this->schalte($zielzustand, $jetzt);
$schaltzustand = $this->leseSchaltzustand();
$this->pruefeAusstehendenSchaltvorgang($schaltzustand, $jetzt);
$istleistung = $this->leistungFuerSchaltzustand($schaltzustand);
$wartetAufRueckmeldung = $this->ReadAttributeBoolean('SchaltpruefungAusstehend');
$restMindestzeit = $wartetAufRueckmeldung
? 0
: EinStufigRegler::restMindestzeit(
$this->ReadAttributeInteger('FreigabeAb'),
$jetzt
);
}
$this->setzeZustand('Sollleistung', $ziel);
$this->setzeZustand('Verfuegbar', $verfuegbar);
$this->setzeZustand('SchaltbefehlAusstehend', $wartetAufRueckmeldung);
$this->setzeZustand('RestMindestzeit', $restMindestzeit);
$aenderungMoeglich = $verfuegbar
&& (!$mindestlaufzeitErzwungen
|| ($peakbetrieb && $peakSperreBeiMindestlaufzeitAnbieten))
&& $restMindestzeit === 0
&& !$wartetAufRueckmeldung;
$this->setzeZustand('AenderungMoeglich', $aenderungMoeglich);
$angebot = EinStufigRegler::leistungsangebot(
$this->ReadPropertyInteger('Nennleistung'),
$istleistung,
$verfuegbar,
$aenderungMoeglich,
$mindestlaufzeitErzwungen,
$peakbetrieb,
$peakSperreBeiMindestlaufzeitAnbieten
);
$this->WriteAttributeString('Leistungsangebot', json_encode($angebot, JSON_THROW_ON_ERROR));
$this->aktualisiereStoerung();
$this->planeTimer($jetzt);
$this->SetStatus(
$this->ReadAttributeString('Schaltfehler') === ''
&& !(bool) $this->leseZustand('Rueckmeldefehler')
? self::STATUS_AKTIV
: self::STATUS_SCHALTFEHLER
);
if ($meldungPlanen) {
$this->planeMeldung();
}
} catch (Throwable $fehler) {
$this->WriteAttributeString('Schaltfehler', $fehler->getMessage());
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage(), false);
$this->SetStatus(self::STATUS_SCHALTFEHLER);
$this->protokolliere('Aktualisierung', $fehler->getMessage());
}
}
private function schalte(bool $einschalten, int $jetzt): void
{
$kontaktID = $this->ReadPropertyInteger('SchaltkontaktVariableID');
$aktorwert = $einschalten !== $this->ReadPropertyBoolean('SchaltkontaktInvertiert');
$this->WriteAttributeBoolean('Schaltziel', $einschalten);
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', true);
$this->setzeZustand('SchaltbefehlAusstehend', true);
$this->WriteAttributeBoolean('SchaltvorgangLaeuft', true);
try {
RequestAction($kontaktID, $aktorwert);
} catch (Throwable $fehler) {
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
$this->setzeZustand('SchaltbefehlAusstehend', false);
throw new RuntimeException('Schaltkontakt konnte nicht gesetzt werden: ' . $fehler->getMessage());
} finally {
$this->WriteAttributeBoolean('SchaltvorgangLaeuft', false);
}
$istzustand = $this->leseSchaltzustand();
if ($istzustand === $einschalten) {
$this->bestaetigeZustandswechsel($istzustand, $jetzt);
} elseif ($this->ReadPropertyInteger('RueckmeldungVariableID') <= 0) {
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
$this->setzeZustand('SchaltbefehlAusstehend', false);
throw new RuntimeException('Schaltkontakt hat den angeforderten Zustand nicht uebernommen.');
} else {
// Die physische Rueckmeldung bestimmt Zustand und Beginn der Mindestzeit.
$this->setzeZustand('Rueckmeldefehler', false);
}
$this->WriteAttributeString('Schaltfehler', '');
$this->protokolliere('Schaltbefehl', ['Eingeschaltet' => $einschalten]);
}
private function pruefeAusstehendenSchaltvorgang(bool $schaltzustand, int $jetzt): void
{
if ($this->ReadAttributeBoolean('SchaltpruefungAusstehend')
&& $schaltzustand === $this->ReadAttributeBoolean('Schaltziel')
) {
$this->bestaetigeZustandswechsel($schaltzustand, $jetzt);
}
$this->setzeZustand(
'SchaltbefehlAusstehend',
$this->ReadAttributeBoolean('SchaltpruefungAusstehend')
);
}
private function bestaetigeZustandswechsel(bool $schaltzustand, int $jetzt): void
{
$vorherigerZustand = (bool) $this->leseZustand('Schaltzustand');
$zustandGeaendert = $schaltzustand !== $vorherigerZustand
|| $this->ReadAttributeInteger('BestaetigtSeit') <= 0;
$this->setzeSchaltzustand($schaltzustand);
if ($zustandGeaendert) {
$this->WriteAttributeInteger('BestaetigtSeit', $jetzt);
$this->WriteAttributeInteger(
'FreigabeAb',
EinStufigRegler::freigabezeitpunkt(
$schaltzustand,
$jetzt,
$this->ReadPropertyInteger('Mindesteinschaltdauer'),
$this->ReadPropertyInteger('Mindestausschaltdauer')
)
);
}
if ($schaltzustand === $this->ReadAttributeBoolean('Schaltziel')) {
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
}
$this->setzeZustand(
'SchaltbefehlAusstehend',
$this->ReadAttributeBoolean('SchaltpruefungAusstehend')
);
$this->setzeZustand('Rueckmeldefehler', false);
$this->protokolliere('Schaltzustand bestaetigt', ['Eingeschaltet' => $schaltzustand]);
}
private function aktualisiereTageslaufzeit(int $jetzt): void
{
$werte = EinStufigRegler::aktualisiereTageslaufzeit(
$this->ReadAttributeString('LaufzeitTag'),
(int) $this->leseZustand('Tageslaufzeit'),
$this->ReadAttributeInteger('LaufzeitStandZeit'),
$jetzt,
(bool) $this->leseZustand('Schaltzustand'),
$this->zeitzone()
);
$this->WriteAttributeString('LaufzeitTag', $werte['Tag']);
$this->WriteAttributeInteger('LaufzeitStandZeit', $werte['StandZeit']);
$this->setzeZustand('Tageslaufzeit', $werte['Sekunden']);
}
private function mussMindestlaufzeitErzwingen(int $jetzt): bool
{
return EinStufigRegler::mussMindestlaufzeitErzwingen(
(int) $this->leseZustand('Tageslaufzeit'),
$this->ReadPropertyInteger('Mindestlaufzeit'),
EinStufigRegler::sekundenBisTagesende($jetzt, $this->zeitzone())
);
}
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 planeTimer(int $jetzt): void
{
$this->SetTimerInterval('Umschaltfreigabe', 0);
$freigabeAb = $this->ReadAttributeInteger('FreigabeAb');
$wartetAufRueckmeldung = $this->ReadAttributeBoolean('SchaltpruefungAusstehend');
$this->SetTimerInterval(
'MindestzeitAbgelaufen',
!$wartetAufRueckmeldung && $freigabeAb > $jetzt
? max(1, ($freigabeAb - $jetzt) * 1000)
: 0
);
$this->planeVorgabeTimeout($jetzt);
$bisTagesende = EinStufigRegler::sekundenBisTagesende($jetzt, $this->zeitzone());
$naechstePruefung = EinStufigRegler::sekundenBisNaechsteTagespruefung(
(int) $this->leseZustand('Tageslaufzeit'),
$this->ReadPropertyInteger('Mindestlaufzeit'),
$bisTagesende,
(bool) $this->leseZustand('Schaltzustand')
);
$this->SetTimerInterval('Tagesplanung', $naechstePruefung * 1000);
}
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 planeMeldung(): void
{
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
private function initialisiereZustand(): void
{
$jetzt = time();
$schaltzustand = $this->leseSchaltzustand();
if (!$this->ReadAttributeBoolean('Initialisiert')) {
$this->WriteAttributeString('LaufzeitTag', date('Y-m-d', $jetzt));
$this->WriteAttributeInteger('LaufzeitStandZeit', $jetzt);
$this->setzeZustand('Tageslaufzeit', 0);
$this->setzeZustand('SollwertGueltig', false);
$this->setzeZustand('Rueckmeldefehler', false);
$this->WriteAttributeBoolean('Initialisiert', true);
}
if ($this->ReadAttributeInteger('BestaetigtSeit') <= 0) {
// Migration bestehender Instanzen ohne bestaetigten Zeitbezug.
$this->WriteAttributeBoolean('Schaltziel', $schaltzustand);
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
$this->bestaetigeZustandswechsel($schaltzustand, $jetzt);
} else {
$this->setzeSchaltzustand($schaltzustand);
$this->setzeZustand(
'SchaltbefehlAusstehend',
$this->ReadAttributeBoolean('SchaltpruefungAusstehend')
);
}
}
private function setzeSchaltzustand(bool $eingeschaltet): void
{
$this->setzeZustand('Schaltzustand', $eingeschaltet);
$this->setzeZustand('Istleistung', (float) $this->leistungFuerSchaltzustand($eingeschaltet));
$this->setzeZustand('Leistungsquelle', Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET);
}
private function leseSchaltzustand(): bool
{
$rueckmeldungID = $this->ReadPropertyInteger('RueckmeldungVariableID');
if ($rueckmeldungID > 0) {
return (bool) GetValue($rueckmeldungID);
}
return $this->leseAktorSchaltzustand();
}
private function leseAktorSchaltzustand(): bool
{
$kontaktwert = (bool) GetValue($this->ReadPropertyInteger('SchaltkontaktVariableID'));
return $kontaktwert !== $this->ReadPropertyBoolean('SchaltkontaktInvertiert');
}
private function leistungFuerSchaltzustand(bool $eingeschaltet): int
{
return $eingeschaltet ? $this->ReadPropertyInteger('Nennleistung') : 0;
}
private function aktualisiereStoerung(): void
{
$texte = [];
if ($this->ReadAttributeString('Schaltfehler') !== '') {
$texte[] = $this->ReadAttributeString('Schaltfehler');
}
if ((bool) $this->leseZustand('Rueckmeldefehler')) {
$texte[] = 'Schaltzustands-Rueckmeldung entspricht nicht dem angeforderten Zustand.';
}
$this->setzeStoerung(implode("\n", $texte), $texte === []);
}
private function setzeStoerung(string $text, bool $fehlerfrei): void
{
$this->setzeZustand('Stoerung', !$fehlerfrei);
$this->setzeZustand('Stoertext', $text);
}
private function sendeVerbraucherdaten(): void
{
foreach ($this->zugeordneteManagerIDs() as $managerID) {
$daten = $this->baueVerbraucherdaten($managerID);
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
try {
IPS_RequestAction(
$managerID,
'VerbraucherdatenEmpfangen',
json_encode($daten, JSON_THROW_ON_ERROR)
);
} catch (Throwable $fehler) {
$this->protokolliere('Managerkommunikation', $fehler->getMessage());
}
}
}
/** @return array<string, mixed> */
private function baueVerbraucherdaten(int $managerID): array
{
return [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $this->InstanceID,
'EmpfaengerID' => $managerID,
'Zeitpunkt' => time(),
],
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET,
'Zustand' => [
[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung')
: null,
'Einheit' => 'W',
],
[
'Kennung' => 'Schaltzustand',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('Schaltzustand'),
'Einheit' => '',
],
[
'Kennung' => 'SchaltbefehlAusstehend',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('SchaltbefehlAusstehend'),
'Einheit' => '',
],
[
'Kennung' => 'Schaltbereit',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('AenderungMoeglich'),
'Einheit' => '',
],
[
'Kennung' => 'RestMindestzeit_s',
'Art' => 'Istwert',
'Wert' => (int) $this->leseZustand('RestMindestzeit'),
'Einheit' => 's',
],
[
'Kennung' => 'Tageslaufzeit_s',
'Art' => 'Istwert',
'Wert' => (int) $this->leseZustand('Tageslaufzeit'),
'Einheit' => 's',
],
[
'Kennung' => 'Rueckmeldefehler',
'Art' => 'Stoerung',
'Wert' => (bool) $this->leseZustand('Rueckmeldefehler'),
'Einheit' => '',
'Text' => (bool) $this->leseZustand('Rueckmeldefehler')
? 'Schaltzustands-Rueckmeldung entspricht nicht dem angeforderten Zustand.'
: '',
],
],
];
}
/** @return list<int> */
private function zugeordneteManagerIDs(): array
{
$ergebnis = [];
foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) {
try {
$automatisch = (bool) IPS_GetProperty($managerID, 'AutomatischeSuche');
$property = $automatisch
? 'AutomatischeVerbraucherZuordnung'
: 'VerbraucherZuordnung';
$zuordnung = $this->leseManagerZuordnung($managerID, $property);
if ($automatisch && $zuordnung === []) {
$zuordnung = $this->leseManagerZuordnung($managerID, 'VerbraucherZuordnung');
}
} catch (Throwable $fehler) {
continue;
}
foreach ($zuordnung as $eintrag) {
if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) {
continue;
}
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
if ($instanzID === $this->InstanceID) {
$ergebnis[] = (int) $managerID;
break;
}
}
}
return array_values(array_unique($ergebnis));
}
/** @return array<mixed> */
private function leseManagerZuordnung(int $managerID, string $property): array
{
$zuordnung = json_decode(
(string) IPS_GetProperty($managerID, $property),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($zuordnung) ? $zuordnung : [];
}
private function pruefeKonfiguration(): void
{
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException($property . ' muss mindestens 0 sein.');
}
}
foreach (['Meldeintervall', 'VorgabeTimeout'] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
}
}
if ($this->ReadPropertyInteger('Nennleistung') <= 0) {
throw new InvalidArgumentException('Nennleistung muss groesser als 0 sein.');
}
if ($this->ReadPropertyInteger('Mindestlaufzeit') < 0
|| $this->ReadPropertyInteger('Mindestlaufzeit') > 86400
) {
throw new InvalidArgumentException('Mindestlaufzeit muss zwischen 0 und 86400 Sekunden liegen.');
}
foreach (['Mindesteinschaltdauer', 'Mindestausschaltdauer'] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException($property . ' darf nicht negativ sein.');
}
}
$kontaktID = $this->ReadPropertyInteger('SchaltkontaktVariableID');
$this->pruefeBooleanVariable($kontaktID, 'Schaltkontakt', true);
$rueckmeldungID = $this->ReadPropertyInteger('RueckmeldungVariableID');
if ($rueckmeldungID > 0) {
$this->pruefeBooleanVariable($rueckmeldungID, 'Rueckmeldung', false);
}
}
private function pruefeBooleanVariable(int $variableID, string $bezeichnung, bool $aktionErforderlich): void
{
if ($variableID <= 0 || !IPS_VariableExists($variableID)) {
throw new InvalidArgumentException($bezeichnung . ' ist nicht eingerichtet.');
}
$variable = IPS_GetVariable($variableID);
if ((int) $variable['VariableType'] !== 0) {
throw new InvalidArgumentException($bezeichnung . ' muss eine Booleanvariable sein.');
}
if ($aktionErforderlich
&& (int) $variable['VariableAction'] <= 0
&& (int) $variable['VariableCustomAction'] <= 0
) {
throw new InvalidArgumentException($bezeichnung . ' benoetigt eine Standard- oder benutzerdefinierte Aktion.');
}
}
private function registriereMeldungen(): void
{
$alteIDs = array_unique(array_filter([
$this->ReadAttributeInteger('RegistrierterSchaltkontakt'),
$this->ReadAttributeInteger('RegistrierteRueckmeldung'),
]));
$neueIDs = array_unique(array_filter([
$this->ReadPropertyInteger('SchaltkontaktVariableID'),
$this->ReadPropertyInteger('RueckmeldungVariableID'),
]));
foreach ($alteIDs as $alteID) {
if (!in_array($alteID, $neueIDs, true)) {
$this->UnregisterMessage($alteID, self::VM_UPDATE);
}
}
foreach ($neueIDs as $neueID) {
if (IPS_VariableExists($neueID)) {
$this->RegisterMessage($neueID, self::VM_UPDATE);
}
}
$this->WriteAttributeInteger(
'RegistrierterSchaltkontakt',
$this->ReadPropertyInteger('SchaltkontaktVariableID')
);
$this->WriteAttributeInteger(
'RegistrierteRueckmeldung',
$this->ReadPropertyInteger('RueckmeldungVariableID')
);
}
private function aktualisiereVariablen(): void
{
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if ($ident === 'Leistungsquelle') {
continue;
}
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
$this->setzeZustand($ident, GetValue($variablenID));
}
}
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->registriereVerbraucherDiagnose();
$this->RegisterVariableBoolean('Rueckmeldefehler', 'Rueckmeldefehler', '~Alert', 200);
$this->RegisterVariableBoolean(
'SchaltbefehlAusstehend',
'Schaltbefehl ausstehend',
'~Switch',
210
);
$this->RegisterVariableInteger('RestMindestzeit', 'Restliche Mindestzeit', '', 220);
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$wert = $ident === 'Leistungsquelle'
? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET
: $this->leseZustand($ident);
$this->setzeZustand($ident, $wert);
}
} else {
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
$this->UnregisterVariable($ident);
}
}
}
}
/** @param bool|int|float|string $wert */
private function setzeZustand(string $ident, $wert): void
{
$booleanAttribute = [
'SollwertGueltig' => 'ZustandSollwertGueltig',
'Verfuegbar' => 'ZustandVerfuegbar',
'AenderungMoeglich' => 'ZustandAenderungMoeglich',
'Stoerung' => 'ZustandStoerung',
'Schaltzustand' => 'ZustandSchaltzustand',
'Rueckmeldefehler' => 'ZustandRueckmeldefehler',
'SchaltbefehlAusstehend' => 'ZustandSchaltbefehlAusstehend',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'Tageslaufzeit' => 'ZustandTageslaufzeit',
'RestMindestzeit' => 'ZustandRestMindestzeit',
];
$floatAttribute = [
'Istleistung' => 'ZustandIstleistung',
];
$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);
} elseif ($ident !== 'Leistungsquelle') {
throw new LogicException('Unbekannter interner Zustand: ' . $ident);
}
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
SetValue($variablenID, $wert);
}
}
/** @return bool|int|float|string */
private function leseZustand(string $ident)
{
$booleanAttribute = [
'SollwertGueltig' => 'ZustandSollwertGueltig',
'Verfuegbar' => 'ZustandVerfuegbar',
'AenderungMoeglich' => 'ZustandAenderungMoeglich',
'Stoerung' => 'ZustandStoerung',
'Schaltzustand' => 'ZustandSchaltzustand',
'Rueckmeldefehler' => 'ZustandRueckmeldefehler',
'SchaltbefehlAusstehend' => 'ZustandSchaltbefehlAusstehend',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'Tageslaufzeit' => 'ZustandTageslaufzeit',
'RestMindestzeit' => 'ZustandRestMindestzeit',
];
$floatAttribute = [
'Istleistung' => 'ZustandIstleistung',
];
$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 Zustand: ' . $ident);
}
/** @return list<int> */
private function leseLeistungsangebot(): array
{
$angebot = json_decode(
$this->ReadAttributeString('Leistungsangebot'),
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($angebot)) {
return [];
}
return array_values(array_map('intval', $angebot));
}
private function zeitzone(): DateTimeZone
{
return new DateTimeZone(date_default_timezone_get());
}
private function deaktiviereTimer(): void
{
foreach ([
'Meldezyklus',
'RueckmeldungVerzoegert',
'Umschaltfreigabe',
'MindestzeitAbgelaufen',
'VorgabeTimeout',
'Tagesplanung',
] 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
);
}
}
+166
View File
@@ -0,0 +1,166 @@
{
"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": "ExpansionPanel",
"caption": "Waermepumpe und Kontakte",
"expanded": true,
"items": [
{
"type": "Select",
"name": "Kontaktart",
"caption": "Kontaktart",
"options": [
{"caption": "Sperre und Erhoehung", "value": 0},
{"caption": "SG Ready", "value": 1}
]
},
{
"type": "SelectVariable",
"name": "Kontakt1VariableID",
"caption": "Kontakt 1 (Sperre / SG Ready 1)",
"validVariableTypes": [0]
},
{
"type": "CheckBox",
"name": "Kontakt1Invertiert",
"caption": "Kontakt 1 invertieren"
},
{
"type": "SelectVariable",
"name": "Kontakt2VariableID",
"caption": "Kontakt 2 (Erhoehung / SG Ready 2)",
"validVariableTypes": [0]
},
{
"type": "CheckBox",
"name": "Kontakt2Invertiert",
"caption": "Kontakt 2 invertieren"
},
{
"type": "NumberSpinner",
"name": "Nennleistung",
"caption": "Nennleistung",
"suffix": " W",
"minimum": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Betriebsrueckmeldung",
"expanded": true,
"items": [
{
"type": "Select",
"name": "Rueckmeldungsart",
"caption": "Rueckmeldungsart",
"options": [
{"caption": "Gemessene Leistung", "value": 0},
{"caption": "Betriebsstatus", "value": 1}
]
},
{
"type": "SelectVariable",
"name": "IstleistungVariableID",
"caption": "Leistung oder Betriebsstatus",
"validVariableTypes": [0, 1, 2]
},
{
"type": "NumberSpinner",
"name": "Laufschwelle",
"caption": "Laufschwelle bei Leistungsmessung",
"suffix": " W",
"minimum": 0,
"digits": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Schutzzeiten",
"expanded": true,
"items": [
{
"type": "NumberSpinner",
"name": "Anlaufwartezeit",
"caption": "Anlaufwartezeit",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Wiederholsperre",
"caption": "Wiederholsperre nach erfolglosem Anlauf",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Mindestlaufzeit",
"caption": "Mindestlaufzeit",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Mindestsperrzeit",
"caption": "Mindestsperrzeit",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "MaximaleSperrzeit",
"caption": "Maximale Sperrzeit am Stueck",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "MaximaleSperrzeit24h",
"caption": "Maximale Sperrzeit in 24 Stunden",
"suffix": " s",
"minimum": 0,
"maximum": 86400
}
]
},
{
"type": "ExpansionPanel",
"caption": "Darstellung und Diagnose",
"items": [
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Lokale Einstellungen in der Visualisierung anzeigen"
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Diagnoseprotokoll aktivieren"
}
]
}
],
"status": [
{"code": 102, "icon": "active", "caption": "Aktiv"},
{"code": 201, "icon": "error", "caption": "Konfiguration ungueltig"},
{"code": 202, "icon": "error", "caption": "Schalt- oder Rueckmeldefehler"}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}",
"name": "VerbraucherWaermepumpe",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Waermepumpe"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Waermepumpe"
}
File diff suppressed because it is too large Load Diff
+230
View File
@@ -0,0 +1,230 @@
{
"elements": [
{
"type": "ExpansionPanel",
"caption": "Manager und Zeitverhalten",
"expanded": true,
"items": [
{
"type": "NumberSpinner",
"name": "PrioritaetPV",
"caption": "Priorität PV",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "PrioritaetPeak",
"caption": "Priorität 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": "LastwechselSperrzeit",
"caption": "Mindestzeit zwischen Lastwechseln",
"suffix": " s",
"minimum": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Speichereinstellungen",
"expanded": true,
"items": [
{
"type": "List",
"name": "LeistungsStufen",
"caption": "Exklusive Leistungsstufen und Schaltkontakte",
"add": true,
"delete": true,
"sortable": true,
"columns": [
{
"caption": "Stufe",
"name": "Stufe",
"width": "100px",
"add": 1,
"edit": {
"type": "NumberSpinner",
"minimum": 1
}
},
{
"caption": "Leistung",
"name": "Leistung",
"width": "180px",
"add": 0,
"edit": {
"type": "NumberSpinner",
"minimum": 1,
"suffix": " W"
}
},
{
"caption": "Schaltkontakt",
"name": "Schaltkontakt_Stufe",
"width": "auto",
"add": 0,
"edit": {
"type": "SelectVariable"
}
}
]
},
{
"type": "SelectVariable",
"name": "Boilerfuehler_PT1",
"caption": "Boilerfühler"
},
{
"type": "CheckBox",
"name": "LegionellenfunktionAktiv",
"caption": "Legionellenschaltung aktiv"
},
{
"type": "NumberSpinner",
"name": "LegionellenMinimalintervallTage",
"caption": "Frühestens nach",
"suffix": " Tagen",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "LegionellenMaximalintervallTage",
"caption": "Spätestens nach",
"suffix": " Tagen",
"minimum": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Erweiterte Speichereinstellungen",
"items": [
{
"type": "NumberSpinner",
"name": "Boilervolumen",
"caption": "Boilervolumen",
"suffix": " l",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "Hysterese",
"caption": "Temperaturhysterese",
"suffix": " K",
"minimum": 0.1,
"digits": 1
},
{
"type": "List",
"name": "Zeitplan",
"caption": "Zeitplan für Solltemperaturen",
"add": true,
"delete": true,
"sortable": true,
"columns": [
{
"caption": "Uhrzeit",
"name": "Uhrzeit",
"width": "150px",
"add": "00:00",
"edit": {
"type": "ValidationTextBox"
}
},
{
"caption": "Solltemperatur",
"name": "Solltemperatur",
"width": "180px",
"add": 50,
"edit": {
"type": "NumberSpinner",
"minimum": 0,
"maximum": 100,
"digits": 1,
"suffix": " °C"
}
}
]
}
]
},
{
"type": "ExpansionPanel",
"caption": "Erweiterte sonstige Einstellungen",
"items": [
{
"type": "NumberSpinner",
"name": "TemperaturMaxAlter",
"caption": "Maximales Messwertalter",
"suffix": " s",
"minimum": 1
},
{
"type": "CheckBox",
"name": "BoilertemperaturGlaetten",
"caption": "Boilertemperatur mit PT1 glätten"
},
{
"type": "NumberSpinner",
"name": "ZeitKonstante",
"caption": "PT1-Zeitkonstante",
"suffix": " s",
"minimum": 1
},
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Temperatursollwerte in der Visualisierung anzeigen und bedienen"
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anlegen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Laufendes Debug-Logging aktivieren"
}
]
}
],
"status": [
{
"code": 102,
"icon": "active",
"caption": "Aktiv"
},
{
"code": 201,
"icon": "inactive",
"caption": "Temperaturmessung fehlt oder ist veraltet"
},
{
"code": 202,
"icon": "error",
"caption": "Konfiguration ungültig"
},
{
"code": 203,
"icon": "error",
"caption": "Schaltfehler"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{B7C54AF4-AD7D-4FE4-B75D-203693906251}",
"name": "VerbraucherWarmwassererwaermer",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Wassererwärmer"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Warmwassererwaermer"
}
File diff suppressed because it is too large Load Diff
+10
View File
@@ -2,6 +2,7 @@
"name": "belevo/enelix-ems", "name": "belevo/enelix-ems",
"description": "Energiemanagement und steuerbare Verbraucher fuer IP-Symcon", "description": "Energiemanagement und steuerbare Verbraucher fuer IP-Symcon",
"type": "library", "type": "library",
"license": "proprietary",
"require": { "require": {
"php": ">=8.0" "php": ">=8.0"
}, },
@@ -20,10 +21,19 @@
}, },
"scripts": { "scripts": {
"lint": "find . -path ./vendor -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l", "lint": "find . -path ./vendor -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l",
"lint:verbraucher-einstufig": "find VerbraucherEinStufig libs/EinStufigRegler.php tests/VerbraucherEinStufig -name '*.php' -print0 | xargs -0 -n1 php -l",
"test": "phpunit", "test": "phpunit",
"test:verbraucher-einstufig": "phpunit --configuration phpunit.verbraucher-einstufig.xml",
"symcon:all": "bash tests/Symcon/bin/run-symcon-tests.sh all",
"symcon:single": "bash tests/Symcon/bin/run-symcon-tests.sh single",
"symcon:affected": "bash tests/Symcon/bin/run-symcon-tests.sh affected",
"check": [ "check": [
"@lint", "@lint",
"@test" "@test"
],
"check:verbraucher-einstufig": [
"@lint:verbraucher-einstufig",
"@test:verbraucher-einstufig"
] ]
}, },
"config": { "config": {
+60
View File
@@ -0,0 +1,60 @@
# Obere Anschlüsse des Managers
> Status: Lizenzierung, Prognose und Stoerueberwachung sind an
> `license.enelix.ch` angebunden. SDL/VGT bleibt als optionaler Anschluss
> vorbereitet.
Die Anschluesse werden ausschliesslich im Manager konfiguriert. Sie erzeugen
keine Abhaengigkeit zwischen Enelix EMS und Enelix Utils.
| Anschluss | Aufgabe | Gegenstelle |
| --- | --- | --- |
| SDL/VGT | Zeitlich gueltige Leistungsauftraege und Rueckmeldungen | Optional das unabhaengige Utils-Modul VGT-Schnittstelle |
| Prognose / Forecast | Topologie, Telemetrie und Netzfahrplan | `license.enelix.ch/api/v1/installations/{id}/prognosis/*` |
| Lizenzierung | Pruefung freigeschalteter Manager-Funktionen | `POST https://license.enelix.ch/api/v1/licenses/activate` |
| Stoerueberwachung | Vollsnapshot aktiver Manager- und Geraetestoerungen | `PUT https://license.enelix.ch/api/v1/installations/{id}/faults` |
## Stoerungsdatenfluss
1. Jedes Geraetemodul meldet seinen Zustand ueber den bestehenden
Verbraucher-Nachrichtenvertrag an den Manager.
2. Der Manager sammelt aktive Eintraege mit `Art = Stoerung`, fuegt eigene
Regelungsstoerungen hinzu und normalisiert sie.
3. Der Manager speichert lokal einen deterministisch sortierten Vollsnapshot.
4. Bei Zustandsaenderung oder spaetestens im konfigurierten Heartbeat-Intervall
sendet er den Snapshot mit dem vorhandenen Geraete-Bearer-Token.
5. Ein leerer Snapshot loest zuvor aktive Stoerungen im Portal auf.
Jede Meldung besteht aus `sourceType`, `sourceId`, `sourceName`, `code`,
`severity` und `message`. Die stabile Kombination aus `sourceId` und
`code` identifiziert ein Ereignis ueber mehrere Snapshots hinweg.
Fehler der externen Uebertragung blockieren die lokale EMS-Regelung nicht. Der
Manager wiederholt mit exponentiellem Abstand zwischen 30 und 900 Sekunden.
Nach HTTP 401 oder 403 verwirft er den Geraetezugang und fordert ihn bei der
naechsten Lizenzaktivierung neu an.
## Lizenzvorbereitung
Die Stoerueberwachung ist technisch als separat schaltbare Manager-Funktion
gekapselt. Das Portal kann spaeter eine Berechtigung mit dem Katalogschluessel
`fault_monitoring`, einer Laufzeit von einem Jahr und einem erneuerten
`validUntil` ausliefern. In der ersten Ausbaustufe wird diese zusaetzliche
Jahresberechtigung noch nicht erzwungen; Voraussetzung bleibt eine gueltige
Managerlizenz samt Geraetezugang.
## Gemeinsame Grundsaetze
- Anbieterformate werden am Manageranschluss uebersetzt und gelangen nicht in
die Verbraucher-Schnittstelle.
- Jeder Anschluss meldet `NichtVerwendet`, `Wartet`, `Verbunden` oder
`Fehler`.
- Fehlende optionale Anschluesse duerfen die lokale EMS-Grundfunktion nicht
blockieren.
- Zugangstoken werden weder als Property noch in Diagnosevariablen oder Logs
ausgegeben.
## Noch festzulegen
- Aufbau, Gueltigkeitszeitraum und Rueckmeldung eines SDL-Auftrags
- Produkt- und Zahlungsmodell der jaehrlichen Stoerueberwachungslizenz
- Verhalten nach Ablauf einer spaeter aktivierten Stoerueberwachungslizenz
+122
View File
@@ -0,0 +1,122 @@
# Offene Punkte
Diese Datei ist die zentrale, repositoryuebergreifende Liste fuer Themen, die das
Enelix-Team noch entscheiden muss. Sie dient als Arbeitsgrundlage fuer
Sprintmeetings. Modulinterne Implementierungsdetails bleiben in der jeweiligen
Moduldokumentation.
## Arbeitsweise
- Neue Punkte erhalten fortlaufende IDs im Format `OP-001`.
- Erlaubte Status sind `Offen`, `In Klaerung`, `Entschieden` und `Zurueckgestellt`.
- Ein Eintrag beschreibt die konkrete Entscheidungsfrage, nicht nur ein Stichwort.
- Entscheidungen bleiben in dieser Datei erhalten und verweisen auf ADR, Issue oder Commit.
- Neue Punkte werden standardmaessig ohne Verantwortlichen und Ziel-Sprint erfasst.
- Im Sprintmeeting werden Status, Verantwortlicher und Ziel-Sprint gepflegt.
## Aufnahmeschranke fuer offene Punkte und Issues
Ein neuer Eintrag wird nur angelegt, wenn er konkret, abgrenzbar und pruefbar ist.
- Eine offene Entscheidung nennt eine konkrete Entscheidungsfrage, ihren Kontext und die Auswirkung der Entscheidung.
- Ein Bug beschreibt betroffenes Verhalten, Ist- und Sollzustand sowie eine nachvollziehbare Ausloesung.
- Ein Feature oder Task nennt Anwendungsfall, Ausloeser oder Eingabe, erwartetes Verhalten und ein pruefbares Ergebnis.
- Ein Eintrag behandelt genau ein zusammenhaengendes Thema und kann unabhaengig umgesetzt oder entschieden werden.
- Zu grosse oder gebuendelte Anforderungen werden vor der Aufnahme in kleinere, einzeln pruefbare Punkte zerlegt.
Abstrakte Ziele, Visionen und Loesungsideen ohne konkretes Verhalten werden nicht aufgenommen.
Die Rueckmeldung lautet in diesem Fall sinngemaess:
> Nicht aufgenommen: Der Punkt ist noch zu abstrakt. Bitte in konkrete,
> einzeln ausfuehrbare und pruefbare Probleme, Entscheidungen oder Schritte unterteilen.
Beispiel fuer einen ausreichend konkreten Ausgangspunkt:
> Die Temperatureinheit des Boilers kann zwischen Celsius und Fahrenheit
> eingestellt werden; Anzeige und Grenzwerte verwenden die gewaehlte Einheit.
Beispiel fuer einen noch zu abstrakten Punkt:
> Der Manager soll aufgrund einer Prognose regeln.
Hier fehlen unter anderem Prognoseart, geregelte Verbraucher, konkretes Regelziel,
Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
## Uebersicht
| ID | Status | Bereich | Kurzthema |
| --- | --- | --- | --- |
| OP-001 | Entschieden | Lizenzierung | Betriebsort des Lizenzservers |
| OP-002 | Erledigt | Grundeinrichtung | Generator und kostenpflichtige Ersteinrichtung |
| OP-003 | Offen | Lizenzportal | VGT-Integration in license.enelix.ch |
| OP-004 | Entschieden | Lizenzierung | Lizenzmodell des Verbrauchskostenreports |
| OP-005 | Offen | Teststrategie | Standort fuer Feldtests |
## Offene Punkte
### OP-003: VGT-Integration in license.enelix.ch
- **Entscheidungsfrage:** Soll die VGT-Anwendung als Bestandteil in die
Oberflaeche von `license.enelix.ch` integriert werden?
- **Kontext:** Zu klaeren ist, ob die VGT-Anwendung innerhalb der Oberflaeche von
`license.enelix.ch` oder getrennt davon bereitgestellt wird.
- **Auswirkung:** Die Entscheidung legt fest, ob die VGT-Anwendung Teil dieser
Oberflaeche oder eine eigenstaendige Anwendung bleibt.
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
### OP-005: Standort fuer Feldtests
- **Entscheidungsfrage:** An welchem Standort koennen die Module aus Enelix EMS
und Enelix Utils unter realen Einsatzbedingungen getestet werden?
- **Kontext:** Fuer die Module aus beiden Repositories soll ein geeigneter
Standort fuer Feldtests festgelegt werden.
- **Auswirkung:** Die Entscheidung legt fest, wo die praktische Validierung der
Module unter realen Einsatzbedingungen stattfindet.
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
## Entschiedene Punkte
### OP-001: Betriebsort des Lizenzservers
- **Entscheidung:** Der Entwicklungs-Lizenzdienst wird unter
`https://license.enelix.ch` betrieben.
- **Manager-Endpunkt:** `POST /api/v1/licenses/activate`
- **Ergebnis/Verweis:** [Obere Anschluesse](Obere-Anschluesse.md) und
[Manager](module/Manager/README.md)
### OP-002: Generator und kostenpflichtige Ersteinrichtung
- **Entscheidung:** Der automatische Systemgenerator und die kostenpflichtige
Ersteinrichtung sind derselbe Vorgang.
- **Abrechnung:** Eine Bestellung aus dem Systemkonfigurator enthaelt
automatisch die noch nicht bezahlten Einrichtungskosten fuer den Manager und
die konfigurierten Verbrauchermodule. Bereits bezahlte Einrichtungsmengen
werden je Anlage angerechnet. Direkte Lizenzbestellungen enthalten keine
Einrichtungskosten.
- **Status:** Erledigt
- **Ergebnis/Verweis:** Umsetzung im Lizenzportal unter
`https://license.enelix.ch`.
### OP-004: Lizenzmodell des Verbrauchskostenreports
- **Entscheidung:** Der Verbrauchskostenreport verwendet eine Grundlizenz sowie
getrennte Kontingente fuer Stromzaehler und Nebenzaehler. Die Anzahl der
konfigurierten Zaehler wird bei der Lizenzpruefung beruecksichtigt.
- **Status:** Entschieden und implementiert
- **Ergebnis/Verweis:** Enelix Utils, Commit `1f945be` und
`Verbrauchskostenreport/module.php`.
## Vorlage fuer neue Punkte
```markdown
### OP-NNN: Kurztitel
- **Entscheidungsfrage:** Welche konkrete Entscheidung muss das Team treffen?
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
```
+112
View File
@@ -0,0 +1,112 @@
# 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
Einzelwert oder Leistungsbereich enthalten sein. Waehrend 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": "PV",
"PrioritaetPV": 0,
"PrioritaetPeak": 0,
"Leistungswerte_W": [{"Von_W": -5000, "Bis_W": 5000}],
"AenderungMoeglich": true,
"Verfuegbar": true,
"Istleistung_W": -250.0,
"Leistungsquelle": 2,
"Zustand": [
{
"Kennung": "Sollleistung_W",
"Art": "Sollwert",
"Wert": -537,
"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. Frei regelbare Lade- und Entladeangebote werden als
inklusive Bereiche in ganzen Watt gemeldet. Feste Schutz- und Peakvorgaben
bleiben einzelne Leistungswerte.
## 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).
+103
View File
@@ -0,0 +1,103 @@
# Easee-Gateway-Schnittstelle
Stand: 2026-09-22
Die Schnittstelle verbindet das kontobezogene Splittermodul `EaseeGateway`
mit beliebig vielen Kindinstanzen `LadestationGateway`.
## IP-Symcon-Daten-IDs
| Richtung | DataID |
| --- | --- |
| Ladestation an Gateway | `{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}` |
| Gateway an Ladestation | `{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}` |
Die aeussere IP-Symcon-Nachricht enthaelt `DataID` und `Buffer`. `Buffer`
ist wiederum ein JSON-Objekt.
## Anfragen an das Gateway
### Subscribe und GetState
```json
{
"action": "Subscribe",
"serialNumber": "EH123456"
}
```
`GetState` besitzt dasselbe Format. Beide Aktionen registrieren die
Seriennummer und liefern den Cache:
```json
{
"success": true,
"connected": true,
"state": {
"109": 3,
"110": 30,
"120": 11.0,
"updated": 1790053200
}
}
```
### SetDynamicChargerCurrent
```json
{
"action": "SetDynamicChargerCurrent",
"serialNumber": "EH123456",
"amps": 13
}
```
Zulaessig sind `0 A` oder ganzzahlige Werte von `6 A` bis `32 A`.
Das Gateway ruft
`POST /api/chargers/{serialNumber}/commands/set_dynamic_charger_current`
mit `{"amps":13,"minutes":0}` auf.
## Ereignisse an Kindinstanzen
### Observation
```json
{
"type": "Observation",
"serialNumber": "EH123456",
"id": 110,
"value": 30,
"timestamp": 1790053200
}
```
Verteilt werden die IDs `47`, `48`, `100`, `104`, `109`, `110`,
`119`, `120`, `182` bis `185` und `250`. Jede Kindinstanz verwirft
Ereignisse anderer Seriennummern.
### GatewayStatus
```json
{
"type": "GatewayStatus",
"connected": false
}
```
Bei `false` setzt die Ladestation Verfuegbarkeit und Aenderbarkeit sofort
zurueck. Bei `true` fordert sie den aktuellen Zustand erneut an.
## Zustandsabbildung
Observation `109` wird gemaess Easee OpMode abgebildet: `0` offline,
`1` getrennt, `2/6/7/8` bereit, `3` laedt, `4` geladen und `5` Fehler.
Unbekannte Werte geben die Regelung nicht frei. Observation `110` liefert die
aktive Ausgangsphase: `10..15` einphasig und `30` dreiphasig. Es gibt keine
leistungsbasierte Phasenschaetzung.
## Fehlervertrag
Gateway-Antworten enthalten immer `success`. Bei `false` folgt ein
menschenlesbares Feld `error`; optional wird `httpCode` ergaenzt.
Zugangsdaten, Access Token und Refresh Token duerfen weder in Antworten noch
in Ereignissen oder Diagnosevariablen vorkommen.
+102 -25
View File
@@ -1,6 +1,6 @@
# EMS-Schnittstelle # EMS-Schnittstelle
Vertragsversion: `3.0` Vertragsversion: `4.0`
Es gibt genau eine fachliche Empfangsmethode je Richtung: Es gibt genau eine fachliche Empfangsmethode je Richtung:
@@ -13,7 +13,7 @@ public function VerbraucherdatenEmpfangen(array $daten): void;
| Feld | Typ | Bedeutung | | Feld | Typ | Bedeutung |
| --- | --- | --- | | --- | --- | --- |
| `Version` | Text | Vertragsversion `3.0` | | `Version` | Text | Vertragsversion `4.0` |
| `AbsenderID` | Ganzzahl | Sendende Symcon-Instanz | | `AbsenderID` | Ganzzahl | Sendende Symcon-Instanz |
| `EmpfaengerID` | Ganzzahl | Empfangende Symcon-Instanz | | `EmpfaengerID` | Ganzzahl | Empfangende Symcon-Instanz |
| `Zeitpunkt` | Ganzzahl | Unixzeit in UTC | | `Zeitpunkt` | Ganzzahl | Unixzeit in UTC |
@@ -23,61 +23,138 @@ public function VerbraucherdatenEmpfangen(array $daten): void;
```json ```json
{ {
"Kopf": { "Kopf": {
"Version": "3.0", "Version": "4.0",
"AbsenderID": 10001, "AbsenderID": 10001,
"EmpfaengerID": 20001, "EmpfaengerID": 20001,
"Zeitpunkt": 1788825600 "Zeitpunkt": 1788825600
}, },
"Betriebsart": "PV",
"Sollleistung_W": 1501 "Sollleistung_W": 1501
} }
``` ```
`Sollleistung_W` ist immer eine Ganzzahl und muss im aktuell gemeldeten Leistungsangebot liegen. `Betriebsart` ist `PV` oder `Peak`. `Sollleistung_W` ist eine Ganzzahl
aus dem fuer diese Betriebsart gemeldeten Leistungsangebot oder `null`.
`null` kuendigt nur die Betriebsart an. Der Verbraucher uebernimmt sie,
berechnet sein Leistungsangebot neu und meldet es zurueck. Eine vorhandene
Sollleistung wird dabei nur verworfen, wenn sie im neuen Angebot nicht mehr
zulaessig ist.
## Verbraucher an Manager ## Verbraucher an Manager
```json ```json
{ {
"Kopf": { "Kopf": {
"Version": "3.0", "Version": "4.0",
"AbsenderID": 20001, "AbsenderID": 20001,
"EmpfaengerID": 10001, "EmpfaengerID": 10001,
"Zeitpunkt": 1788825602 "Zeitpunkt": 1788825602
}, },
"Betriebsart": "Peak",
"PrioritaetPV": 0, "PrioritaetPV": 0,
"PrioritaetPeak": 0, "PrioritaetPeak": 0,
"Leistungswerte_W": [ "Leistungswerte_W": [0],
-3000, "AenderungMoeglich": false,
-2000,
{"Von_W": -1000, "Bis_W": -500},
0,
100,
{"Von_W": 1000, "Bis_W": 2000},
3000
],
"AenderungMoeglich": true,
"Verfuegbar": true, "Verfuegbar": true,
"Istleistung_W": 1498.5, "Istleistung_W": 0,
"Leistungsquelle": 2, "Leistungsquelle": 1,
"Zustand": [ "Zustand": [
{ {
"Kennung": "Sollleistung_W", "Kennung": "Sollleistung_W",
"Art": "Sollwert", "Art": "Sollwert",
"Wert": 1501, "Wert": 0,
"Einheit": "W" "Einheit": "W"
} }
] ]
} }
``` ```
### Feste Regeln ## Betriebsart-Synchronisation
- Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet eine hoehere Prioritaet. 1. Der Manager bestimmt `PV` oder `Peak`.
2. Meldungen einer anderen Betriebsart werden nicht zur Verteilung verwendet.
3. Der Manager sendet diesen Verbrauchern eine Betriebsart-Ankuendigung mit
`Sollleistung_W=null`.
4. Jeder Verbraucher berechnet und meldet seine PowerSteps fuer diese
Betriebsart.
5. Der Manager verteilt Sollleistungen an alle bereits synchronisierten
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
melden, ohne dass der Manager seine interne Geraetelogik kennen muss.
## Feste Regeln
- Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet hoehere Prioritaet.
- `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` einschliesslich. - Leistungsbereiche enthalten jeden ganzen Wattwert von `Von_W` bis `Bis_W`.
- Die Leistungswerte sind aufsteigend, eindeutig und ueberschneiden sich nicht. - Leistungswerte sind aufsteigend, eindeutig und ueberschneiden sich nicht.
- `Zustand` enthaelt immer den gemeinsamen Eintrag `Sollleistung_W` und daneben nur benoetigte geraetespezifische Eintraege. - `Zustand` enthaelt immer `Sollleistung_W`.
- Eine aktive Stoerung wird mit `Art=Stoerung` und `Wert=true` gemeldet; eine behobene mit `false`. - Verbraucher werden ausschliesslich im Manager zugeordnet.
- Verbraucher werden ausschliesslich im Manager zugeordnet. Der Verbraucher besitzt keine Manager-ID-Property. - Der Verbraucher besitzt keine Manager-ID-Property.
## Technische Umsetzung in IP-Symcon
Der Transport erfolgt ueber `IPS_RequestAction` mit JSON. `MessageSink`
erkennt registrierte Aenderungen. Empfang und Neuberechnung sind intern
entkoppelt, damit keine gegenseitige Endlosschleife entsteht.
Das PHP-Interface legt nur die Empfangsmethode fest. Die gemeinsamen
Symcon-Datenpunkte registriert `VerbraucherBasisTrait`.
### Gemeinsame Properties aller Verbraucher
| Ident | Typ | Standard | Beschreibung |
| --- | --- | --- | --- |
| `PrioritaetPV` | Integer | `0` | Prioritaet in der Betriebsart PV |
| `PrioritaetPeak` | Integer | `0` | Prioritaet in der Betriebsart Peak |
| `Meldeintervall` | Integer | `10` | Vollstaendige Rueckmeldung in Sekunden |
| `VorgabeTimeout` | Integer | `120` | Ablaufzeit einer Sollleistung |
| `EinstellungenInVisu` | Boolean | `false` | Lokale Einstellungen in der Visualisierung |
| `LoggingEin` | Boolean | `false` | Laufendes Diagnoseprotokoll |
### Gemeinsame Variablen aller Verbraucher
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; Start `false` |
| `Istleistung` | Float / Anzeige | Aktuelle Leistung in W |
| `Leistungsquelle` | Integer / Anzeige | 0 nicht vorhanden, 1 berechnet, 2 gemessen |
| `Sollleistung` | Integer / Anzeige | Angenommene oder lokal erzwungene Vorgabe |
| `SollwertGueltig` | Boolean / Anzeige | Aktuelle, nicht abgelaufene Vorgabe |
| `Verfuegbar` | Boolean / Anzeige | Verbraucher grundsaetzlich verfuegbar |
| `AenderungMoeglich` | Boolean / Anzeige | Neue Vorgabe darf uebernommen werden |
| `Stoerung` | Boolean / Anzeige | Mindestens eine Stoerung aktiv |
| `Stoertext` | String / Anzeige | Zusammengefasste Stoerbeschreibung |
`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
`LadestationGateway` ist getrennt vom fachlichen Managervertrag dokumentiert:
[Easee-Gateway-Schnittstelle](Schnittstelle-Easee-Gateway.md). Die
Ladestation uebersetzt Gateway-Ereignisse in den hier beschriebenen
Verbrauchervertrag `4.0`.
## Zeitverhalten
- Rueckmeldung nach Start, relevanten Aenderungen und alle `Meldeintervall`
Sekunden.
- Laufende Vorgaben werden vom Manager standardmaessig erneuert.
- Nach `VorgabeTimeout` ist eine nicht erneuerte Vorgabe ungueltig.
- Nach einem Neustart wird keine alte Vorgabe ungeprueft aufgenommen.
@@ -0,0 +1,24 @@
# ADR 0001: Warmwassererwaermer verwendet ausschliesslich Vertrag 3.0
## Status
Akzeptiert.
## Entscheidung
Der neue `VerbraucherWarmwassererwaermer` implementiert die bestehende
`VerbraucherSchnittstelle` und den `Nachrichtenvertrag` in Version `3.0`.
Die Enelix-1-Variablen `Power`, `PowerSteps`, `PV_Prio`, `Sperre_Prio`,
`Is_Peak_Shaving` und `Leistung_Delta` werden nicht als zweite parallele
Manager-Schnittstelle weitergefuehrt.
Der Manager bleibt allein fuer die Betriebsart und Leistungsverteilung
verantwortlich. Lokale Temperatur- und Hygienesicherheit wird als eingeschraenktes
Leistungsangebot mit `AenderungMoeglich=false` gemeldet.
## Folgen
- Es gibt genau eine fachliche Empfangsmethode pro Richtung.
- Der Verbraucher hat keine Manager-ID-Property.
- Alte Instanzen werden anhand der Migrationstabelle neu konfiguriert.
- Manager und Verbraucher koennen unabhaengig getestet werden.
@@ -0,0 +1,74 @@
# ADR 0002: Verbraucher 1-Stufig arbeitet ereignisbasiert
## Status
Akzeptiert, am 17. September 2026 um getrennte Mindestzeiten erweitert.
## Kontext
Das Enelix-1-Modul berechnete seinen Zustand in einem festen Intervall und
bildete Lastwechselsperren ueber `Interval`, `IdleCounterMax` und weitere
zyklusabhaengige Zaehler ab. Ein erster Enelix-2-Stand ersetzte dies durch
einen allgemeinen `Umschaltabstand`. Dieser konnte unterschiedliche
Geraeteanforderungen fuer Ein- und Aus-Zustand nicht ausdruecken.
Bei einem asynchron schaltenden Geraet kann ausserdem der Aktorbefehl nicht als
physische Zustandsbestaetigung gelten.
## Entscheidung
Der `VerbraucherEinStufig` verwendet keine zyklische Regelberechnung.
Schaltkontakt, Rueckmeldung, Manager-Vorgabe, Freigabe, Vorgabeablauf und
Tagesplanung loesen die Regelung direkt aus.
Der allgemeine `Umschaltabstand` entfaellt. An seine Stelle treten:
- `Mindesteinschaltdauer` in Sekunden, Standard 5,
- `Mindestausschaltdauer` in Sekunden, Standard 5.
Die passende Mindestzeit beginnt mit dem bestaetigten Zustandswechsel. Ohne
separate Rueckmeldung bestaetigt die Aktorvariable den Wechsel unmittelbar.
Mit separater Rueckmeldung beginnt die Mindestzeit erst, wenn deren Wert den
Zielzustand erreicht.
Bis dahin bleiben Istleistung und Leistungsangebot beim rueckgemeldeten
Zustand. `SchaltbefehlAusstehend=true`,
`AenderungMoeglich=false` und `Schaltbereit=false` machen die laufende
Umschaltung fuer den Manager sichtbar.
Das gemeinsame `Meldeintervall` bleibt erhalten, weil Vertrag `3.0`
zusaetzlich zu Ereignismeldungen eine periodische Vollmeldung fordert. Dieser
Timer ist kein Regelzyklus.
Die Tagesmindestlaufzeit wird in realen Sekunden und lokaler Symcon-Zeitzone
gezaehlt. Bei separater Rueckmeldung zaehlt ausschliesslich der bestaetigte
Ein-Zustand.
## Alternativen
- Ein fester Regelzyklus wurde verworfen, weil Reaktionszeit und Zeitregeln
wieder voneinander abhaengen wuerden.
- Ein allgemeiner Umschaltabstand wurde verworfen, weil Ein- und Aus-Zustand
unterschiedliche Mindestzeiten benoetigen koennen.
- Der Aktorbefehl als sofortige physische Bestaetigung wurde bei vorhandener
Rueckmeldung verworfen.
- Ein zusaetzlicher Rueckmelde-Timeout wurde nicht eingefuehrt. Ein
ausstehender Befehl bleibt transparent sichtbar, bis ein neues Ereignis den
Zustand klaert.
## Folgen
- Lastwechsel reagieren ohne Polling auf relevante Ereignisse.
- `Interval`, `IdleCounterMax` und `Umschaltabstand` entfallen.
- Waerend Mindestzeiten und ausstehenden Rueckmeldungen wird nur die
bestaetigte Istleistung angeboten.
- Der Manager erhaelt Status und Restmindestzeit als Zustandseintraege.
- Eine unerwartete Abweichung zwischen Aktor und Rueckmeldung ausserhalb eines
laufenden Schaltvorgangs wird als Stoerung gemeldet.
- Tageslaufzeit und Mindestzeiten sind unabhaengig von einer Zyklusdauer.
- Das Modul bleibt vollstaendig im Repository Enelix EMS.
## Offene Punkte
- Eine spaetere sperrbare Variante wird separat spezifiziert.
- Ein konfigurierbarer Rueckmelde-Timeout benoetigt eine eigene Entscheidung.
@@ -0,0 +1,43 @@
# ADR 0003: Standardisierte Teststrategie
## Kontext
Die Module besitzen PHPUnit-Tests, ihre Prüfungen in einer echten
IP-Symcon-Installation waren jedoch unterschiedlich aufgebaut. Dadurch fehlten
ein einheitlicher Aufruf, sicherer Cleanup, maschinenlesbare Berichte und eine
verbindliche Regel für neue Module.
## Entscheidung
Enelix verwendet zwei Testebenen. PHPUnit bleibt die schnelle Pflichtprüfung bei
jedem Push. Zusätzlich erhält jedes Modul einen registrierten Symcon-Modultest
mit dem gemeinsamen `TestContext`. Der Runner unterstützt `single`,
`affected` und `all`, isoliert jeden Testlauf und liefert Konsole, JSON und
JUnit XML.
Der Manager-Test erzeugt jeden implementierten Verbrauchertyp und prüft ihn
einzeln sowie in einer gemeinsamen Konstellation. Neue Verbrauchertypen müssen
diese Matrix erweitern.
## Alternativen
- Nur PHPUnit: verworfen, weil das reale Objektmodell, Actions und
Modulinteraktionen nicht abgedeckt werden.
- Freie Schnellausführungs-Skripte pro Modul: verworfen, weil Aufbau, Cleanup
und Berichte erneut auseinanderlaufen würden.
- Ein drittes Test-Repository: vorerst verworfen, weil Tests zusammen mit dem
jeweiligen Modul versioniert und atomar geändert werden sollen.
## Folgen
Jedes neue Modul benötigt zusätzlich zu Unit-Tests einen Manifest-Eintrag und
einen Symcon-Test. Der Vertrags-Unit-Test verhindert unregistrierte Module.
Integrationstests benötigen einen isolierten IP-Symcon-8.x-Runner. Testobjekte
dürfen ausschließlich innerhalb der vom Framework erzeugten Kategorie liegen.
## Offene Punkte
- Bereitstellung und Registrierung des Gitea-Runners mit Label `symcon-8`.
- Festlegung der Aufbewahrungsdauer für JSON- und JUnit-Artefakte.
- Erweiterung der Manager-Matrix, sobald weitere Verbrauchertypen umgesetzt
werden.
@@ -0,0 +1,76 @@
# ADR 0004: Betriebsartabhaengige Leistungsangebote
## Kontext
Der Nachrichtenvertrag 3.0 uebermittelte vom Manager nur
`Sollleistung_W`. Verbraucher meldeten ein einziges Leistungsangebot und
kannten die aktuelle Betriebsart nicht. Damit konnten Module keine
unterschiedlichen PowerSteps fuer PV- und Peakbetrieb bereitstellen.
Eine einfache Ergaenzung des Sollwertpakets reicht nicht aus: Beim Wechsel der
Betriebsart besitzt der Manager zunaechst noch das Angebot der vorherigen
Betriebsart. Eine sofortige Verteilung koennte deshalb einen Sollwert erzeugen,
den der Verbraucher im neuen Modus ablehnen muss.
## Entscheidung
Der Vertrag wird inkompatibel auf Version `4.0` angehoben.
- Managerdaten und Verbraucherdaten enthalten verpflichtend `Betriebsart`
mit dem Wert `PV` oder `Peak`.
- `Sollleistung_W` in Managerdaten darf `null` sein.
- `null` ist eine reine Betriebsart-Ankuendigung und kein Schaltbefehl.
- Der Verbraucher uebernimmt die Betriebsart, berechnet sein Leistungsangebot
neu und meldet dieses mit derselben Betriebsart zurueck.
- Der Manager verwendet nur Angebote seiner aktuellen Betriebsart.
- Solange mindestens ein aktiver Verbraucher nicht synchronisiert ist, erfolgt
keine Verteilung.
- Nach einem Betriebsartwechsel verwirft der Manager seinen Sollwertcache.
Die konkrete Differenz zwischen PV- und Peakangebot bleibt Verantwortung des
Verbrauchermoduls. Die derzeit implementierten Verbraucher uebernehmen die
zustandsabhaengigen Enelix-1-Angebote gezielt:
- Der einstufige Verbraucher bietet in Peak normalerweise `[0]`. Bei faelliger
Tagesmindestlaufzeit ist konfigurierbar, ob `[0, Nennleistung]` angeboten
oder nur die Nennleistung erzwungen wird.
- Die Ladestation bietet in Peak mit Solarladen `[0]`, ohne Solarladen dagegen
`[0, ...Ladestufen]` an.
- Der Warmwassererwaermer bietet unter seiner wirksamen Mindesttemperatur auch
in Peak `[0, ...Leistungsstufen]` an.
- Der Pufferspeicher bietet im Peakbetrieb unabhaengig vom Zustand `[0]` an.
- Technische Schaltsperren und ausstehende Rueckmeldungen duerfen das Angebot
weiterhin auf die aktuell gehaltene Leistung begrenzen.
## Alternativen
### Zwei Angebote gleichzeitig melden
Separate Felder fuer PV- und Peak-PowerSteps wuerden den Umschalt-Handshake
vermeiden. Sie verdoppeln jedoch alle Angebotsdaten und zwingen jedes Modul,
beide Zustaende jederzeit parallel zu berechnen.
### Betriebsart ohne Synchronisation senden
Der Manager koennte Betriebsart und Sollleistung in einem Paket senden. Das
erste Kommando nach einem Wechsel waere dann aus dem alten Angebot berechnet
und koennte ungueltig sein.
### Betriebsart aus einer Manager-Variable lesen
Eine direkte Objektkopplung wuerde die definierte Schnittstelle umgehen,
mehrere Manager erschweren und Verbraucher unnoetig an die Managerinstanz
binden.
## Folgen
- Alle Manager und Verbraucher einer Installation muessen gemeinsam auf
Vertrag 4.0 aktualisiert werden.
- Version 3.0 und 4.0 koennen nicht innerhalb derselben Managerzuordnung
gemischt werden.
- Ein Betriebsartwechsel benoetigt mindestens einen zusaetzlichen
Nachrichtenumlauf.
- Tests pruefen Vertragsvalidierung, Synchronisation und modulspezifische
Angebote fuer PV und Peak.
- ADR 0001 bleibt als historische Entscheidung erhalten, wird fuer den
aktuellen Vertrag aber durch diese Entscheidung abgeloest.
@@ -0,0 +1,76 @@
# ADR 0005: Anlagentopologie als Manager-Stammdaten
## Kontext
Die Prognose benoetigt technische Stammdaten zu PV-Flaechen,
Wechselrichtern und Batteriespeichern. Diese Daten beschreiben die reale
Installation und werden auch von der lokalen Regelung benoetigt. Eine
unabhaengige Pflege im Prognoseportal wuerde zwei konkurrierende Wahrheiten
erzeugen.
Hybridwechselrichter benoetigen eine ausdrueckliche Topologie. PV und Batterie
duerfen nicht als zwei unabhaengige AC-Quellen mit jeweils voller
Wechselrichterleistung behandelt werden.
## Entscheidung
Der Enelix-Manager ist die fuehrende Quelle fuer die technische
Anlagentopologie. Er speichert drei getrennte Listen:
- Wechselrichter mit Typ, AC-Nennleistung und optionalen AC-Grenzen,
- PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichterbezug,
- Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und
Wechselrichterbezug.
Jede Komponente besitzt eine innerhalb ihrer Liste eindeutige, stabile ID.
PV-Flaechen und Batterien referenzieren einen Wechselrichter ueber diese ID.
Die AC-Nennleistung gehoert zum Wechselrichter. Die DC-Leistung gehoert zur
PV-Flaeche. Modulanzahl und Modulleistung sind optionale Detailangaben und
muessen gemeinsam gepflegt werden.
Bei einem Hybridwechselrichter bleiben PV-Flaechen und Batterie getrennte
Komponenten. Der Exportvertrag liefert zusaetzlich eine gemeinsame AC-Grenze,
die beide Seiten demselben Wechselrichter zuordnet. Damit darf ihre kombinierte
AC-Leistung die Wechselrichtergrenze nicht unabhaengig mehrfach ausschoepfen.
Der Manager exportiert die normalisierte Struktur mit Vertragsversion '1.0'
und seiner stabilen Lizenz-Installations-ID. Eine aggregierte Zusammenfassung
erleichtert die schrittweise Anbindung bestehender Prognoseberechnungen.
Tarife, Prognosevarianten und rein prognosespezifische Annahmen bleiben im
Prognoseportal. Dort werden die vom Manager gelieferten technischen Stammdaten
standardmaessig nur angezeigt.
## Alternativen
### Vollstaendige Pflege im Prognoseportal
Diese Variante waere fuer den Prognosedienst einfach, erzeugt aber
Doppelpflege und kann von der lokal tatsaechlich installierten Anlage
abweichen.
### Batterie und PV als ein Hybridobjekt speichern
Ein einzelnes Objekt waere kompakt, bildet mehrere PV-Flaechen, MPPT-Eingaenge
und spaetere Erweiterungen jedoch schlecht ab. Ausserdem gingen die getrennten
Kapazitaets- und Leistungsgrenzen verloren.
### AC- und DC-Leistung an jeder PV-Flaeche speichern
Das wuerde bei mehreren Flaechen an einem Wechselrichter die AC-Leistung
mehrfach zaehlen. Deshalb wird AC am Wechselrichter und DC an der Flaeche
gespeichert.
## Folgen
- Bestehende Installationen bleiben mit drei leeren Listen gueltig.
- Fehlerhafte Referenzen oder unplausible technische Grenzen sperren die
Manager-Konfiguration kontrolliert.
- Ein Hybridwechselrichter kann mehrere PV-Flaechen und Batteriespeicher
verbinden, besitzt aber nur eine gemeinsame AC-Nennleistung.
- Das Prognoseportal muss die technischen Stammdaten aus dem Managervertrag
lesen und eigene Eingaben dafuer als schreibgeschuetzt behandeln.
- Fuer den automatischen Upload ist ein eigener, widerrufbarer
Installationszugang erforderlich. Der Lizenzcode selbst wird nicht als
dauerhaftes API-Secret verwendet.
+67
View File
@@ -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
- 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 |
| 250-W-Grundraster mit 50-W-Feinwerten | inklusive Leistungsbereiche in ganzen Watt |
| 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.
+49
View File
@@ -0,0 +1,49 @@
# Migration von Boiler_x_Stufig
## Eigenschaften
| Enelix 1 | Enelix 2 | Hinweis |
| --- | --- | --- |
| `LeistungsStufen` | `LeistungsStufen` | Felder `Stufe`, `Leistung`, `Schaltkontakt_Stufe` bleiben erhalten. |
| `ZeitKonstante` | `ZeitKonstante` | PT1-Berechnung verwendet die tatsaechlich verstrichene Zeit. |
| `Boilerfuehler_PT1` | `Boilerfuehler_PT1` | Alter und Datentyp werden geprueft. |
| `Boilertemperatur_glätten` | `BoilertemperaturGlaetten` | ASCII-sicherer Ident. |
| `Boilervolumen` | `Boilervolumen` | Wird fuer die Zeitplanprognose verwendet. |
| `Zeitplan` | `Zeitplan` | JSON-Liste mit `Uhrzeit` und `Solltemperatur`. |
| `Interval` | entfaellt | Die Regelung wird ereignisbasiert ausgeloest. |
| `IdleCounterMax` | `LastwechselSperrzeit` | Zykluszaehler wird durch eine Sperrzeit in Sekunden ersetzt; Standard `5`. |
Die neuen Properties `TemperaturMaxAlter`, `Hysterese`, die Parameter der
Legionellenfunktion und `DiagnosevariablenAnzeigen` ersetzen feste Werte und
nicht pruefbare Annahmen des Altmoduls.
## Variablen
| Enelix 1 | Enelix 2 |
| --- | --- |
| `Mindesttemperatur` | `Mindesttemperatur` |
| `Maximaltemperatur` | `Maximaltemperatur` |
| `Legionellentemperatur` | `Legionellentemperatur` |
| `Boilertemperatur` | `Boilertemperatur` |
| `LegioCounter` | `LegioCounter` |
| `Idle` | entfaellt; Freigabe ueber `AenderungMoeglich` |
| `IdleCounter` | entfaellt; Sperre ueber `Leistungswerte_W` und `AenderungMoeglich` |
| `Aktuelle_Leistung` | gemeinsames `Istleistung` |
| `Bezogene_Energie` | `BezogeneEnergie` in kWh |
| `PV_Prio` | Property `PrioritaetPV` |
| `Sperre_Prio` | Property `PrioritaetPeak` |
| `Power` | gemeinsames `Sollleistung` |
| `PowerSteps` | Vertragsfeld `Leistungswerte_W`, keine Variable |
| `Is_Peak_Shaving` | entfaellt; Betriebsart liegt im Manager |
| `Leistung_Delta` | entfaellt; Leistungsverteilung liegt im Manager |
## Inbetriebnahme
1. Neue Instanz `VerbraucherWarmwassererwaermer` anlegen.
2. Fuehler, Leistungsstufen und Schaltkontakte uebertragen.
3. Temperaturvariablen einmalig mit den bisherigen Sollwerten setzen.
4. Die Instanz im Enelix-2-Manager unter `VerbraucherZuordnung` aktiv zuordnen.
5. Erst danach `Aktiv` einschalten und jede Stufe beaufsichtigt pruefen.
Das Modul verwendet eine neue Modul-ID. Eine automatische Umwandlung der alten
Instanz findet deshalb nicht statt.
+61
View File
@@ -0,0 +1,61 @@
# Migration von Verbraucher_1_Stufig
Das Enelix-1-Modul wird nicht in-place aktualisiert. Das neue Modul verwendet
eine neue Modul-ID, Vertrag `3.0` und eine ereignisbasierte Regelung.
## Eigenschaften
| Enelix 1 / bisheriges Enelix 2 | Aktuelles Enelix 2 | Migration |
| --- | --- | --- |
| `BoilerLeistung` | `Nennleistung` | Wert in ganzen Watt uebernehmen. |
| `Schaltkontakt1` | `SchaltkontaktVariableID` | Boolean-Aktor uebernehmen und Aktion pruefen. |
| nicht vorhanden | `SchaltkontaktInvertiert` | Nur bei umgekehrter Aktorlogik aktivieren. |
| nicht vorhanden | `RueckmeldungVariableID` | Optional eine echte Boolean-Rueckmeldung zuordnen. |
| `Mindesttlaufzeit` | `Mindestlaufzeit` | Alten Minutenwert fuer gleiches Verhalten mit 60 multiplizieren. |
| `Zeit_Zwischen_Zustandswechseln` | `Mindesteinschaltdauer`, `Mindestausschaltdauer` | Alte Minuten nicht ungeprueft uebernehmen; beide Sekundenwerte geraetespezifisch setzen. |
| `Umschaltabstand` aus fruehem Enelix 2 | `Mindesteinschaltdauer`, `Mindestausschaltdauer` | Der alte Wert wird nicht automatisch uebertragen; beide Werte haben Standard 5 s. |
| `Interval` | entfaellt | Keine zyklische Regelberechnung. |
| `IdleCounterMax` | entfaellt | Keine zyklusbasierte Idle-Erkennung. |
## Variablen und Verhalten
| Alt | Aktuell |
| --- | --- |
| `DailyOnTime` | `Tageslaufzeit` in bestaetigten echten Sekunden |
| `IstNacht` | entfaellt; Tagesmindestlaufzeit wird spaetestmoeglich erzwungen |
| `IsTimerActive` | entfaellt; interne einmalige Timer |
| `Aktuelle_Leistung` | gemeinsames `Istleistung` |
| `Power` | gemeinsames `Sollleistung` |
| `PowerSteps` | Vertragsfeld `Leistungswerte_W` |
| `PV_Prio` | Property `PrioritaetPV` |
| `Sperre_Prio` | Property `PrioritaetPeak` |
| `Is_Peak_Shaving` | entfaellt; Betriebsart liegt im Manager |
| `Leistung_Delta` | entfaellt; Leistungsverteilung liegt im Manager |
| `Idle`, `IdleCounter` | entfallen |
Bei konfigurierter Rueckmeldung bestimmt nicht mehr der ausgegebene
Aktorbefehl, sondern erst der rueckgemeldete Zustand die Istleistung, die
Tageslaufzeit und den Start der jeweiligen Mindestzeit.
## Inbetriebnahme
1. Bestehende Instanzkonfiguration und Aktorlogik dokumentieren.
2. Neue Instanz **Verbraucher 1-Stufig** anlegen oder die bestehende
Enelix-2-Instanz aktualisieren.
3. Leistung, Aktor, Invertierung und optional Rueckmeldung konfigurieren.
4. Tagesmindestlaufzeit gegebenenfalls von Minuten in Sekunden umrechnen.
5. Mindest-Ein- und Mindest-Aus-Zeit getrennt anhand der Geraeteanforderungen
setzen.
6. Neue Instanz im Enelix-2-Manager aktiv zuordnen.
7. Ein- und Ausschalten zuerst ohne reale Last oder unter Aufsicht pruefen.
8. Bei separater Rueckmeldung kontrollieren, dass
`SchaltbefehlAusstehend` erst mit deren Zustandswechsel verschwindet.
9. Mindestens je einen verhinderten Ein- und Ausschaltvorgang testen.
10. Tageslaufzeit und Tageswechsel kontrollieren.
## Rueckkehr
Solange die alte Instanz nicht geloescht wurde, kann zurueckgekehrt werden,
indem die neue Instanz im Manager deaktiviert und ausgeschaltet wird. Danach
darf die alte Instanz wieder aktiviert werden. Beide Module duerfen nie
gleichzeitig denselben Aktor steuern.
+125
View File
@@ -0,0 +1,125 @@
# Batterie
> Status: implementiert. Zielplattform ist IP-Symcon ab Version 8.0,
> Nachrichtenvertrag 4.0.
## Verantwortung
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.
Positive Leistung bedeutet Laden, negative Leistung Entladen. Die
Geraeteanbindung erfolgt ausschliesslich ueber vom Benutzer ausgewaehlte
numerische IP-Symcon-Variablen. Schreibziele muessen eine Aktion besitzen.
## Ereignismodell
Fachliche Neuberechnungen werden ausgeloest durch:
- 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
Der Meldezyklus verschickt eine Vollmeldung und dient nicht als Regelzyklus.
Ein periodischer Berechnungstimer oder Idle-Counter existiert nicht.
## Properties
### Gemeinsame Verbraucherproperties
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
Die Batterie meldet innerhalb der dynamischen Lade- und Entladegrenzen
inklusive Leistungsbereiche in ganzen Watt. Der Manager kann dadurch jeden
ganzzahligen Sollwert innerhalb des aktuell erlaubten Bereichs vorgeben. Die
Grenzen werden abgerundet, damit keine dynamische Maximalleistung ueberschritten
wird.
PV und Peak verwenden weiterhin die bisherige SoC-Logik. Feste Schutz- oder
Peakvorgaben bleiben Einzelwerte; frei regelbare Angebote werden als Bereiche
gemeldet. 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: Leistungsbereiche, 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
+77
View File
@@ -0,0 +1,77 @@
# Easee Gateway
> Status: implementiert fuer IP-Symcon 8 und die Easee Cloud API.
Das Modul stellt pro Easee-Nutzerkonto genau eine gemeinsame Verbindung bereit.
Benutzername, Passwort, Access Token und Refresh Token verbleiben im Gateway.
Mehrere Instanzen von **Ladestation Gateway** koennen denselben Elternknoten
verwenden.
## Funktionsumfang
- Anmeldung mit Easee-Benutzerkonto und automatische Token-Erneuerung,
- gemeinsame Ereignisverbindung zu `streams.easee.com`,
- Abonnement mehrerer Ladestationen mit aktuellem Zustand,
- Verteilung der Easee-Observations an die passenden Kindinstanzen,
- Stromvorgabe ueber `set_dynamic_charger_current`,
- Wiederverbindung und erneute Anmeldung aller Stationen nach Unterbrechungen,
- TLS-Zertifikatspruefung standardmaessig aktiv.
## Properties
| Ident | Typ | Standard | Beschreibung |
| --- | --- | --- | --- |
| `Active` | Boolean | `true` | Aktiviert Anmeldung und Ereignisverbindung. |
| `Username` | String | leer | E-Mail-Adresse oder Telefonnummer des Easee-Kontos. |
| `Password` | Passwort | leer | Passwort, nur im Gateway gespeichert. |
| `VerifyCertificate` | Boolean | `true` | Prueft TLS-Zertifikate fuer REST und WebSocket. |
Die interne Property `Testmodus` ist nicht im Formular sichtbar und wird nur
vom automatisierten IP-Symcon-Test verwendet.
## Variablen
| Ident | Typ | Beschreibung |
| --- | --- | --- |
| `Connected` | Boolean | Ereignisverbindung ist betriebsbereit. |
| `SubscriptionCount` | Integer | Anzahl angemeldeter Seriennummern. |
| `LastError` | String | Letzter Fehler ohne Zugangsdaten oder Tokens. |
## API und Ereignisse
Der Gateway-Transport ist in
[Easee-Gateway-Schnittstelle](../../Schnittstelle-Easee-Gateway.md)
vollstaendig beschrieben. Fuer Fahrzeug- und Phasenstatus werden insbesondere
Observation `109` und `110` verteilt. Nach jeder Wiederverbindung wird der
Cache verworfen und durch `SubscribeWithCurrentState` neu aufgebaut.
## Fehlerbehandlung
| Status | Bedeutung |
| ---: | --- |
| `102` | Verbindung aktiv. |
| `104` | Gateway deaktiviert. |
| `201` | Benutzername oder Passwort fehlt. |
| `202` | Anmeldung oder Token-Erneuerung fehlgeschlagen. |
| `203` | Ereignisverbindung fehlgeschlagen. |
REST-Aufrufe verwenden 5 Sekunden Verbindungs- und 30 Sekunden
Gesamt-Timeout. HTTP-401 fuehrt einmalig zu einer Token-Erneuerung und
Wiederholung. Tokens werden nie als Variable oder Debugtext ausgegeben.
## Inbetriebnahme
1. Eine Instanz **Easee Gateway** erstellen.
2. Benutzername und Passwort des Easee-Kontos eintragen.
3. TLS-Pruefung aktiviert lassen.
4. Speichern und Status `102` sowie `Connected=true` abwarten.
5. Fuer jede Station eine Kindinstanz **Ladestation Gateway** anlegen.
6. Bei mehreren Konten je Konto eine eigene Gateway-Instanz verwenden.
## Tests
`composer check` prueft Syntax und Unit-Tests. Der Funktionstest laeuft mit:
```bash
tests/Symcon/bin/run-symcon-tests.sh single EaseeGateway
```
+112
View File
@@ -0,0 +1,112 @@
# Ladestation Gateway
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
Das Modul bindet genau eine Easee-Ladestation als EMS-Verbraucher an. Es ist
Kind eines **Easee Gateway** und besitzt keine Zugangsdaten.
## Varianten
| Betriebsmodus | Verhalten |
| --- | --- |
| `Easee` | Solarladen kann als Startwert konfiguriert und lokal umgeschaltet werden. |
| `Easee - Nur Solarladen` | Solarladen ist fest aktiv und kann nicht abgeschaltet werden. |
Die alte eCarUp-Zusatzabhaengigkeit wird nicht uebernommen. Konto,
Ladestatus und Steuerung laufen ausschliesslich ueber Easee.
## Ereignisbasierter Status
Das Modul pollt die Ladestation nicht. Es verarbeitet unmittelbar die vom
Gateway gelieferten Easee-Observations:
| Observation | Verwendung |
| ---: | --- |
| `47` | Maximalstrom der Ladestation. |
| `104` | Kabelstromgrenze. |
| `109` | Fahrzeugerkennung und Ladezustand. |
| `110` | Aktive Ausgangsphase: Werte 10 bis 15 ergeben 1 Phase, Wert 30 ergibt 3 Phasen. |
| `119` | Easee-Fehlercode. |
| `120` | Gemessene Gesamtleistung in kW. |
| `182..185` | Gemessene Leiterstroeme; der groesste Betrag ist der angezeigte Ladestrom. |
Damit entfallen die alte 60-Sekunden-Erkennung, die 7500-W-Schwelle und die
Voll-Erkennung aus einem unterschaetzten Strom. Fahrzeug, Ladeende und
Phasenzahl stammen direkt aus der Easee API. Zweiphasige oder noch nicht
zugewiesene Ausgangsphasen sowie unbekannte Betriebszustaende werden sicher
als ungueltig behandelt und ergeben bis zur gueltigen 1-/3-Phasenmeldung nur
`[0]`.
## Properties
| Ident | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `Betriebsmodus` | Integer | `0` | `0` Easee, `1` Easee - Nur Solarladen. |
| `Ladestationskennung` | String | leer | Easee-Seriennummer. |
| `MaximalerLadestrom` | Integer | `16 A` | Lokale Obergrenze von 6 bis 32 A. API- und Kabelgrenze wirken zusaetzlich. |
| `Ladefreigabe` | Boolean | `false` | Startwert der lokalen Ladefreigabe. |
| `Solarladen` | Boolean | `true` | Startwert im normalen Easee-Modus. |
| `PrioritaetPV` | Integer | `0` | Prioritaet im PV-Betrieb. |
| `PrioritaetPeak` | Integer | `0` | Prioritaet im Peakbetrieb. |
| `Meldeintervall` | Integer | `10 s` | Periodische Vollmeldung an den Manager. |
| `VorgabeTimeout` | Integer | `120 s` | Ablauf einer nicht erneuerten Manager-Vorgabe. |
| `EinstellungenInVisu` | Boolean | `false` | Zeigt Ladefreigabe und Solarladen. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Zeigt technische Diagnosewerte. |
| `LoggingEin` | Boolean | `false` | Aktiviert Debugmeldungen ohne Geheimnisse. |
## Variablen
Immer sichtbar sind `Aktiv`, `FahrzeugVerbunden`, `FahrzeugGeladen`,
`Fahrzeugstatus`, `Ladestrom` und `Phasenzahl`. Der normalisierte
Fahrzeugstatus ist `0` unbekannt, `1` getrennt, `2` bereit, `3` laedt,
`4` geladen oder `5` Fehler.
Optional sichtbar sind die gemeinsamen EMS-Diagnosewerte sowie
`GatewayVerbunden`, `ApiMaximalstrom`, `LetzterGeraetebefehl` und
`LeistungsangebotDiagnose`.
## Regelung
Die Leistungsstufen und das PV-/Peak-Verhalten entsprechen der
Ladestation Stand-Alone:
| Betriebsart | Solarladen | Angebot |
| --- | --- | --- |
| PV | ein | `[0, ...Ladestufen]` |
| PV | aus | nur maximale Ladestufe |
| Peak | ein | `[0]` |
| Peak | aus | `[0, ...Ladestufen]` |
Die niedrigste Grenze aus Property, Observation `47` und Observation `104`
bestimmt den angebotenen Maximalstrom. Eine neue Observation berechnet das
Angebot sofort neu und meldet die Aenderung kurz gebuendelt an den Manager.
Bei einer Gateway-Unterbrechung werden Fahrzeug- und Phasenstatus sofort
verworfen; erst ein neuer aktueller Gateway-Zustand gibt die Regelung wieder
frei. Die eigentliche Vorgabe wird als dynamischer Ladestrom mit `minutes=0`
an Easee gesendet.
## Managerkommunikation
Das Modul implementiert den
[EMS-Nachrichtenvertrag `4.0`](../../Schnittstelle.md). Es wird im Manager
wie die Stand-Alone-Ladestation unter dem Lizenzkatalog `ev_charger`
gefuehrt. Es gibt keine Manager-ID-Property; die Zuordnung erfolgt nur im
Manager.
## Inbetriebnahme
1. Ein konfiguriertes Easee Gateway mit Status `102` bereitstellen.
2. Darunter **Ladestation Gateway** erstellen.
3. Variante, Seriennummer und elektrische Maximalgrenze konfigurieren.
4. Ladefreigabe und Solarladen festlegen.
5. Die Instanz im Enelix Manager aktiv zuordnen.
6. Ohne Fahrzeug Status `1` und Angebot `[0]` pruefen.
7. Fahrzeug verbinden und die API-Werte fuer Status und Phasenzahl kontrollieren.
8. Erst danach `Aktiv` einschalten und eine kleine Vorgabe testen.
## Tests
```bash
composer check
tests/Symcon/bin/run-symcon-tests.sh single LadestationGateway
```
@@ -0,0 +1,296 @@
# Ladestation Stand-Alone
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
Das Modul bindet eine einzelne Ladestation direkt an Enelix EMS an. Es liest
den Fahrzeug- und Ladezustand ueber die Geraete-API, ermittelt die Phasenzahl
wie Enelix 1 und setzt den vom Manager gewaehlten Ladestrom. Es benoetigt keine
Gateway-Instanz und hat keine technische Beziehung zum separaten Modul
**Ladestation Gateway**.
## Funktionsumfang
- direkte Statusabfrage und Steuerung der unterstuetzten Ladestationen,
- Fahrzeug-, Lade- und Phasenerkennung nach der Enelix-1-Logik,
- Leistungsangebote fuer PV- und Peakbetrieb,
- lokale Freigabe und Umschaltung zwischen Solar- und Normalbetrieb,
- Kommunikation mit dem Enelix Manager ueber den Nachrichtenvertrag `4.0`,
- optional sichtbare Diagnosevariablen und schaltbares Debug-Logging,
- automatisierte Tests mit Fake-HTTP-Transport ohne reale Ladestation.
## Unterstuetzte Geraete
| Geraetetyp | Statusabfrage | Steuerung | Erforderliche Konfiguration |
| --- | --- | --- | --- |
| go-e Charger, alte API | `GET /mqtt?payload=` | `alw` und `amp` | IP-Adresse oder Hostname |
| go-e Charger Gemini / Gemini flex | `GET /api/status` | `frc` und `amp` | IP-Adresse oder Hostname |
| smart-me Pico | Pico-Charging-API | Load-Management-Current-API | Geraete-ID, Seriennummer, Benutzername und Passwort |
Bei go-e wird die lokale HTTP-API verwendet. Die Geraeteadresse wird ohne
`http://`, Pfad oder Parameter eingetragen. Die Pico-Anbindung verwendet HTTPS
und HTTP Basic Auth gegen `api.smart-me.com`. Zugangsdaten werden weder als
Variable noch im Debug-Protokoll oder im Diagnosefeld
`LetzterGeraetebefehl` ausgegeben.
## Fahrzeug- und Phasenerkennung
Die Auswertung folgt bewusst dem Verhalten der bisherigen Enelix-1-Ladestation:
1. Bei go-e gilt das Fahrzeug als verbunden, wenn `car != 1` ist. Bei Pico
wird entsprechend `State != 1` ausgewertet.
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.
3. Bei einer Leistung ueber 7500 W wird dreiphasiges Laden erkannt. Darunter
wird bei verbundenem Fahrzeug einphasiges Laden angenommen.
4. Der Ladestrom wird aus der Leistung mit `230 V` einphasig beziehungsweise
`1.71 * 400 V` dreiphasig berechnet.
5. War das Fahrzeug bereits im vorherigen Zyklus verbunden und liegt der
ermittelte Maximalstrom aus Ladestrom plus `2.5 A` unter `6 A`, wird
`FahrzeugGeladen=true` gesetzt.
Der normalisierte `Fahrzeugstatus` verwendet folgende Werte:
| Wert | Bedeutung |
| ---: | --- |
| `0` | unbekannt oder noch nicht ermittelt |
| `1` | kein Fahrzeug verbunden |
| `2` | Fahrzeug verbunden und bereit |
| `3` | Fahrzeug laedt |
| `4` | Fahrzeug geladen |
## Leistungsregelung
Ein Leistungsangebot groesser als `0 W` wird nur erzeugt, wenn das Modul
aktiviert, die Ladefreigabe gesetzt, ein Fahrzeug verbunden und das Fahrzeug
noch nicht als geladen erkannt ist. Andernfalls meldet das Modul `[0]` und
stoppt die Ladestation.
| Betriebsart | Solarladen | Leistungsangebot |
| --- | --- | --- |
| PV | ein | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum |
| PV | aus | ausschliesslich die maximale Ladeleistung |
| Peak | ein | ausschliesslich `[0]`; die Ladestation wird gestoppt |
| 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
Enelix 1 uebernommen. Ohne gueltige Manager-Vorgabe faehrt das Modul bei
aktivem Solarladen mit `0 A`; bei ausgeschaltetem Solarladen verwendet es die
maximale angebotene Leistung.
## Bedienung
| Ident | Wirkung |
| --- | --- |
| `Aktiv` | Gemeinsame lokale EMS-Freigabe. `false` setzt das Angebot auf `[0]` und stoppt die Ladestation. |
| `Ladefreigabe` | Lokale Freigabe fuer das Laden. Diese Variable ist nur mit `EinstellungenInVisu=true` sichtbar. |
| `Solarladen` | Schaltet das oben beschriebene PV-/Peak-Verhalten um. Diese Variable ist nur mit `EinstellungenInVisu=true` sichtbar. |
Die Properties `Ladefreigabe` und `Solarladen` definieren die Startwerte. Eine
Aenderung der jeweiligen Property wird beim Anwenden der Konfiguration in den
lokalen Zustand uebernommen. Eine Bedienung der Variablen verwirft eine noch
gueltige Manager-Vorgabe und berechnet das Leistungsangebot sofort neu.
## Konfiguration
### Gemeinsame EMS-Einstellungen
| Property | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `PrioritaetPV` | Integer | `0` | Prioritaet des Verbrauchers im PV-Betrieb. |
| `PrioritaetPeak` | Integer | `0` | Prioritaet des Verbrauchers im Peakbetrieb. |
| `Meldeintervall` | Integer | `10 s` | Intervall fuer die periodische Vollmeldung an zugeordnete Manager. |
| `VorgabeTimeout` | Integer | `120 s` | Gueltigkeitsdauer einer Manager-Vorgabe ohne Erneuerung. |
| `EinstellungenInVisu` | Boolean | `false` | Blendet `Ladefreigabe` und `Solarladen` als bedienbare Variablen ein. |
| `LoggingEin` | Boolean | `false` | Aktiviert das laufende Debug-Protokoll des Moduls. |
### Ladestation und Diagnose
| Property | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `Geraetetyp` | Integer | `0` | `1` go-e alt, `2` go-e Gemini, `3` smart-me Pico; `0` ist nicht konfiguriert. |
| `Geraeteadresse` | String | leer | IP-Adresse oder Hostname fuer beide go-e-Varianten. |
| `GeraeteID` | String | leer | Geraete-ID fuer die Pico-Statusabfrage. |
| `Seriennummer` | String | leer | Seriennummer fuer die Pico-Stromvorgabe. |
| `Benutzername` | String | leer | Benutzername fuer die Pico-API. |
| `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. |
| `Abfrageintervall` | Integer | `5 s` | Intervall der Geraetestatusabfrage. |
| `Ladefreigabe` | Boolean | `false` | Startwert der lokalen Ladefreigabe. |
| `Solarladen` | Boolean | `true` | Startwert der lokalen Solarlogik. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Blendet gemeinsame und ladestationsspezifische Diagnosevariablen ein. |
Die internen Properties `Testmodus` und `Testantwort` gehoeren ausschliesslich
zum automatisierten Symcon-Funktionstest und erscheinen nicht im
Konfigurationsformular.
## Variablen
Immer sichtbar:
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; der Initialwert ist `false`. |
| `FahrzeugVerbunden` | Boolean / Anzeige | Zeigt, ob die API ein verbundenes Fahrzeug meldet. |
| `FahrzeugGeladen` | Boolean / Anzeige | Ergebnis der kompatiblen Enelix-1-Voll-Erkennung. |
| `Fahrzeugstatus` | Integer / Anzeige | Normalisierter Status von `0` bis `4`. |
| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Ladestrom in A. |
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig oder `3` dreiphasig. |
Nur mit `EinstellungenInVisu=true` sichtbar und bedienbar:
- `Ladefreigabe`
- `Solarladen`
Nur mit `DiagnosevariablenAnzeigen=true` sichtbar:
| Ident | Beschreibung |
| --- | --- |
| `Istleistung` | Von der Ladestation gemessene Leistung in W. |
| `Leistungsquelle` | Immer `2` fuer eine gemessene Leistung. |
| `Sollleistung` | Letzte angenommene Manager-Vorgabe in W. |
| `SollwertGueltig` | Zeigt, ob die Vorgabe noch gueltig und im aktuellen Angebot enthalten ist. |
| `Verfuegbar` | Zeigt, ob die Ladestation grundsaetzlich Leistung aufnehmen kann. |
| `AenderungMoeglich` | Zeigt, ob das aktuelle Angebot mehr als eine Leistungsstufe enthaelt. |
| `Stoerung` | Sammelstatus fuer Konfigurations- und Kommunikationsfehler. |
| `Stoertext` | Letzte verstaendliche Fehlerbeschreibung. |
| `LetzterGeraetebefehl` | Letzte HTTP-Methode und URL ohne Zugangsdaten. |
| `LeistungsangebotDiagnose` | Aktuelles Leistungsangebot als JSON-Liste in W. |
Die Regellogik speichert ihren Zustand unabhaengig von der Sichtbarkeit der
Diagnosevariablen. Das Ein- oder Ausblenden veraendert daher nicht das
Regelverhalten.
## Managerkommunikation
Das Modul implementiert
`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet
[ausschliesslich den Nachrichtenvertrag `4.0`](../../Schnittstelle.md). Eine
separate Manager-ID wird nicht konfiguriert. Das Modul akzeptiert Vorgaben nur
von einem Manager, in dessen manueller oder automatischer
Verbraucherzuordnung die Ladestation aktiv eingetragen ist.
An den Manager werden neben den gemeinsamen Feldern diese Zustaende gemeldet:
- `Sollleistung_W`
- `FahrzeugVerbunden`
- `FahrzeugGeladen`
- `Fahrzeugstatus`
- `Phasenzahl`
- `Ladestrom_A`
- `Ladefreigabe`
- `Solarladen`
- `Ladefehler` mit Stoertext
Eine Sollleistung wird nur angenommen, wenn sie im zuletzt berechneten
Leistungsangebot enthalten ist. Nach `VorgabeTimeout` ohne Erneuerung wird sie
ungueltig. Statusaenderungen werden kurz verzoegert und zusaetzlich alle
`Meldeintervall` Sekunden an alle zugeordneten Manager gemeldet.
## Instanzstatus und Fehlerbehandlung
| Status | Bedeutung |
| ---: | --- |
| `102` | Konfiguration und letzte Geraetekommunikation sind gueltig. |
| `201` | Konfiguration ungueltig, beispielsweise fehlende Adresse oder Pico-Zugangsdaten. |
| `202` | Geraetekommunikation oder Antwortauswertung fehlgeschlagen. |
Bei einem Fehler meldet das Modul `Verfuegbar=false`,
`AenderungMoeglich=false` und setzt `Stoerung` sowie `Stoertext`. HTTP-Anfragen
verwenden 5 Sekunden Verbindungs- und 10 Sekunden Gesamt-Timeout. Antworten ab
HTTP-Status 400 sowie unvollstaendige oder ungueltige JSON-Antworten gelten als
Kommunikationsfehler.
## Installation und Inbetriebnahme
1. Im IP-Symcon Module Control den Testing-Branch `develop` der Bibliothek
`https://git.belevo.ch/ENELIX/Enelix-EMS.git` installieren oder
aktualisieren.
2. Unter **Instanz hinzufuegen** nach **Ladestation Stand-Alone** suchen und
eine Instanz anlegen.
3. Den Geraetetyp auswaehlen und die dazugehoerigen Verbindungsdaten eintragen:
bei go-e nur IP-Adresse oder Hostname, bei Pico Geraete-ID, Seriennummer,
Benutzername und Passwort.
4. Den maximal zulaessigen Ladestrom der Installation zwischen 6 und 32 A
einstellen. Diese Grenze ersetzt keine elektrische Absicherung.
5. `Abfrageintervall`, `Meldeintervall`, `VorgabeTimeout` sowie die beiden
Prioritaeten festlegen.
6. Die gewuenschten Startwerte fuer `Ladefreigabe` und `Solarladen` setzen.
7. Fuer die Erstinbetriebnahme `EinstellungenInVisu`,
`DiagnosevariablenAnzeigen` und bei Bedarf `LoggingEin` aktivieren.
8. Die Instanz im Manager manuell aktiv zuordnen oder bei automatischer Suche
in der gefundenen Liste aktivieren.
9. Zuerst ohne Fahrzeug kontrollieren, ob die Statusabfrage fehlerfrei ist.
Danach unter Aufsicht ein Fahrzeug verbinden und `Aktiv` einschalten.
Fuer go-e muss IP-Symcon das Geraet im lokalen Netz per HTTP erreichen koennen.
Fuer Pico ist ausgehender HTTPS-Zugriff auf `api.smart-me.com` erforderlich.
Passwoerter gehoeren ausschliesslich in das dafuer vorgesehene Passwortfeld und
niemals in Repository-Dateien, Skripte oder Screenshots.
## Abnahmecheckliste
- Die Instanz erreicht Status `102`, und `Stoerung` bleibt `false`.
- Ohne Fahrzeug sind `FahrzeugVerbunden=false`, `Phasenzahl=0` und das
Leistungsangebot `[0]`.
- Nach dem Anstecken wird das Fahrzeug erkannt und die gemessene Leistung
plausibel in `Istleistung` und `Ladestrom` abgebildet.
- Bei einer Ladeleistung bis 7500 W wird eine Phase, darueber werden drei
Phasen angezeigt.
- `Aktiv=false` oder `Ladefreigabe=false` stoppt die Ladestation.
- PV mit `Solarladen=true` bietet `0 W` und die Ladestufen 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=false` bietet `0 W` und die Ladestufen an.
- Eine Manager-Vorgabe ausserhalb des gemeldeten Angebots wird abgewiesen.
- Nach Ablauf des Vorgabe-Timeouts gilt wieder das lokale Ersatzverhalten.
- Im Debug-Protokoll und in `LetzterGeraetebefehl` erscheinen keine
Zugangsdaten.
## Fehlersuche
- Status `201`: Geraetetyp und Pflichtfelder kontrollieren. Bei go-e darf die
Adresse kein Protokoll, keinen Pfad und keine Parameter enthalten. Bei Pico
muessen alle vier Zugangsfelder befuellt sein.
- Status `202`: Erreichbarkeit, DNS, lokale Firewall und API-Antwort pruefen.
`Stoertext` enthaelt den konkreten Kommunikations- oder JSON-Fehler.
- Fahrzeug wird nicht erkannt: Rohstatus der Geraete-API kontrollieren. Der
Adapter erwartet bei go-e `car` und `nrg[11]`, bei Pico `State` und
`ActiveChargingPower`.
- Falsche Phasenzahl bei sehr kleiner oder stehender Ladung: Die kompatible
Enelix-1-Erkennung basiert auf der momentanen Leistung. Fuer eine sichere
Dreiphasenerkennung muss waehrend der Erkennung mehr als 7500 W anliegen.
- Manager-Vorgabe wird abgewiesen: aktive Zuordnung im Manager sowie
`LeistungsangebotDiagnose` und die Betriebsart kontrollieren.
## Tests
Die Unit- und Adaptertests verwenden einen injizierten Fake-HTTP-Transport.
Damit werden fuer go-e alt, go-e Gemini und smart-me Pico Statusantworten,
URL, HTTP-Methode, Authentisierung und Steueraufrufe ohne reale Hardware
geprueft.
Der Symcon-Funktionstest verwendet den internen `Testmodus` mit simulierten
API-Antworten. Er prueft alle drei Geraetevarianten, Fahrzeug- und
Phasenerkennung, die Leistungsangebote in PV und Peak sowie die Umschaltung
ueber den `Solarladen`-Button.
PHP-Syntax, Struktur- und Unit-Tests des gesamten Repositorys:
```bash
composer check
```
Funktionstest nur fuer die Ladestation gegen IP-Symcon 8:
```bash
tests/Symcon/bin/run-symcon-tests.sh single LadestationStandAlone
```
Vollstaendiger Symcon-Modultestlauf:
```bash
tests/Symcon/bin/run-symcon-tests.sh all
```
Aufbau, Testvertrag und Ergebnisdateien sind unter
[`docs/testing/README.md`](../../testing/README.md) beschrieben.
+353
View File
@@ -0,0 +1,353 @@
# Manager
> Status: Implementiert. Führt Hauptmanager und Peakshaving zusammen.
Der Manager liest die Netzleistung, verwaltet ausschliesslich die ausgewählten
Verbraucher und verteilt Leistung im PV- oder Peak-Betrieb. Die Prioritäten
kommen aus den Verbrauchermeldungen.
## Variablen
Die drei Regelungsvariablen `Aktiv`, `Betriebsart` und `Netzleistung` sind immer sichtbar. Die vom Manager gefuehrten Mess- und Energievariablen werden immer angelegt und archiviert, koennen aber gemeinsam ausgeblendet werden.
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Regelung ein/aus; Start `false`. |
| `Betriebsart` | String / Anzeige | `Inaktiv`, `PV` oder `Peak`. |
| `Netzleistung` | Float / Anzeige | Aktuelle Netzleistung in W; positiv Bezug, negativ Einspeisung. |
| `PVLeistungArchiv` | Float / Logging | Normalisierte PV-Leistung in W. |
| `HausverbrauchLeistungArchiv` | Float / Logging | Normalisierter Hausverbrauch in W. |
| `NetzleistungArchiv` | Float / Logging | Normalisierte Netzleistung in W; positiv Bezug, negativ Einspeisung. |
| `BatterieleistungArchiv` | Float / Logging | Batterieleistung in W; positiv Laden, negativ Entladen. |
| `PVEnergie` | Float / Zaehler | Integrierte PV-Erzeugung in kWh. |
| `Hausenergie` | Float / Zaehler | Integrierter Hausverbrauch in kWh. |
| `NetzbezugEnergie` | Float / Zaehler | Integrierter Netzbezug in kWh. |
| `EinspeisungEnergie` | Float / Zaehler | Integrierte Einspeisung in kWh. |
| `BatterieLadenEnergie` | Float / Zaehler | Integrierte Batterieladung in kWh. |
| `BatterieEntladenEnergie` | Float / Zaehler | Integrierte Batterieentladung in kWh. |
| `NetzleistungGueltig` | Boolean / Logging | Messquelle vorhanden und aktuell. |
| `WirksameLastspitzengrenze` | Float / Logging | Aktuelle feste oder monatliche Bezugsgrenze in W. |
| `WirksameEinspeisegrenze` | Float / Logging | Anlagenweite Einspeisegrenze in W. |
| `Abregelbedarf` | Float / Logging | Aktuell erforderliche PV-Leistungsreduktion in W. |
| `Wechselrichterstatus` | String/JSON / Logging | Verteilte Grenzen und Zustand der PV-Regelung. |
| `Verteilbudget` | Float / Logging | Aktuell verfügbares Budget in W. |
| `VerbraucherAnzahl` | Integer / Logging | Anzahl zugeordneter Verbraucher. |
| `VerbraucherVerfuegbar` | Integer / Logging | Anzahl aktuell verfügbarer Verbraucher. |
| `Verbraucherstatus` | String/JSON / Logging | Letzte vollständige Meldungen. |
| `Prognosestatus` | String / Logging | `NichtVerwendet`, `Verbunden` oder `Fehler`. |
| `SDLStatus` | String / Logging | Status des SDL/VGT-Anschlusses. |
| `Lizenzstatus` | String / Logging | Status der Lizenzprüfung. |
| `Stoerueberwachungsstatus` | String / Logging | Status der externen Störüberwachung. |
| `Sammelstoerung` | Boolean / Logging | Eigene und weitergeleitete Störungen. |
| `Stoertext` | String / Logging | Lesbare Sammelmeldung. |
## Properties
| Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `Rolle` | Auswahl | `Alleine`; alternativ `Hauptmanager` oder `Untermanager`. |
| `NetzleistungVariableID` | Integer | `0*`; gültige Messquelle vor Regelstart. |
| `Netzleistungsfaktor` | Float | `1`; auf W und die vereinbarte Vorzeichenrichtung normieren. |
| `MesswertMaxAlter` | Integer | `60` s; auf echte Messaktualisierung bezogen. |
| `VerbraucherZuordnung` | String/JSON | `[]`; manuelle Auswahl aus Instanz-ID und Aktiv-Status. |
| `AutomatischeVerbraucherZuordnung` | String/JSON | `[]`; Auswahl aus automatisch gefundenen Verbrauchern. |
| `AutomatischeSuche` | Boolean | `true`; zeigt gefundene Verbraucher zur gezielten Auswahl. |
| `SuchbereichID` | Integer | `0`; optionaler Suchbereich für die automatische Suche. |
| `KeepAlive` | Integer | `60` s; erneuert laufende Vorgaben zyklisch. |
| `VerbraucherTimeout` | Integer | `60` s; erkennt ausgebliebene Verbrauchermeldungen. |
| `Lastspitzenmodus` | Auswahl | `Aus`; alternativ `Konstant` oder `Monatlich`. |
| `Lastspitzengrenze` | Float | Feste Grenze in W für Modus `Konstant`. |
| `Monatsgrenzen` | String/JSON | Editierbare Liste mit zwölf Monatswerten in W. |
| `SollwertSolarladen` | Float | `0` W; gewünschte Netzleistung im Solarladebetrieb. |
| `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. |
| `EinspeisebegrenzungAktiv` | Boolean | `false`; gemeinsame Exportgrenze am Netzanschlusspunkt aktivieren. |
| `Einspeisegrenze` | Float | Maximale Einspeisung der Gesamtanlage in W; `0` bedeutet Nulleinspeisung. |
| `PrognoseAktiv` | Boolean | `false`; Prognosetelemetrie und Topologiesynchronisation mit `forecast_pv` plus `forecast_load` oder `grid_schedule` aktivieren. |
| `NetzfahrplanAktiv` | Boolean | `false`; lizenzierten, tarif- und prognosebasierten Netzzielwert verwenden. |
| `NetzbezugEnergieVariableID` | Integer | Optionaler kumulativer Netzbezugszaehler. |
| `NetzeinspeisungEnergieVariableID` | Integer | Optionaler kumulativer Einspeisezaehler. |
| `NetzbezugEnergiefaktor`, `NetzeinspeisungEnergiefaktor` | Float | Umrechnung der Netzenergiezaehler nach kWh. |
| `AnlagenWechselrichter` | String/JSON | `[]`; Wechselrichter mit Typ, AC-Nennleistung, Leistungs- und Erzeugungsenergiemessung sowie optionalem Begrenzungsregister. |
| `AnlagenPVFlaechen` | String/JSON | `[]`; PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichter-ID. |
| `AnlagenBatterien` | String/JSON | `[]`; Batteriespeicher mit Kapazitaeten, Kopplung, Leistung, SOC sowie Lade- und Entladeenergiezaehlern. |
| `PrognoseSendeintervall` | Integer | `60` s; Intervall fuer den Upload aktueller Messwerte, zulaessig sind 60 bis 3600 Sekunden. |
| `PrognoseAnschluss` | String/JSON | Verdeckte Altproperty fuer bestehende Konfigurationen. |
| `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. |
| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. |
| `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. |
| `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. |
| `LoggingEin` | Boolean | `false`; laufende Meldungen im Debug-Fenster ausgeben. |
| `EnergieaufzeichnungAktiv` | Boolean | `true`; managergefuehrte Leistungs- und Energieaufzeichnung aktivieren. |
| `MesswerteAnzeigen` | Boolean | `false`; einzelne Manager-Messwerte in der Objektstruktur anzeigen. |
| `EnergyPieAnzeigen` | Boolean | `false`; Energy Pie unter dem Manager anlegen oder entfernen. |
| `EnergiediagrammeAnzeigen` | Boolean | `false`; native Leistungs- und Energiediagramme anlegen oder entfernen. |
| `FunFactsAnzeigen` | Boolean | `false`; responsive Energy Facts anlegen oder entfernen. |
| `EnergieflussAnzeigen` | Boolean | `false`; native Energy Distribution anlegen oder entfernen. |
| `LeistungsaufzeichnungMinuten` | Auswahl | `1`; Verdichtung auf 1 Minute, 5 Minuten oder 1 Stunde. |
| `LeistungLoeschenMonate` | Integer | `12`; Leistungswerte nach zwoelf Monaten loeschen, `0` deaktiviert die Loeschung. |
| `EnergieaufzeichnungMinuten` | Auswahl | `5`; Verdichtung auf 1 Minute, 5 Minuten oder 1 Stunde. |
| `EnergieVerdichtenMonate` | Integer | `12`; Energiezaehler nach zwoelf Monaten auf Tageswerte verdichten. |
| `EnergieLoeschenMonate` | Integer | `0`; Energiezaehler nicht loeschen. |
Es gibt keine Sollwertquellenauswahl und keine zweite Prioritätseinstellung im
Manager. Im Modus `Aus` bleibt Solarladen aktiv, nur die Peak-Begrenzung entfällt.
Die Monatsgrenzen werden in den Grundeinstellungen über eine Schaltfläche
ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut
ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren.
## Energieaufzeichnung und Visualisierung
Sobald Netzleistung und mindestens eine PV-Istleistung in der Anlagentopologie
konfiguriert sind, tastet der Manager die Quellen minuetlich ab. PV-Leistung ist
die Summe der den PV-Flaechen zugeordneten Wechselrichter. Batterieleistung und
der kapazitaetsgewichtete SOC stammen aus den Batteriespeichern. Positive
Batterieleistung bedeutet Laden, negative Entladen. Der Hausverbrauch wird als
`PV + Netz - Batterie` bilanziert. Sind Netzbezugs-, Einspeise-, PV-Erzeugungs-
sowie gegebenenfalls Batterie-Lade- und Entladezaehler vollstaendig hinterlegt,
verwendet der Manager deren Deltas fuer die Energieaufzeichnung. Die Hausenergie
wird dann als `PV + Netzbezug + Batterieentladung - Einspeisung - Batterieladung`
berechnet. Ohne vollstaendigen Zaehlerdatensatz integriert der Manager weiterhin
die Leistungswerte. Dieselben Quellen versorgen ohne zweite Eingabe auch die
Prognosetelemetrie. Ausfallluecken ueber fuenf Minuten werden bei der
Leistungsintegration nicht nachberechnet.
Batteriespeicher bleiben als physische Komponenten samt Messquellen in der
Anlagentopologie. Ein zusaetzliches Batterieverbrauchermodul beschreibt dagegen
Regelung, Leistungsangebot und Betriebszustand; beide Rollen sind bewusst
getrennt.
Die eigenen Variablen werden automatisch im ersten Archive Control aktiviert.
Leistungen verwenden die Standardaggregation, Energievariablen den Zaehlermodus.
Standardmaessig werden Leistungen auf eine Minute verdichtet und nach zwoelf
Monaten geloescht. Energie wird auf fuenf Minuten verdichtet, nach zwoelf
Monaten auf Tageswerte reduziert und nie geloescht. Aenderungen an diesen
Regeln werden idempotent ueber die Archiv-API gesetzt und reaggregiert.
Vier Schalter im Bereich `Energieaufzeichnung` verwalten die
Visualisierungen idempotent. Beim Einschalten werden die Objekte direkt unter
dem Manager angelegt oder aktualisiert. Beim Ausschalten werden ausschliesslich
die vom Manager anhand ihrer festen Kennung und ihres Typs erkannten Objekte
entfernt beziehungsweise die native Energy Distribution ausgeblendet:
- Energy Pie aus Enelix Utils mit den vier relevanten Energiezaehlern,
- zwei native IP-Symcon-Diagrammmedien fuer Leistungen und Energien,
- responsive Energy Facts mit Solar-, Netz-, Haus- und Vergleichswerten,
- native Energy Distribution fuer PV, Netz, Haus und Batterie.
## Anlagenweite Einspeisebegrenzung
Die Einspeisebegrenzung ist Bestandteil von Peak Shaving und arbeitet auf der
Messung am Netzanschlusspunkt. Fuer jeden regelbaren PV- oder Hybridwechselrichter
werden die Istleistungsvariable und eine bedienbare Begrenzungsvariable
konfiguriert. Der Stellwert kann als Prozent der AC-Nennleistung oder als
absolute Leistung in Watt ausgegeben werden.
Der Manager berechnet eine einzige Grenze fuer die Gesamtanlage. Beim Abregeln
verteilt er sie nach der tatsaechlichen Erzeugung, beim Freigeben nach der
AC-Nennleistung auf alle angebotenen Wechselrichter. Jede gemessene Abweichung
wird stufenlos nachgefuehrt; ein zusaetzliches Toleranzfenster wird nicht
verwendet. Beim Abschalten des Managers oder der Funktion werden zuvor gesetzte
Grenzen kontrolliert auf die jeweilige
Nennleistung zurueckgesetzt. Wechselrichter ohne beide Register werden nicht
geregelt; sobald eines der beiden Register gesetzt ist, muessen beide gueltig
sein und das Begrenzungsregister eine IP-Symcon-Aktion besitzen.
Der intelligente Netzfahrplan verwendet die PV-, Verbrauchs- und
Netzleistungsprognose sowie Tarife und Batteriespeicher. Liegt im
Prognosezeitraum mehr PV-Ertrag als zulaessige Einspeisung vor, wird dieses
Potenzial als flexibler Verbrauch beziehungsweise Speicherladung eingeplant.
Damit werden Verbraucher in ertragreiche Zeitfenster verschoben, bevor der
harte Anlagenregler die Wechselrichter reduziert. Ein fehlender oder
abgelaufener Fahrplan fuehrt automatisch zum konfigurierten
`SollwertSolarladen` zurueck; die harte Einspeisebegrenzung bleibt unabhaengig
davon aktiv.
## Anlagentopologie und Prognoseexport
Der Manager ist die fuehrende Quelle fuer die technischen Stammdaten. Die
Konfiguration trennt Wechselrichter, PV-Flaechen und Batteriespeicher. AC-Leistung
wird am Wechselrichter, DC-Leistung an der PV-Flaeche gepflegt. Individuelle
AC-Einspeise- und Bezugsgrenzen werden nicht erfasst, weil die Begrenzung
anlagenweit am Netzanschlusspunkt erfolgt. Modulanzahl und Modulleistung sind
optional und muessen gemeinsam gesetzt werden.
PV-Flaechen und Batterien referenzieren ihren Wechselrichter ueber dessen stabile
ID. Bei `hybrid` koennen beide denselben Wechselrichter verwenden. Der Export
`ENELIX_AnlagentopologieExportieren($InstanzID)` weist dann eine gemeinsame
AC-Grenze aus, damit PV und Batterie die Nennleistung nicht unabhaengig doppelt
beanspruchen.
Der JSON-Export enthaelt die Vertragsversion `1.0`, die Lizenz-Installations-ID,
die drei Komponentenlisten, gemeinsame AC-Nennleistungen und aggregierte Summen
fuer die schrittweise Prognoseanbindung. Interne Symcon-Variablen-IDs werden
nicht exportiert. Tarif- und Variantenparameter bleiben im Prognoseportal. Bei
aktivierter Prognose wird eine nichtleere Topologie beim Speichern automatisch ueber
einen separaten, widerrufbaren Installationszugang synchronisiert. Der
Lizenzserver liefert diesen Zugang nur auf ausdrueckliche Geraeteanforderung;
der Manager entfernt ihn vor dem Speichern aus der Lease und haelt ihn in einem
internen Attribut. Der Lizenzcode wird nicht als API-Token verwendet. Ein
Synchronisationsfehler erscheint im `Prognosestatus`, blockiert die lokale
EMS-Regelung aber nicht.
Der Forecast-Schalter zeigt direkt an, ob fuer die Manager-ID eine passende
Kombination `forecast_pv` plus `forecast_load` oder `grid_schedule` vorhanden ist. Nach der Aktivierung sendet der
Manager PV-Leistung, berechneten Hausverbrauch, Netzleistung und Batterie-SOC
mit UTC-Zeitstempel im eingestellten Sendeintervall. Dieses Intervall bestimmt
also, wie oft aktuelle Messwerte zum Prognosedienst hochgeladen werden; es ist
kein Regelintervall. Das Minimum von 60 Sekunden
passt zum Rate-Limit des Lizenzportals. Die Uebertragung nutzt denselben
widerrufbaren Installationszugang wie die Topologie; Lizenzcode und Geraet Token
werden weder als Telemetriefelder noch im Debug-Log ausgegeben. Bei HTTP 401
oder 403 verwirft der Manager den Geraetezugang und fordert ihn bei der naechsten
Lizenzpruefung neu an.
Die Entscheidung und ihre Alternativen sind in
[ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert.
## Lizenzierung
Der Manager arbeitet nur mit einer gueltigen Manager-Lizenz. Das Lizenzfeld steht
zuoberst im Konfigurationsformular. Ohne Freigabe bleibt die Instanz mit Status
`203` inaktiv und sendet keine Leistungsvorgaben.
### Voraussetzungen
- Der Auftrag im Enelix-Lizenzportal ist bezahlt und enthaelt eine aktive
Manager-Berechtigung.
- IP-Symcon erreicht `https://license.enelix.ch` ueber HTTPS (Port 443).
- Der Lizenzcode liegt im Format `ENX-XXXX-XXXX-XXXX-XXXX` vor.
### Lizenz aktivieren
1. Manager-Konfiguration in IP-Symcon oeffnen.
2. Lizenzcode im Bereich `Lizenzierung` eintragen.
3. `Lizenz pruefen und binden` ausloesen.
4. Die erfolgreiche Freigabe am angezeigten Lizenzstatus kontrollieren.
5. Die Manager-Konfiguration mit `Uebernehmen` beziehungsweise `OK` speichern,
damit der eingegebene Lizenzcode als Property erhalten bleibt.
Beim ersten Anlegen erzeugt die Manager-Instanz eine UUIDv4 als stabile
Installations-ID. Die Aktivierung sendet ausschliesslich `code` und
`installationId` per
`POST https://license.enelix.ch/api/v1/licenses/activate`. Sie benoetigt weder
Portal-Cookies noch einen CSRF-Token. Derselbe Code kann von derselben
Installation erneut abgerufen werden; die Bindung an eine andere Installation
wird vom Lizenzserver abgelehnt.
### Berechtigungen
| Berechtigung | Freigegebene Funktion |
| --- | --- |
| `manager_standard` | Manager-Grundregelung ohne Peak Shaving. |
| `manager_peak` | Bezugs- und Einspeisebegrenzung am Netzanschlusspunkt. |
| `forecast_pv` | PV-Ertragsprognose; interne Modellvarianten bleiben verborgen. |
| `forecast_load` | Verbrauchsprognose; interne Modellvarianten bleiben verborgen. |
| `grid_schedule` | Intelligenter Netzfahrplan inklusive PV- und Verbrauchsprognose sowie Vermeidung von Abregelung. |
Wird mit `manager_standard` ein Lastspitzenmodus aktiviert, bleibt der Manager
mit dem Hinweis `Peak Shaving ist nicht lizenziert` gesperrt. `manager_peak`
gilt zugleich als Berechtigung fuer die Grundregelung.
### Erneuerung und Offline-Betrieb
Die erfolgreiche Serverantwort wird als Lease in der Manager-Instanz gespeichert.
Der Manager erneuert sie ab `refreshAfter` automatisch ueber denselben
Aktivierungsendpunkt. Schlaegt eine Erneuerung fehl, wird fruehestens nach einer
Stunde erneut angefragt. Eine bereits bestaetigte Entwicklungsfreigabe bleibt
bis `offlineUntil` verwendbar. Der aktuelle Entwicklungsvertrag setzt diesen
Zeitpunkt ungefaehr 14 Tage nach Ausstellung. Danach sperrt der Manager die
Regelung, bis der Lizenzserver wieder eine gueltige Antwort liefert.
Die Installations-ID und die Lease liegen in internen Instanzattributen. Die
ID wird erst nach dem Laden bestehender Attribute initialisiert und bleibt bei
Modulupdates, Modul-Neuladen und einem normalen Neustart unveraendert. Bei einer
Migration muss trotzdem die vollstaendige Manager-Instanz mitsamt ihren
Attributen uebernommen werden. Eine neu erzeugte Instanz erhaelt eine andere
Installations-ID und kann einen bereits gebundenen Code nicht selbststaendig
uebertragen. Wurde die ID mit einer aelteren Manager-Version bereits ungewollt
geaendert, muss im Lizenzportal einmalig ein Ersatzcode erzeugt und an die nun
stabile ID gebunden werden.
### Status und Fehlerbehebung
| Anzeige / Serverstatus | Bedeutung und Massnahme |
| --- | --- |
| `Lizenzcode fehlt.` | Code eintragen, pruefen und die Konfiguration speichern. |
| `Lizenzcode ist ungueltig.` | Format und Zeichen des Codes kontrollieren. |
| HTTP `404` | Code unbekannt oder zugehoeriger Auftrag noch nicht bezahlt. |
| HTTP `409` | Code ist bereits an eine andere Installation gebunden. |
| 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`. |
| `Offline-Freigabe ist abgelaufen.` | Verbindung zum Lizenzserver wiederherstellen und Lizenz erneut pruefen. |
Fuer eine genauere Diagnose koennen die Diagnosevariablen eingeblendet werden.
`Lizenzstatus` zeigt dann den aktuellen Zustand. Mit aktiviertem Debug-Logging
werden Fehlermeldungen der Lizenzpruefung ausgegeben, niemals jedoch der
Lizenzcode selbst.
### Datenschutz und Entwicklungsstand
Der Lizenzcode wird als Manager-Property in der IP-Symcon-Konfiguration
gespeichert. Fuer den internen Abgleich mit der Lease verwendet der Manager
zusaetzlich nur einen SHA-256-Hash und schreibt den Code nicht ins Debug-Log.
Die aktuelle Serverantwort ist ein Entwicklungsvertrag mit
`development: true` und noch nicht kryptografisch signiert. Ein optionaler
Geraete-Public-Key sowie Challenge-, Heartbeat- oder separate
Entitlement-Endpunkte werden vom Manager derzeit bewusst nicht verwendet.
## Verteilalgorithmus
Der Manager bildet das Verteilbudget aus der aktuellen Netzleistung, dem Ziel
der aktiven Betriebsart und der aktuellen Leistung aller frisch gemeldeten
Verbraucher. Eine gültige Istleistung wird bevorzugt; fehlt sie, bleibt der im
Zustand bestätigte Sollwert konservativ reserviert.
Nicht verfügbare, nicht änderbare oder aktuell angebotlose Verbraucher behalten
ihre Leistung und erhalten keine neue Vorgabe. Die übrigen Verbraucher werden
nach der gemeldeten PV- beziehungsweise Peak-Priorität und danach stabil nach
Instanz-ID sortiert. Von ihrem jeweils kleinsten erlaubten Leistungswert aus
wird das Budget in dieser Reihenfolge 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
der wirksamen Lastspitzengrenze zu `Peak`. Zwischen den beiden Zielwerten
bleibt sie erhalten. `Umschaltdifferenz` verhindert zusätzlich einen Wechsel,
wenn sich die beiden berechneten Korrekturen nicht ausreichend unterscheiden.
## Laufzeit und Fehlerverhalten
- Netzleistungsänderungen und Verbrauchermeldungen lösen die Berechnung aus.
- Identische Sollwerte werden nur beim Keep-alive erneut gesendet.
- Diagnosevariablen und laufendes Debug-Logging werden getrennt aktiviert.
- Verbraucherpakete werden zentral geprüft und nur von aktiv zugeordneten
Absendern angenommen.
- Veraltete oder noch fehlende Verbrauchermeldungen werden nicht verteilt und
als Sammelstörung ausgewiesen.
- Bei fehlender oder veralteter Netzleistung bleibt die Regelung `Inaktiv` und
sendet keine neuen Vorgaben.
- Ohne gueltige Manager-Berechtigung bleibt der Manager mit Status `203` gesperrt.
- Die Serverantwort wird lokal gespeichert und ab `refreshAfter` erneuert. Bei
einem Verbindungsfehler gilt eine zuvor bestaetigte Entwicklungsfreigabe bis
`offlineUntil`; danach wird die Regelung wieder gesperrt.
- `manager_standard` erlaubt die Grundregelung. Ein aktiver Lastspitzenmodus
benoetigt `manager_peak`.
- Der Lizenzcode wird nie geloggt. Lokal wird fuer den Lease-Abgleich nur sein
SHA-256-Wert gespeichert.
- Die oberen Anschlüsse bleiben optional. Solange kein konkreter Adapter
implementiert ist, meldet ein aktivierter Anschluss den Status `Fehler`, ohne
die lokale EMS-Regelung zu blockieren.
## Schnittstellen
- Empfängt genau `VerbraucherdatenEmpfangen(array $daten)`.
- Sendet an jeden Verbraucher nur Kopf und `Sollleistung_W`.
- Obere Anschlüsse: siehe [Obere Anschlüsse](../../Obere-Anschluesse.md).
## Offene Punkte
- Monatliche Batteriereserve aus dem alten Peakshaving-Modul übernehmen?
- Anbieterformate für Prognose und Störüberwachung festlegen.
- Produktive signierte Lizenz-Leases nach Abschluss des Entwicklungsvertrags integrieren.
- Verhalten und Messabgrenzung bei Untermanagern im Anlagentest bestätigen.
+144
View File
@@ -0,0 +1,144 @@
# Verbraucher Pufferspeicher
> Status: implementiert fuer IP-Symcon 8.0+ und den Enelix-2-Vertrag `4.0`.
Das Modul bindet elektrische Heizstufen eines Pufferspeichers an den
Enelix-Manager an. Die Freigabe wird aus Puffer- und Aussentemperatur,
Heizkurve, Hysterese, lokaler Aktivierung und aktueller Betriebsart berechnet.
Es ist eine gezielte Adaption des Enelix-1-Moduls `Puffer_Speicher`.
Leistungsstufen, Temperaturquellen, optionale PT1-Glaettung und Heizkurve
wurden uebernommen und ueberarbeitet. Zyklische Altlogik, alte
Kommunikationsvariablen, `Idle` und `PowerSteps` als Symcon-Variable wurden
verworfen.
| Technisches Merkmal | Wert |
| --- | --- |
| Modulname | `VerbraucherPufferspeicher` |
| Alias | `Pufferspeicher` |
| Modul-ID | `{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}` |
| Enelix-Vertrag | `4.0` |
## Regelverhalten
Die Solltemperatur wird berechnet als:
`FusspunktVorlauftemperatur + HeizkurvenSteigung * (20 - Aussentemperatur)`
Anschliessend wird sie auf `HeizkurveMinimaltemperatur` und
`HeizkurveMaximaltemperatur` begrenzt.
Die Einschaltschwelle wird je nach `MindesttemperaturModus` bestimmt:
- `Aus`: Solltemperatur minus Hysterese,
- `Statisch`: konfigurierte absolute Mindesttemperatur,
- `Differenz`: Solltemperatur minus konfigurierte Differenz.
Es gilt:
- im PV-Betrieb unter der Einschaltschwelle: `[0, ...Leistungsstufen]`,
- im PV-Betrieb an oder oberhalb der Einschaltschwelle: `[0]`,
- im Peakbetrieb unabhaengig von der Temperatur: `[0]`,
- bei `Aktiv=false`: immer `[0]`,
- bei ungueltigen Temperaturen: `[0]` und nicht verfuegbar.
Der Pufferspeicher ist im Peakbetrieb damit pauschal gesperrt. Beim Wechsel in
Peak wird eine aktive Stufe sicher ausgeschaltet.
## Properties
Die Properties erscheinen im Formular in dieser Reihenfolge.
### Manager und Zeitverhalten
| Ident | Standard | Beschreibung |
| --- | ---: | --- |
| `PrioritaetPV` | `0` | Prioritaet im PV-Betrieb |
| `PrioritaetPeak` | `0` | Prioritaet im Peakbetrieb |
| `Meldeintervall` | `10 s` | Vollstaendige Rueckmeldung |
| `VorgabeTimeout` | `120 s` | Gueltigkeit einer Vorgabe |
| `LastwechselSperrzeit` | `5 s` | Mindestzeit zwischen Lastwechseln |
### Pufferspeicher
| Ident | Standard | Beschreibung |
| --- | ---: | --- |
| `LeistungsStufen` | `[]` | Stufe, Leistung und Boolean-Schaltkontakt |
| `PufferfuehlerVariableID` | `0` | Integer- oder Floatvariable |
| `AussentemperaturVariableID` | `0` | Integer- oder Floatvariable |
### Heizkurve
| Ident | Standard | Beschreibung |
| --- | ---: | --- |
| `FusspunktVorlauftemperatur` | `35.0 C` | Vorlauf bei 20 C aussen |
| `HeizkurvenSteigung` | `1.0` | Anhebung je Kelvin fallender Aussentemperatur |
| `HeizkurveMinimaltemperatur` | `20.0 C` | Untere Sollwertbegrenzung |
| `HeizkurveMaximaltemperatur` | `80.0 C` | Obere Sollwertbegrenzung |
| `Hysterese` | `5.0 K` | Abstand zur Einschaltschwelle |
| `MindesttemperaturModus` | `Aus` | Aus, statisch oder Differenz |
| `Mindesttemperatur` | `20.0 C` | Wert im statischen Modus |
| `MindesttemperaturDifferenz` | `5.0 K` | Wert im Differenzmodus |
| `WaermepumpenSolltemperaturVariableID` | `0` | Optionale Quelle fuer die WP-Uebernahme |
Der Button **Wert von Waermepumpe uebernehmen** berechnet den Fusspunkt so,
dass die lokale Heizkurve beim aktuellen Aussenwert durch den aktuellen
WP-Sollwert verlaeuft. Es entsteht keine dauerhafte Laufzeitkopplung.
### Erweiterte Einstellungen
| Ident | Standard | Beschreibung |
| --- | ---: | --- |
| `TemperaturMaxAlter` | `120 s` | Maximal zulaessiges Alter beider Messwerte |
| `PuffertemperaturGlaetten` | `false` | Aktiviert PT1-Glaettung |
| `ZeitKonstante` | `120 s` | PT1-Zeitkonstante |
| `EinstellungenInVisu` | `false` | Gemeinsame Verbraucheroption |
| `DiagnosevariablenAnzeigen` | `false` | Diagnosevariablen einblenden |
| `LoggingEin` | `false` | Debug-Protokoll aktivieren |
## Variablen
Immer vorhanden:
| Ident | Typ | Beschreibung |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Lokale Ein-/Aus-Freigabe |
| `Puffertemperatur` | Float | Aktueller, gegebenenfalls geglaetteter Wert |
| `Aussentemperatur` | Float | Heizkurveneingang |
| `Solltemperatur` | Float | Berechneter und begrenzter Sollwert |
| `Heizbedarf` | Boolean | Temperatur liegt unter der Einschaltschwelle |
Optional sichtbar sind die gemeinsamen Verbraucher-Diagnosevariablen sowie
`AktiveStufe`, `BezogeneEnergie` und `TemperaturenGueltig`.
## Managerkommunikation
Das Modul implementiert `VerbraucherSchnittstelle` und Vertrag `4.0`.
Es uebernimmt zuerst die vom Manager angekuendigte Betriebsart, berechnet das
passende Leistungsangebot und meldet es mit derselben Betriebsart zurueck.
Erst danach wird eine Sollleistung angenommen.
Die Rueckmeldung enthaelt zusaetzlich:
- `Puffertemperatur_C`,
- `Aussentemperatur_C`,
- `Solltemperatur_C`,
- `Heizbedarf`,
- `AktiveStufe`,
- `Fuehlerfehler` und `Schaltfehler`.
## Sicherheit und Schaltung
Die Kontakte werden Break-before-make geschaltet: zuerst alle aus, danach
hoechstens eine Stufe ein. Ein Schaltfehler fuehrt zu einem Ausschaltversuch
aller Kontakte und einer Stoerungsmeldung. Hardwareseitige Verriegelungen,
Temperaturbegrenzer und Schutzorgane bleiben erforderlich.
## Tests
- `PufferspeicherReglerTest` prueft Heizkurve, Hysterese, Ein/Aus, Peak und
alle Mindesttemperatur-Modi.
- `PufferspeicherModulstrukturTest` prueft Metadaten, Properties, Formular,
Vertrag und Schaltfolge.
- Der standardisierte Symcon-Test prueft die reale Modulinstanz und ist im
gemeinsamen Manifest sowie in der Manager-Matrix registriert.
+28
View File
@@ -0,0 +1,28 @@
# EMS-Module und Modulentwürfe
> Manager, Warmwassererwaermer, Pufferspeicher, Batterie, Verbraucher 1-Stufig,
> Waermepumpe, Ladestation Stand-Alone, Easee Gateway und Ladestation Gateway
> sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren Ordner
> enthalten Besprechungsgrundlagen.
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
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 (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) |
| [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready |
| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung (implementiert) |
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Eventbasierte Ladestation am Easee Gateway (implementiert) |
| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation (implementiert) |
## Review-Regel
`0*` bezeichnet einen noch nicht eingerichteten Anlagenwert. Offene Punkte
werden nicht durch Annahmen ersetzt. Erst nach Freigabe wird aus einem Entwurf
ein Ordner mit `module.json`, `form.json`, `module.php` und Unit-Tests.
+220
View File
@@ -0,0 +1,220 @@
# Verbraucher 1-Stufig
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
Das Modul schaltet einen elektrischen Ein-/Aus-Verbraucher. Im frei
schaltbaren Zustand meldet es bei PV `[0, Nennleistung]` und bei Peak `[0]`.
Ist die Tagesmindestlaufzeit faellig, wird bei PV die Nennleistung erzwungen.
Bei Peak ist konfigurierbar, ob `[0, Nennleistung]` angeboten oder die
Nennleistung ohne Sperrmoeglichkeit erzwungen wird. Waerend einer
Mindest-Ein- oder Mindest-Aus-Zeit sowie bei einer ausstehenden Rueckmeldung
wird unabhaengig von der Betriebsart nur der aktuelle bestaetigte
Leistungswert angeboten.
## Architekturentscheidung
Die Regelung ist ereignisbasiert. Neuberechnungen werden ausgeloest durch:
- eine neue Manager-Vorgabe,
- eine Aenderung an Schaltkontakt oder optionaler Rueckmeldung,
- das Ein- oder Ausschalten der lokalen EMS-Freigabe,
- das Ende der zustandsabhaengigen Mindestzeit,
- das Ablaufen einer Manager-Vorgabe,
- den naechsten relevanten Zeitpunkt der Tageslaufzeitplanung.
Es gibt keinen Regelzyklus, kein `Interval`, keinen `IdleCounterMax` und
keinen allgemeinen `Umschaltabstand`. `Meldeintervall` bleibt als
vertraglich geforderte periodische Vollmeldung bestehen und steuert keine
Regelberechnung.
## Properties
Zu den sechs gemeinsamen Verbraucher-Properties aus
[`Schnittstelle.md`](../../Schnittstelle.md) kommen neun
Modul-Properties hinzu.
| Ident | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `Nennleistung` | Integer | `0` | Positive elektrische Leistung in W. |
| `SchaltkontaktVariableID` | Integer | `0` | Boolean-Aktor mit Standard- oder benutzerdefinierter Aktion. |
| `SchaltkontaktInvertiert` | Boolean | `false` | Kehrt die Ein-/Aus-Semantik des Aktors um. |
| `RueckmeldungVariableID` | Integer | `0` | Optionale Boolean-Rueckmeldung; `true` bedeutet physisch eingeschaltet. |
| `Mindestlaufzeit` | Integer | `0` | Geforderte Laufzeit pro lokalem Kalendertag in Sekunden, maximal 86400. |
| `PeakSperreBeiMindestlaufzeitAnbieten` | Boolean | `true` | Bietet bei faelliger Tagesmindestlaufzeit im Peakbetrieb zusaetzlich `0` als Sperre an. Bei `false` wird die Nennleistung lokal erzwungen. |
| `Mindesteinschaltdauer` | Integer | `5` | Mindestzeit in Sekunden, die ein bestaetigter Ein-Zustand gehalten wird. |
| `Mindestausschaltdauer` | Integer | `5` | Mindestzeit in Sekunden, die ein bestaetigter Aus-Zustand gehalten wird. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Legt gemeinsame und modulspezifische Diagnosevariablen an. |
Beide Mindestzeiten duerfen `0` sein. Die lokale Aktion `Aktiv=false`
bleibt der einzige bewusste Override und schaltet sicher aus, auch wenn die
Mindesteinschaltdauer noch laeuft.
`EinstellungenInVisu` ist Teil der gemeinsamen Verbraucherbasis. Das Modul
besitzt derzeit keine zusaetzlichen bedienbaren Einstellvariablen und wertet
diese Property deshalb nicht weiter aus.
## Variablen
Immer sichtbar:
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; Startwert `false`. |
| `Schaltzustand` | Boolean / Anzeige | Bestaetigter oder aus dem Aktor berechneter Zustand. |
| `Tageslaufzeit` | Integer / Anzeige | Bestaetigte Laufzeit des lokalen Kalendertags in Sekunden. |
Mit `DiagnosevariablenAnzeigen` werden zusaetzlich angelegt:
- `Istleistung`
- `Leistungsquelle`
- `Sollleistung`
- `SollwertGueltig`
- `Verfuegbar`
- `AenderungMoeglich`
- `Stoerung`
- `Stoertext`
- `Rueckmeldefehler`
- `SchaltbefehlAusstehend`
- `RestMindestzeit`
Die Regellogik arbeitet mit persistenten Attributen und ist nicht von der
Sichtbarkeit der Diagnosevariablen abhaengig.
## Mindest-Ein- und Mindest-Aus-Zeit
Nach einer bestaetigten Einschaltung wird fuer
`Mindesteinschaltdauer` Sekunden kein regulaeres Ausschalten angeboten. Nach
einer bestaetigten Ausschaltung blockiert `Mindestausschaltdauer` ein
regulaeres Einschalten.
Waerend einer Mindestzeit gilt:
- `AenderungMoeglich=false`,
- `Schaltbereit=false`,
- `RestMindestzeit_s` enthaelt die verbleibenden Sekunden,
- `Leistungswerte_W` enthaelt nur die bestaetigte Istleistung.
Ein einmaliger Timer loest am Ende der Mindestzeit genau eine Neuberechnung
aus. Der fruehere allgemeine `Umschaltabstand` ist vollstaendig entfallen.
## Rueckmeldung und Schaltbereitschaft
Ohne separate Rueckmeldung wird der logische Zustand der Aktorvariable als
sofortige Schaltbestaetigung verwendet.
Mit `RueckmeldungVariableID` ist ausschliesslich deren Booleanwert fuer
`Schaltzustand`, `Istleistung_W`, Tageslaufzeit und den Beginn der
Mindestzeit massgeblich. Nach einem Aktorbefehl und vor der passenden
Rueckmeldung meldet das Modul:
- die bisherige bestaetigte Istleistung,
- `SchaltbefehlAusstehend=true`,
- `Schaltbereit=false`,
- `AenderungMoeglich=false`,
- nur den bisherigen Leistungswert in `Leistungswerte_W`.
Sobald die Rueckmeldung den Zielzustand bestaetigt, beginnt die passende
Mindest-Ein- oder Mindest-Aus-Zeit. Es gibt bewusst keinen zusaetzlichen
zyklischen Rueckmelde-Timeout. Bleibt die Rueckmeldung aus, bleibt der
Schaltbefehl sichtbar ausstehend.
Weichen Aktor- und Rueckmeldezustand ohne ausstehenden Schaltbefehl voneinander
ab, wird `Rueckmeldefehler=true`, `Verfuegbar=false` und eine Stoerung
gemeldet.
## Tagesmindestlaufzeit
Die Tageslaufzeit wird mit der in IP-Symcon eingestellten lokalen Zeitzone
gefuehrt. Sommer- und Winterzeit sowie der Wechsel um Mitternacht werden
beruecksichtigt. Nur der bestaetigte Ein-Zustand zaehlt.
Ist die konfigurierte Tagesmindestlaufzeit noch nicht erreicht, wird der
Verbraucher zum spaetestmoeglichen Zeitpunkt angefordert, an dem die
Restlaufzeit bis Mitternacht noch erfuellt werden kann. Im PV-Betrieb wird die
Nennleistung dann lokal erzwungen. Im Peakbetrieb meldet das Modul bei
aktivierter Property `PeakSperreBeiMindestlaufzeitAnbieten` wie Enelix 1
`[0, Nennleistung]`, sodass der Manager zwischen Sperren und Nachholen der
Laufzeit waehlen kann. Ist die Property deaktiviert, wird auch im Peakbetrieb
nur die Nennleistung angeboten und lokal erzwungen. Eine noch aktive
Mindestausschaltdauer hat Vorrang; eine unmoegliche Restlaufzeit kann nicht
rueckwirkend nachgeholt werden.
## Managerkommunikation
Das Modul implementiert
`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet
ausschliesslich Vertrag `4.0`. Der Verbraucher besitzt keine
Manager-ID-Property. Er akzeptiert nur Manager, in deren manueller oder
automatischer Verbraucherzuordnung seine Instanz aktiv eingetragen ist.
Neben den gemeinsamen Vertragsfeldern werden diese Zustaende gemeldet:
- `Schaltzustand`
- `SchaltbefehlAusstehend`
- `Schaltbereit`
- `RestMindestzeit_s`
- `Tageslaufzeit_s`
- `Rueckmeldefehler`
Rueckmeldungen erfolgen bei relevanten Ereignissen, kurz verzoegert nach einer
Manager-Vorgabe und zusaetzlich alle `Meldeintervall` Sekunden. Eine Vorgabe
wird nach `VorgabeTimeout` Sekunden ohne Erneuerung ungueltig.
## Installation und Inbetriebnahme
1. Im IP-Symcon Module Control den Testing-Branch `develop` der Bibliothek
`https://git.belevo.ch/ENELIX/Enelix-EMS.git` installieren oder
aktualisieren.
2. Unter **Instanz hinzufuegen** nach **Verbraucher 1-Stufig** suchen und eine
Instanz anlegen.
3. `Nennleistung` in ganzen Watt eintragen.
4. Als Schaltkontakt eine Booleanvariable mit funktionsfaehiger Aktion
auswaehlen und bei Bedarf `SchaltkontaktInvertiert` aktivieren.
5. Optional eine separate Boolean-Rueckmeldung auswaehlen. Dort muss
`true` dem physisch eingeschalteten Verbraucher entsprechen.
6. Tagesmindestlaufzeit, Mindest-Einschaltdauer und Mindest-Ausschaltdauer
passend zum angeschlossenen Geraet festlegen.
7. Die Instanz im Manager manuell aktiv zuordnen oder bei automatischer Suche
in der gefundenen Liste aktivieren.
8. Fuer die Erstpruefung `DiagnosevariablenAnzeigen` und bei Bedarf
`LoggingEin` einschalten.
9. Unter Aufsicht `Aktiv` einschalten und ueber den Manager je eine Ein- und
Aus-Vorgabe pruefen.
## Abnahmecheckliste
- `Aktiv=false` schaltet den Ausgang aus.
- Eine Ein-Vorgabe setzt zuerst den Aktor; mit separater Rueckmeldung bleibt
`SchaltbefehlAusstehend=true`, bis diese einschaltet.
- Die Mindesteinschaltdauer beginnt erst mit der Ein-Rueckmeldung.
- Vor Ablauf der Mindesteinschaltdauer wird kein Ausschalten angeboten.
- Nach bestaetigter Ausschaltung beginnt die Mindestausschaltdauer.
- Vor Ablauf der Mindestausschaltdauer wird kein Einschalten angeboten.
- `RestMindestzeit_s` erreicht 0 und `Schaltbereit` wird danach true.
- `Tageslaufzeit` steigt nur bei bestaetigtem Ein-Zustand.
- Ein unerwarteter Unterschied zwischen Aktor und Rueckmeldung erzeugt eine
Stoerung.
- Nach einem Neustart wird keine alte Manager-Vorgabe ungeprueft fortgesetzt.
## Tests
Nur dieses Modul:
```bash
composer check:verbraucher-einstufig
```
Gesamtes Repository inklusive dieser Modulsuite:
```bash
composer check
```
Die separate Suite und ihre Struktur sind unter
[`tests/VerbraucherEinStufig`](../../../tests/VerbraucherEinStufig/README.md)
dokumentiert.
## Migration
Die Zuordnung der Enelix-1- und bisherigen Enelix-2-Zeitwerte steht in
[`docs/migration/Verbraucher-1-Stufig.md`](../../migration/Verbraucher-1-Stufig.md).
+89
View File
@@ -0,0 +1,89 @@
# Wärmepumpe
> Status: Implementiert.
Das Modul bindet eine Wärmepumpe über zwei schaltbare Boolean-Ausgänge in den
Enelix-Manager ein. Die Ausgänge können als getrennte Kontakte für Sperre und
Erhöhung oder als SG-Ready-Eingänge verwendet werden. Die Regelung arbeitet mit
dem Nachrichtenvertrag 4.0 und meldet für PV und Peak ein
zustandsabhängiges Leistungsangebot.
## Verhalten
- Im PV-Betrieb kann eine freigegebene Wärmepumpe aus dem Normalzustand in die
Erhöhung geschaltet werden.
- Im Peak-Betrieb kann eine laufende Wärmepumpe gesperrt werden, sofern
Mindestlaufzeit und Sperrgrenzen dies erlauben.
- Schaltvorgänge erfolgen als Break-before-Make: beide Kontakte werden zuerst
deaktiviert, danach wird höchstens ein Kontakt aktiviert.
- Nach einem erfolglosen PV-Anlauf wird die Erhöhung aufgehoben und bis zum
Ablauf der Wiederholsperre nicht erneut angeboten.
- Die maximale Sperrzeit am Stück und innerhalb von 24 Stunden erzwingt eine
Erholungsphase im Normalbetrieb.
- Ohne gültige Manager-Vorgabe fällt die Steuerung in den sicheren
Normalzustand zurück.
## Konfiguration
| Property | Standard | Beschreibung |
| --- | ---: | --- |
| `Kontaktart` | `0` | `0`: Sperre/Erhöhung, `1`: SG Ready |
| `Kontakt1VariableID` | `0` | Schaltbarer Ausgang für Sperre beziehungsweise SG Ready 1 |
| `Kontakt2VariableID` | `0` | Schaltbarer Ausgang für Erhöhung beziehungsweise SG Ready 2 |
| `Kontakt1Invertiert` | `false` | Logik des ersten Ausgangs invertieren |
| `Kontakt2Invertiert` | `false` | Logik des zweiten Ausgangs invertieren |
| `Nennleistung` | `0 W` | Elektrische Nennleistung der Wärmepumpe |
| `Rueckmeldungsart` | `0` | `0`: gemessene Leistung, `1`: Boolean-Betriebsstatus |
| `IstleistungVariableID` | `0` | Leistungsmessung oder Betriebsrückmeldung |
| `Laufschwelle` | `100 W` | Ab dieser Leistung gilt die Wärmepumpe als laufend |
| `Anlaufwartezeit` | `30 s` | Wartezeit auf bestätigten Anlauf |
| `Wiederholsperre` | `300 s` | Pause nach einem erfolglosen Anlauf |
| `Mindestlaufzeit` | `1200 s` | Mindestlaufzeit nach bestätigtem Start |
| `Mindestsperrzeit` | `1200 s` | Mindestdauer einer begonnenen Sperre |
| `MaximaleSperrzeit` | `7200 s` | Maximale zusammenhängende Sperre; `0` deaktiviert |
| `MaximaleSperrzeit24h` | `21600 s` | Maximale Sperrsumme in 24 Stunden; `0` deaktiviert |
Hinzu kommen die gemeinsamen Verbraucher-Properties für Prioritäten,
Meldeintervall, Vorgabe-Timeout, Visualisierung und Logging.
## Variablen
| Ident | Typ | Beschreibung |
| --- | --- | --- |
| `Waermepumpenzustand` | Integer | `1` normal/aus, `2` normal/laufend, `3` erhöht, `4` gesperrt |
| `WaermepumpeLaeuft` | Boolean | Bestätigter Betriebsstatus |
| `SperreAktiv` | Boolean | Sperrkontakt ist aktiv |
| `ErhoehungAktiv` | Boolean | Erhöhungskontakt ist aktiv |
| `SGReadyZustand` | Integer | Bitwert der beiden logischen Kontakte |
| `Laufzeit` | Integer | Kumulierte bestätigte Laufzeit in Sekunden |
Optionale Diagnosevariablen zeigen unter anderem Ist- und Sollleistung,
Verfügbarkeit, Anlaufstatus, Wiederholsperre, Sperrerholung, verbleibende
Mindestzeiten und die Sperrsumme der letzten 24 Stunden.
## Adaption aus Enelix 1
| Bereich | Behandlung in Enelix 2 |
| --- | --- |
| Grundzustände Normal, Sperre und Erhöhung | angepasst übernommen |
| Zwei physische Kontakte | angepasst und mit Invertierung sowie Rücklesekontrolle umgesetzt |
| Leistungsangebot an den Manager | neu nach Nachrichtenvertrag 4.0 implementiert |
| Mindestlauf- und Sperrzeiten | neu als ereignisbasierte Schutzlogik implementiert |
| Wetter-, Wolken- und Sonnenaufgangslogik | nicht uebernommen |
| Warmwasser-Schwellwert im Wärmepumpenmodul | nicht uebernommen |
| Fester zyklischer Fünf-Sekunden-Regler | nicht uebernommen; Ereignisse und Einmal-Timer |
## Lizenzierung und Manager
Der Manager erkennt das Modul über die Modul-ID
`{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}`. Es verwendet das bestehende
Lizenzkontingent `consumer_single`, das im Portal als einstufiger Verbraucher
mit Wärmepumpe als Beispiel angeboten wird.
## Sicherheitshinweise
Die beiden Ausgänge müssen unterschiedliche, schaltbare Boolean-Variablen sein.
Die Rückmeldung ist verpflichtend: entweder eine numerische Leistungsmessung
oder ein Boolean-Betriebsstatus. Bei ungültiger Konfiguration, fehlgeschlagenem
Schalten oder widersprüchlicher Rücklesung setzt das Modul seinen Status auf
Fehler und meldet sich dem Manager als nicht verfügbar.
+406
View File
@@ -0,0 +1,406 @@
# Verbraucher Warmwassererwaermer
> Status: implementiert fuer IP-Symcon 8.0+ und den Enelix-2-Vertrag `4.0`.
Das Modul bindet einen elektrischen Warmwasserspeicher mit einer oder mehreren
exklusiven Leistungsstufen an den Enelix-Manager an. Der Manager kann nur eine
der aktuell angebotenen Leistungen vorgeben. Mindesttemperatur, Hysterese,
Zeitplan und Legionellenfunktion bestimmen dabei zustands- und
betriebsartabhaengig das Leistungsangebot.
Das Modul ist eine gezielte Adaption des Enelix-1-Moduls
`Boiler_x_Stufig`. Zyklische Altlogik und alte Kommunikationsvariablen wurden
nicht uebernommen. Die Regelung arbeitet ereignisbasiert und verwendet
ausschliesslich den Enelix-2-Nachrichtenvertrag.
| Technisches Merkmal | Wert |
| --- | --- |
| Modulname | `VerbraucherWarmwassererwaermer` |
| Alias | `Wassererwärmer` |
| Modul-ID | `{B7C54AF4-AD7D-4FE4-B75D-203693906251}` |
| Modultyp | Geraeteinstanz (`3`) |
| Funktionspraefix | `ENELIX` |
| Enelix-Vertrag | `4.0` |
## Funktionsumfang
- beliebig viele, eindeutig konfigurierte Leistungsstufen inklusive `0 W`,
- exklusive Break-before-make-Schaltung der Stufenkontakte,
- lokale Mindest- und Maximaltemperatur mit Hysterese,
- optionale PT1-Glaettung des Temperaturmesswerts,
- thermische Vorhersage fuer taegliche Solltemperatur-Zeitpunkte,
- zweistufige Legionellenfunktion mit Fruehest- und Spaetestintervall,
- berechnete Istleistung, aktive Stufe und bezogene Energie,
- ereignisbasierte Regelung mit einstellbarer Lastwechselsperre,
- Kommunikation mit manuell oder automatisch zugeordneten Enelix-Managern,
- optionale Diagnosevariablen und Debug-Logging.
## Voraussetzungen
- IP-Symcon ab Version 8.0,
- installierte Bibliothek `Enelix-EMS` vom Branch `develop`,
- eine Integer- oder Floatvariable als Temperaturfuehler,
- mindestens eine positive Leistungsstufe,
- je Leistungsstufe eine eigene Booleanvariable mit funktionsfaehiger Aktion,
- ein Enelix-Manager, dem die Verbraucherinstanz aktiv zugeordnet wird.
Die Schaltkontakte duerfen nicht mehrfach verwendet werden. Der Anlagenaufbau
muss sicherstellen, dass die konfigurierten Leistungsstufen elektrisch
zulaessig sind. Die Software ersetzt keine hardwareseitigen Verriegelungen,
Temperaturbegrenzer oder Schutzorgane.
## Regelungsablauf
Eine Neuberechnung wird insbesondere ausgeloest durch:
- einen neuen oder aktualisierten Temperaturmesswert,
- das Ein- oder Ausschalten von `Aktiv`,
- eine neue Manager-Vorgabe,
- eine Aenderung der bedienbaren Temperatursollwerte,
- das Ende der Lastwechselsperre,
- das Uebernehmen einer neuen Instanzkonfiguration,
- eine periodische Vollmeldung an den Manager.
`Meldeintervall` ist kein Regelintervall. Es stellt die regelmaessige
vollstaendige Rueckmeldung sicher. Die Lastwechselsperre verwendet einen
einmaligen Timer und loest nach ihrem Ablauf genau eine Neuberechnung aus.
Die Zielentscheidung folgt der Enelix-1-Zustandsmatrix:
1. Bei deaktivierter Instanz oder ungueltiger Temperatur ist der Verbraucher
nicht verfuegbar und wird ausgeschaltet. Eine ungueltige Konfiguration wird
bereits beim Uebernehmen mit Status `202` abgewiesen.
2. Unter der wirksamen Mindesttemperatur sowie in der unteren Hysterese bei
aktiver Stufe gilt: PV erzwingt die Maximalleistung, Peak bietet
`[0, ...Leistungsstufen]` an.
3. Ausserhalb dieses Mindesttemperaturbereichs bietet Peak nur `[0]` an.
4. Im PV-Betrieb werden unterhalb von `Maximaltemperatur - Hysterese` alle
Stufen angeboten. In der oberen Hysterese gilt dies nur, solange bereits
eine Stufe aktiv ist.
5. An oder oberhalb der wirksamen Maximaltemperatur wird ausgeschaltet.
Eine erzwungene PV-Maximalleistung wird mit `AenderungMoeglich=false`
gemeldet. Das Peak-Array bleibt dagegen durch den Manager waehlbar.
## Konfiguration
Das Modul besitzt 19 Properties: sechs gemeinsame Verbraucher-Properties und
13 modulspezifische Properties.
### Manager und Zeitverhalten
| Ident | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `PrioritaetPV` | Integer | `0` | Prioritaet im PV-Betrieb; kleinere Werte werden zuerst beruecksichtigt. |
| `PrioritaetPeak` | Integer | `0` | Prioritaet im Peak-Betrieb; kleinere Werte werden zuerst beruecksichtigt. |
| `Meldeintervall` | Integer | `10` | Abstand der vollstaendigen Verbraucherrueckmeldungen in Sekunden. |
| `VorgabeTimeout` | Integer | `120` | Zeit in Sekunden, nach der eine nicht erneuerte Manager-Vorgabe ungueltig wird. |
| `LastwechselSperrzeit` | Integer | `5` | Mindestzeit zwischen zwei Lastwechseln in Sekunden. |
### Speichereinstellungen
| Ident | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `LeistungsStufen` | JSON-Liste | `[]` | Positive Leistung, Stufennummer und Boolean-Schaltkontakt jeder Stufe. |
| `Boilerfuehler_PT1` | Integer | `0` | Objekt-ID der Integer- oder Floatvariable fuer die Speichertemperatur. |
| `LegionellenfunktionAktiv` | Boolean | `true` | Aktiviert die periodische Anhebung auf Legionellentemperatur. |
| `LegionellenMinimalintervallTage` | Integer | `4` | Fruehestens: Legionellentemperatur wird zur Maximaltemperatur. |
| `LegionellenMaximalintervallTage` | Integer | `7` | Spaetestens: Legionellentemperatur wird zur Mindesttemperatur. |
Jeder Eintrag in `LeistungsStufen` besteht aus:
| Feld | Anforderung |
| --- | --- |
| `Stufe` | Positive Ganzzahl. |
| `Leistung` | Eindeutige positive Ganzzahl in Watt. |
| `Schaltkontakt_Stufe` | Eindeutige Objekt-ID einer Booleanvariable. |
Die Eintraege werden intern nach Leistung sortiert. `0 W` wird automatisch als
Aus-Zustand in das Leistungsangebot aufgenommen und darf nicht als eigene
Stufe konfiguriert werden.
### Erweiterte Speichereinstellungen
| Ident | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `Boilervolumen` | Integer | `300` | Speichervolumen in Litern fuer die thermische Zeitplanprognose. |
| `Hysterese` | Float | `5.0` | Temperaturhysterese in Kelvin. |
| `Zeitplan` | JSON-Liste | `[]` | Taegliche Zielwerte mit `Uhrzeit` im Format `HH:MM` und `Solltemperatur`. |
Der jeweils naechste Zeitplaneintrag wird fuer heute oder den folgenden Tag
ermittelt. Reicht die verbleibende Zeit bei maximaler elektrischer Leistung
rechnerisch nicht mehr aus, wird das Zeitplanziel zur wirksamen
Mindesttemperatur. Im PV-Betrieb wird dann die hoechste Stufe erzwungen, im
Peakbetrieb werden alle Stufen angeboten. Die Berechnung verwendet Wasser mit
`4186 J/(kg K)` und beruecksichtigt keine Speicher- oder Leitungsverluste. Fuer
Datum und Uhrzeit gilt die in IP-Symcon eingestellte lokale Zeitzone.
### Erweiterte sonstige Einstellungen
| Ident | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `TemperaturMaxAlter` | Integer | `30` | Maximal zulaessiges Alter des Temperaturmesswerts in Sekunden. |
| `BoilertemperaturGlaetten` | Boolean | `false` | Aktiviert die PT1-Glaettung. |
| `ZeitKonstante` | Integer | `120` | PT1-Zeitkonstante in Sekunden. |
| `EinstellungenInVisu` | Boolean | `false` | Zeigt Temperatursollwerte an und gibt ihre Bedienung frei. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Legt die 14 Diagnosevariablen an. |
| `LoggingEin` | Boolean | `false` | Aktiviert laufende Debug-Ausgaben des Moduls. |
Alle Zeitwerte und Intervalle muessen groesser als `0` sein. Die beiden
Prioritaeten muessen mindestens `0` betragen. Das Legionellen-Minimalintervall
darf nicht groesser als das Maximalintervall sein.
## Temperaturverarbeitung
Der Rohwert des konfigurierten Fuehlers muss numerisch und juenger als
`TemperaturMaxAlter` sein. Andernfalls werden `TemperaturGueltig=false`,
`Verfuegbar=false` und eine Stoerung gemeldet; eine aktive Stufe wird
ausgeschaltet.
Ohne Glaettung entspricht `Boilertemperatur` dem letzten gueltigen Rohwert.
Mit aktivierter Glaettung verwendet das Modul ein PT1-Glied. Der erste Wert
initialisiert den Filter; danach wird die tatsaechlich seit der letzten
Temperaturberechnung vergangene Zeit verwendet.
Die Solltemperaturen werden bei der ersten Initialisierung auf folgende Werte
gesetzt:
| Variable | Initialwert |
| --- | ---: |
| `Mindesttemperatur` | `45.0 Grad C` |
| `Maximaltemperatur` | `60.0 Grad C` |
| `Legionellentemperatur` | `65.0 Grad C` |
Es gilt immer:
`Mindesttemperatur < Maximaltemperatur <= Legionellentemperatur`
Temperaturwerte muessen zwischen `0` und `100 Grad C` liegen. Bedienaktionen sind
nur erlaubt, wenn `EinstellungenInVisu=true` gesetzt ist.
## Hysterese und lokales Nachladen
Sinkt die Temperatur unter die wirksame Mindesttemperatur, erzwingt PV die
hoechste Leistungsstufe; Peak bietet dagegen Aus und alle Stufen an. In der
unteren Hysterese bis `Mindesttemperatur + Hysterese` bleibt dieses Verhalten
nur erhalten, solange bereits eine Stufe aktiv ist.
Im PV-Betrieb werden unter `Maximaltemperatur - Hysterese` alle Stufen
angeboten. Innerhalb der oberen Hysterese bleibt das Angebot nur bei einer
bereits aktiven Stufe erhalten. An der Maximaltemperatur wird ausgeschaltet.
## Legionellenfunktion
Die Legionellenfunktion arbeitet zweistufig:
1. Ab `LegionellenMinimalintervallTage` wird die Legionellentemperatur zur
wirksamen Maximaltemperatur. Der Manager kann die Aufheizung innerhalb des
verbleibenden Zeitfensters ermoeglichen.
2. Ab `LegionellenMaximalintervallTage` wird die Legionellentemperatur auch zur
wirksamen Mindesttemperatur. PV erzwingt dadurch die Maximalleistung; Peak
bietet das vollstaendige Stufenarray bis zum Ziel an.
Erreicht die gueltige Speichertemperatur die Legionellentemperatur, wird der
Zeitpunkt als erfolgreicher Abschluss gespeichert und das Intervall beginnt
neu. `LegioCounter` zeigt die Sekunden seit diesem Abschluss.
`Legionellentemperatur` wird nur als Symcon-Variable angelegt, wenn
`LegionellenfunktionAktiv=true` ist. Beim Ausschalten der Funktion wird die
Variable geloescht; der letzte Wert bleibt intern erhalten und steht bei einem
spaeteren Wiedereinschalten wieder zur Verfuegung.
## Lastwechselsperre und Schaltsicherheit
Nach jedem tatsaechlichen Lastwechsel wird fuer
`LastwechselSperrzeit` Sekunden kein weiterer regulaerer Wechsel zugelassen.
In dieser Zeit gilt:
- `AenderungMoeglich=false`,
- `Leistungswerte_W` enthaelt nur die aktuell gehaltene Leistung,
- eine abweichende Manager-Vorgabe wird abgewiesen,
- ein einmaliger Timer plant die Freigabe.
Ein sicherheitsbedingtes Ausschalten, beispielsweise bei ungueltiger
Temperatur oder erreichter Maximaltemperatur, darf die Sperre uebergehen.
Separate Variablen `Idle` oder `IdleCounter` existieren nicht.
Beim Schalten werden zuerst alle konfigurierten Kontakte ausgeschaltet und
anschliessend hoechstens der Kontakt der Zielstufe eingeschaltet. Schlaegt ein
Schaltvorgang fehl, versucht das Modul alle Kontakte auszuschalten, setzt die
berechnete Istleistung auf `0 W` und meldet einen Schaltfehler.
## Variablen
### Betriebs- und Einstellvariablen
| Ident | Typ / Zugriff | Sichtbarkeit | Beschreibung |
| --- | --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | immer | Lokale EMS-Freigabe; Startwert `false`. |
| `Boilertemperatur` | Float / Anzeige | immer | Letzter gueltiger, gegebenenfalls geglaetteter Temperaturwert. |
| `Mindesttemperatur` | Float / bedienbar | bei `EinstellungenInVisu` | Untere Grenze fuer lokales Nachladen. |
| `Maximaltemperatur` | Float / bedienbar | bei `EinstellungenInVisu` | Abschaltgrenze im Normalbetrieb. |
| `Legionellentemperatur` | Float / bedienbar | Funktion aktiv; Anzeige freigegeben | Ziel des Legionellenprogramms. |
### Diagnosevariablen
Mit `DiagnosevariablenAnzeigen=true` werden diese 14 Variablen angelegt. Beim
Abschalten der Property werden sie wieder geloescht. Die Regellogik verwendet
persistente interne Zustaende und funktioniert unabhaengig von diesen
Anzeigevariablen.
| Ident | Typ | Bedeutung |
| --- | --- | --- |
| `Istleistung` | Float | Berechnete aktuelle Leistung in Watt. |
| `Leistungsquelle` | Integer | Immer `1` fuer berechnete Leistung. |
| `Sollleistung` | Integer | Aktuell angenommene oder lokal erzwungene Zielvorgabe in Watt. |
| `SollwertGueltig` | Boolean | Eine Manager-Vorgabe ist vorhanden und noch nicht abgelaufen. |
| `Verfuegbar` | Boolean | Das Modul kann grundsaetzlich durch das EMS gesteuert werden. |
| `AenderungMoeglich` | Boolean | Der Manager darf momentan eine neue Leistung vorgeben. |
| `Stoerung` | Boolean | Mindestens eine Stoerbedingung ist aktiv. |
| `Stoertext` | String | Zusammengefasste lesbare Stoerbeschreibung. |
| `AktiveStufe` | Integer | Stufennummer der berechneten aktuellen Leistung; `0` bedeutet aus. |
| `BezogeneEnergie` | Float | Aus Istleistung und verstrichener Zeit berechnete Energie in kWh. |
| `TemperaturGueltig` | Boolean | Temperaturfuehler vorhanden, numerisch und nicht veraltet. |
| `NachladenAktiv` | Boolean | Lokales Nachladen aufgrund der Mindesttemperatur ist aktiv. |
| `LegionellenbetriebAktiv` | Boolean | Das Fruehestintervall der Legionellenfunktion ist erreicht. |
| `LegioCounter` | Integer | Sekunden seit dem letzten erfolgreichen Legionellenabschluss. |
## Managerkommunikation
Das Modul implementiert
`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet den
Enelix-2-Vertrag `4.0`. Es besitzt keine Manager-ID-Property. Zugelassen sind
nur Manager, in deren manueller oder automatischer Verbraucherzuordnung die
Instanz aktiv eingetragen ist.
Eine Manager-Vorgabe wird nur angenommen, wenn:
- Vertragsversion, Absender, Empfaenger und Datentypen gueltig sind,
- der Manager die Instanz aktiv zugeordnet hat,
- `Sollleistung_W` im zuletzt berechneten `Leistungswerte_W` enthalten ist.
Rueckmeldungen erfolgen bei relevanten Ereignissen, kurz verzoegert nach einer
Manager-Vorgabe und zusaetzlich alle `Meldeintervall` Sekunden. Die kurze
Verzoegerung verhindert eine synchrone Manager-Verbraucher-Endlosschleife.
Die Verbraucherrueckmeldung enthaelt insbesondere:
- Prioritaeten fuer PV- und Peak-Betrieb,
- `Leistungswerte_W`, `AenderungMoeglich` und `Verfuegbar`,
- `Istleistung_W` mit `Leistungsquelle=1`,
- Zustandseintraege fuer Sollleistung, Wassertemperatur, Maximaltemperatur,
aktive Stufe, Nachladen, Legionellenbetrieb und Stoerungen.
Nach `VorgabeTimeout` Sekunden ohne Erneuerung wird eine Manager-Vorgabe
bei der naechsten Neuberechnung, spaetestens bei der folgenden Vollmeldung,
ungueltig. Beim Neustart wird keine alte Manager-Vorgabe ungeprueft wieder
aufgenommen.
## Abgrenzung des aktuellen Stands
- `Istleistung`, `AktiveStufe` und `BezogeneEnergie` sind berechnete Werte; das
Modul besitzt keinen Anschluss fuer einen elektrischen Leistungsmesser.
- Die Stufenkontakte sind Aktoren, keine separate physische Rueckmeldung. Ihr
Booleanzustand wird bei einer Regelberechnung auf Plausibilitaet geprueft.
- Externe Aenderungen an den Stufenkontakten loesen selbst keine
Neuberechnung aus. Der Temperaturfuehler ist die abonnierte Messvariable.
- Der Zeitplan beschreibt taeglich wiederkehrende Zielzeitpunkte und keine
einmaligen Kalendertermine.
## Status- und Fehlerzustaende
| Status | Bedeutung | Typische Ursache |
| ---: | --- | --- |
| `102` | Aktiv | Konfiguration und Temperaturmessung sind gueltig. |
| `201` | Temperaturmessung ungueltig | Fuehler fehlt, ist nicht numerisch oder zu alt. |
| `202` | Konfiguration ungueltig | Unzulaessige Intervalle, Temperaturen, Stufen, Kontakte oder Zeitplaneintraege. |
| `203` | Schaltfehler | Eine Aktion eines Stufenkontakts ist fehlgeschlagen. |
Eine Uebertemperatur liegt vor, wenn `Boilertemperatur` die hoehere Grenze aus
Maximal- und Legionellentemperatur um mehr als `Hysterese` ueberschreitet. Sie
wird ueber `Stoerung` und `Stoertext` gemeldet; der Modulstatus bleibt ohne
zusaetzlichen Schalt- oder Fuehlerfehler `102`.
## Installation und Inbetriebnahme
1. Im IP-Symcon Module Control den Branch `develop` der Bibliothek
`https://git.belevo.ch/ENELIX/Enelix-EMS.git` installieren oder
aktualisieren.
2. Unter **Instanz hinzufuegen** nach dem Alias **Wassererwärmer** oder dem
Modulnamen `VerbraucherWarmwassererwaermer` suchen und eine Instanz anlegen.
3. Den Temperaturfuehler auswaehlen und `TemperaturMaxAlter` passend zum
Aktualisierungsintervall des Fuehlers einstellen.
4. Alle Leistungsstufen mit positiver Leistung und jeweils eigenem
Boolean-Schaltkontakt konfigurieren.
5. Normal-, Legionellen- und Zeitplanwerte pruefen. Danach die Konfiguration
uebernehmen.
6. Die Instanz im Enelix-Manager manuell aktiv zuordnen oder bei automatischer
Suche in der gefundenen Liste aktivieren.
7. Fuer die Erstpruefung `DiagnosevariablenAnzeigen` und bei Bedarf
`LoggingEin` einschalten.
8. Unter Aufsicht `Aktiv` einschalten und jede Stufe einzeln pruefen. Dabei
kontrollieren, dass nie zwei Stufenkontakte gleichzeitig aktiv sind.
9. Eine Manager-Vorgabe fuer `0 W` und fuer jede konfigurierte Stufe pruefen.
10. Diagnosevariablen nach der Abnahme bei Bedarf wieder ausschalten.
## Abnahmecheckliste
- Die Instanz erreicht Status `102`.
- `Aktiv=false` schaltet alle Stufenkontakte aus.
- Der Fuehlerwert erscheint unverfaelscht oder erwartungsgemaess geglaettet in
`Boilertemperatur`.
- Unterhalb der Mindesttemperatur wird die hoechste Stufe lokal erzwungen.
- Oberhalb der Maximaltemperatur werden alle Stufen ausgeschaltet.
- Jede Manager-Vorgabe schaltet genau den zugeordneten Kontakt.
- Direkt nach einem Wechsel ist `AenderungMoeglich=false`; nach der
konfigurierten Sperrzeit wird ein regulaerer Wechsel wieder freigegeben.
- Eine Gegenanforderung waehrend der Sperre liegt nicht im gemeldeten
Leistungsangebot.
- Ein veralteter oder ungueltiger Fuehlerwert fuehrt zu Status `201`, einer
Stoerung und sicherem Ausschalten.
- Bei aktiver Legionellenfunktion wird `Legionellentemperatur` angelegt; nach
dem Abschalten der Funktion wird die Variable geloescht.
- Mit ausgeschalteten Diagnosevariablen bleiben Regelung und Kommunikation
funktionsfaehig.
## Diagnosehinweise
- Status `202`: zuerst Fuehler-ID, Leistungsstufen, eindeutige Kontakte,
Temperaturreihenfolge, Intervalle und Zeitplanformat pruefen.
- Status `201`: `VariableUpdated`, Datentyp des Fuehlers und
`TemperaturMaxAlter` kontrollieren.
- Status `203`: Aktionen der Boolean-Schaltkontakte einzeln in IP-Symcon
testen; das Modul versucht bei einem Fehler alle Kontakte auszuschalten.
- Manager-Vorgabe wird abgewiesen: Zuordnung im Manager,
`AenderungMoeglich` und das aktuelle `Leistungswerte_W` pruefen.
- Unerwartetes Nachladen: Mindesttemperatur, Hysterese, naechstes Zeitplanziel
und Alter des letzten Legionellenabschlusses kontrollieren.
## Tests
`WarmwasserReglerTest.php` prueft die reine Regellogik fuer Leistungsstufen,
PT1, Lastwechselsperre, Energieberechnung, thermische Prognose, Zeitplan und
Legionellengrenzen.
`WarmwassererwaermerModulstrukturTest.php` prueft Metadaten, Formular, alle 19
Properties, ereignisbasierte Lastwechselsperre, Vertrag `4.0`, bedarfsgesteuerte
Diagnosevariablen, Legionellentemperatur, Temperatursollwerte und
Break-before-make-Schaltung.
## Migration von Enelix 1
Die Zuordnung der uebernommenen, angepassten und entfallenen Felder des alten
Moduls `Boiler_x_Stufig` ist in
[`docs/migration/Boiler-x-Stufig.md`](../../migration/Boiler-x-Stufig.md)
dokumentiert.
Wesentliche Unterschiede:
- `Interval` entfaellt; die Regelung ist ereignisbasiert.
- `IdleCounterMax` wird durch `LastwechselSperrzeit` in Sekunden ersetzt.
- `Idle` und `IdleCounter` entfallen vollstaendig.
- `PowerSteps` wird durch das Vertragsfeld `Leistungswerte_W` ersetzt.
- Prioritaeten sind Properties; Betriebsart und Leistungsverteilung liegen im
Manager.
- Die neue Modul-ID erfordert eine neue Instanz; eine automatische Umwandlung
des Enelix-1-Objekts findet nicht statt.
+106
View File
@@ -0,0 +1,106 @@
# Standardisierte Tests
## Ziel
Dieses Repository verwendet zwei verbindliche Testebenen:
1. PHPUnit prüft reine PHP-Logik und Struktur bei jedem Push.
2. Symcon-Modultests prüfen reale Instanzen, Variablen, Actions und Zusammenspiel
in IP-Symcon 8.x.
Alle implementierten Module müssen in `tests/Symcon/manifest.php` eingetragen
sein und ein eigenes Skript in `tests/Symcon/modules/` besitzen. Der
PHPUnit-Test `SymconTestContractTest` erzwingt diese Regel auch für künftig
hinzugefügte Module.
## Testvertrag
Ein Modultest gibt eine aufrufbare Funktion mit dieser Signatur zurück:
```php
use Belevo\EnelixEMS\SymconTest\TestContext;
return static function (TestContext $test): void {
$test->runCase('Beschreibung', static function (TestContext $test): void {
// Instanz aufbauen, Eingang simulieren und Ergebnis prüfen.
});
};
```
Das Framework erzeugt für jeden Modultest eine eindeutige Kategorie unterhalb
der Objektwurzel. Instanzen und Hilfsobjekte werden ausschließlich dort
angelegt. Der Runner entfernt den vollständigen Baum in einem `finally`-Pfad.
Ein fehlgeschlagener Cleanup macht den Gesamtlauf rot.
Tests dürfen keine vorhandenen Objekte verändern oder anhand ihres Namens
löschen. Globale Variablenprofile müssen nur dann registriert und entfernt
werden, wenn sie vor dem Lauf nicht existierten.
## Modi
- `all`: alle registrierten Module
- `single`: genau die als Auswahl übergebenen Module
- `affected`: die durch geänderte Pfade ermittelten Module
Verfuegbare Module: `Batterie`, `EaseeGateway`, `LadestationGateway`,
`LadestationStandAlone`, `Manager`, `Pufferspeicher`, `VerbraucherEinStufig`,
`Waermepumpe` und `Warmwassererwaermer`.
Der Manager-Test enthält Manager ohne Verbraucher, jeden Verbrauchertyp einzeln und alle aktuell implementierten Verbrauchertypen gemeinsam.
## Manuelle Ausführung in IP-Symcon
Das Repository muss über die Modulverwaltung installiert und auf dem zu
prüfenden Stand sein. In der Schnellausführung:
```php
require_once IPS_GetKernelDir() . 'modules/Enelix-EMS/tests/Symcon/bootstrap.php';
$result = enelixEmsRunSymconTests('all');
echo $result['console'];
```
Ein Einzeltest wird beispielsweise mit
`enelixEmsRunSymconTests('single', 'Manager')` gestartet.
Auf dem Agent-Server kann derselbe Lauf über JSON-RPC ausgeführt werden:
```bash
tests/Symcon/bin/run-symcon-tests.sh all
tests/Symcon/bin/run-symcon-tests.sh single Manager
```
Die URL kann ausschließlich zur Laufzeit über `ENELIX_SYMCON_URL` gesetzt
werden. Zugangsdaten gehören nicht in Repository, Skripte oder Logs.
## Berichte
Jeder Lauf erzeugt:
- eine kurze Konsolenzusammenfassung,
- `build/symcon-tests/report.json` für Diagnose und Archivierung,
- `build/symcon-tests/junit.xml` für CI-Auswertung.
Zusätzlich schreibt der Runner die Zusammenfassung in das IP-Symcon-Log.
## CI-Regeln
Bei jedem Push laufen sämtliche PHPUnit-Tests und die PHP-Syntaxprüfung. Die
Symcon-Modultests werden bis zur Verfügbarkeit eines geschützten Runners auf
der isolierten IP-Symcon-8.0-Instanz des Agent-Servers ausgeführt. Vor jeder
Übernahme nach `beta` ist ein erfolgreicher Lauf im Modus `all` verbindlich.
Der erzeugte JSON- und JUnit-Bericht gehört zum Freigabenachweis.
Eine spätere CI-Automatisierung benötigt einen geschützten Runner mit dem Label
`symcon-8`, lokalem Zugriff auf die isolierte IP-Symcon-Instanz und den exakt
zu prüfenden Repository-Stand.
## Checkliste für neue Module
1. PHPUnit-Tests für die reine Logik ergänzen.
2. `tests/Symcon/modules/<Modul>.php` hinzufügen.
3. Modul und betroffene Pfade in `tests/Symcon/manifest.php` registrieren.
4. Instanz, Pflichtvariablen, Actions, Normalfall und mindestens einen
Fehler- oder Grenzfall prüfen.
5. Alle Hilfsobjekte über `TestContext` anlegen.
6. `composer check`, Einzeltest und Gesamttest erfolgreich ausführen.
+107
View File
@@ -0,0 +1,107 @@
# Enelix-2-Demoanlage
Die Demo erzeugt idempotent eine vollständige, spielbare EMS-Anlage in IP-Symcon 8.x:
- Enelix Manager mit manueller Zuordnung von fünf steuerbaren Teilnehmern,
- zwei mehrstufige Boiler (1,2/2,4 kW und 1,5/3,0 kW),
- einen einstufigen Entfeuchter (0,9 kW),
- einen zweistufigen Pufferspeicher (1,8/3,6 kW) mit Heizkurve,
- einen bidirektionalen Batteriespeicher (10 kWh, +/-3,5 kW),
- eine technische Anlagentopologie mit Hybridwechselrichter, PV-Fläche und
Batteriespeicher für die Prognoseanbindung,
- simulierte Istleistungs- und Leistungsbegrenzungsregister des Wechselrichters,
- eine aktive anlagenweite Einspeisebegrenzung auf 3.000 W mit 100 W Toleranz,
- Prognosetelemetrie aus PV, Hausverbrauch, Netzleistung und Batterie-SOC im
60-Sekunden-Intervall,
- simulierte PV-Erzeugung, variable Bewölkung, Tageslastgang, Zusatzlast,
Außentemperatur, Wärmeverluste, Netzleistung und Speicherzustände,
- native Symcon-Instanz `Energy Distribution` für acht Energieflussknoten,
- eigene Kachelansichten mit Laufzeitparametern für Boiler, Puffer und Batterie,
- kombinierten Verlauf für PV-Leistung, Hausverbrauch, EMS-Leistung und Netzleistung,
- kombinierten Verlauf der kumulierten PV-, Haus-, Bezugs- und Einspeiseenergie,
- Archivierung der Leistungs-, Energie- und Temperaturwerte.
## Installation
Das Repository muss in der IP-Symcon-Modulverwaltung auf dem gewünschten Stand installiert sein.
Danach in der Schnellausführung starten:
```php
require_once IPS_GetKernelDir() . 'modules/Enelix-EMS/examples/Demoanlage/bootstrap.php';
$result = enelixDemoInstall();
print_r($result);
```
Ein weiterer Lauf aktualisiert dieselbe Anlage anhand stabiler Idents und erzeugt keine Duplikate.
Bestehende, nicht zur Demo gehörende Objekte werden nicht verändert oder gelöscht.
Der Bootstrap hinterlegt absichtlich keinen Lizenzcode und überschreibt eine
bereits konfigurierte Manager-Lizenz nicht. Bei einer neuen Anlage wird die
Struktur vollständig erstellt; der Manager bleibt bis zur regulären
Lizenzaktivierung im Konfigurationsformular auf Status 203. Solange der Manager
nicht freigegeben ist, bleiben seine Zuordnung und die Verbraucher deaktiviert,
damit keine Lizenzfehler protokolliert werden. Nach der Aktivierung den
Bootstrap erneut ausführen; er ordnet dann alle fünf Teilnehmer zu und startet
die Regelung.
Die Demo übernimmt die einstellbare PV-Spitzenleistung als AC- und
DC-Anlagenleistung. Für die feste Beispieltopologie gelten 30 Grad Neigung und
Südausrichtung. Der 10-kWh-Speicher nutzt den gemeinsamen Hybridwechselrichter;
seine dynamischen Lade- und Entladegrenzen werden beim Bootstrap in die
technischen Stammdaten übernommen. Bei aktiver Manager-Lizenz uebertraegt der
Manager zusaetzlich PV-Leistung, Hausverbrauch, Netzleistung und Batterie-SOC
alle 60 Sekunden an den Enelix-Prognosedienst. Die Demo verwendet ein absolutes
Wechselrichter-Leistungsregister in Watt. Der Manager begrenzt die tatsächliche
PV-Produktion so, dass die Gesamtanlage höchstens 3.000 W einspeist; innerhalb
einer Toleranz von 100 W bleibt der zuletzt gesetzte Stellwert bestehen. Der
intelligente Netzfahrplan bleibt deaktiviert, solange dessen Backendfassung nur
als ausstehende Version bereitliegt.
## Bedienung
In der Kachelvisualisierung öffnet die Startkategorie `Enelix 2 Demoanlage`.
Unter `Simulation` lassen sich Tageslauf, Tageszeit, Geschwindigkeit,
dynamische Profile, PV-Spitzenleistung, mittlere Bewölkung, Haus-Grundlast und
eine ungeregelte Zusatzlast verändern. Bei aktivierten dynamischen Profilen
entstehen reproduzierbare Morgen- und Abendspitzen, ziehende Bewölkung und ein
Tagesgang der Außentemperatur. Der Manager verteilt den verfügbaren
PV-Überschuss nach Priorität auf beide Boiler, Entfeuchter, Pufferspeicher und
Batterie. Bei hoher PV-Erzeugung ist die Abregelung an den technischen Variablen
`PV-Wechselrichter Istleistung` und `PV-Wechselrichter Leistungsgrenze` sowie an
der auf etwa 3.000 W begrenzten Netzeinspeisung sichtbar.
`Übersicht` zeigt die native Symcon-Energieverteilung. Die Kacheln `Boiler 1`,
`Boiler 2`, `Pufferspeicher` und `Batteriespeicher` öffnen direkt die jeweiligen
Bedien-, Status- und Diagnosevariablen, ohne zuerst das
Instanz-Konfigurationsformular zu zeigen. Positive Batterieleistung bedeutet
Laden, negative Leistung Entladen. Unter `Leistung und Energie` liegen die
beiden kombinierten Zeitdiagramme und die Energiezähler. Die Energiesummen,
Temperaturmodelle und der Ladezustand verwenden die beschleunigte
Simulationszeit.
## Validierung
- Die fünf Verbraucher müssen Status 102 besitzen; der Manager nach Lizenzaktivierung ebenfalls (vorher erwarteter Status 203).
- Der Manager muss fünf Teilnehmer aus vier unterschiedlichen Modultypen melden.
- Bei ausreichendem PV-Überschuss müssen thermische Verbraucher schalten und die Batterie laden.
- Bei höherem verbleibendem PV-Überschuss muss das Wechselrichterregister die PV-Leistung so begrenzen, dass ungefähr 3.000 W ins Netz eingespeist werden.
- Wechselrichter-Istleistung und Leistungsgrenze müssen in der technischen Kategorie vorhanden und im Manager zugeordnet sein.
- Puffertemperatur, Außentemperatur und Batterie-Ladezustand müssen sich über den Tageslauf verändern.
- Netzleistung, PV-Leistung, Hausverbrauch, EMS-Leistung, Einzelverbräuche, Speicherleistung, Energiesummen und Temperaturen werden archiviert.
- Die native Energieverteilung muss acht Knoten enthalten.
- Beide Diagrammkacheln müssen mindestens drei beschriftete Zeitreihen anzeigen.
- Ein erneuter Installationslauf muss dieselbe Root-ID zurückgeben.
## Rollback
Nur die eindeutig gekennzeichnete Demo wird entfernt:
```php
require_once IPS_GetKernelDir() . 'modules/Enelix-EMS/examples/Demoanlage/bootstrap.php';
enelixDemoRemove(true);
```
Der boolesche Parameter ist eine bewusste Löschbestätigung. Fremde Objektbäume
und Systeminstanzen bleiben unberührt.
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"id": "{E0185158-0DA9-4D44-BAA7-B06D3D786276}", "id": "{E0185158-0DA9-4D44-BAA7-B06D3D786276}",
"name": "Enelix EMS", "name": "Enelix EMS",
"author": "Belevo AG", "author": "Enelix",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS", "url": "https://git.belevo.ch/ENELIX/Enelix-EMS",
"compatibility": { "compatibility": {
"version": "8.0" "version": "8.0"
}, },
"version": "0.1", "version": "0.1",
"build": 0, "build": 1,
"date": 0 "date": 1790662541
} }
+406
View File
@@ -0,0 +1,406 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
use JsonException;
final class Anlagentopologie
{
public const VERSION = '1.0';
/**
* @return array<string, mixed>
* @throws JsonException
*/
public static function ausJson(
string $wechselrichterJson,
string $pvFlaechenJson,
string $batterienJson,
string $installationID
): array {
$wechselrichter = self::dekodiereListe($wechselrichterJson, 'AnlagenWechselrichter');
$pvFlaechen = self::dekodiereListe($pvFlaechenJson, 'AnlagenPVFlaechen');
$batterien = self::dekodiereListe($batterienJson, 'AnlagenBatterien');
$inverters = [];
$inverterById = [];
foreach ($wechselrichter as $index => $eintrag) {
$pfad = 'Wechselrichter ' . ($index + 1);
$id = self::kennung($eintrag['ID'] ?? null, $pfad . ': ID');
if (isset($inverterById[$id])) {
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
}
$typ = self::auswahl(
$eintrag['Typ'] ?? null,
['pv', 'battery', 'hybrid'],
$pfad . ': Typ'
);
$acRatedKw = self::positiveZahl(
$eintrag['ACNennleistung_kW'] ?? null,
$pfad . ': AC-Nennleistung'
);
$inverter = [
'id' => $id,
'name' => self::name($eintrag['Name'] ?? null, $id),
'type' => $typ,
'acRatedKw' => $acRatedKw,
];
$inverters[] = $inverter;
$inverterById[$id] = $inverter;
}
$pvArrays = [];
$pvIds = [];
foreach ($pvFlaechen as $index => $eintrag) {
$pfad = 'PV-Flaeche ' . ($index + 1);
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $pvIds, $pfad);
$inverterId = self::kennung(
$eintrag['WechselrichterID'] ?? null,
$pfad . ': Wechselrichter-ID'
);
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
if (!in_array($inverter['type'], ['pv', 'hybrid'], true)) {
throw new InvalidArgumentException(
$pfad . ': benoetigt einen PV- oder Hybridwechselrichter.'
);
}
$moduleCount = self::optionalePositiveGanzzahl(
$eintrag['Modulanzahl'] ?? null,
$pfad . ': Modulanzahl'
);
$modulePowerWp = self::optionalePositiveZahl(
$eintrag['Modulleistung_Wp'] ?? null,
$pfad . ': Modulleistung'
);
if (($moduleCount === null) !== ($modulePowerWp === null)) {
throw new InvalidArgumentException(
$pfad . ': Modulanzahl und Modulleistung muessen gemeinsam gepflegt werden.'
);
}
$pvArrays[] = [
'id' => $id,
'name' => self::name($eintrag['Name'] ?? null, $id),
'dcKwp' => self::positiveZahl(
$eintrag['DCLeistung_kWp'] ?? null,
$pfad . ': DC-Leistung'
),
'tiltDeg' => self::zahlImBereich(
$eintrag['Neigung_Grad'] ?? null,
0.0,
90.0,
$pfad . ': Neigung'
),
'azimuthDeg' => self::zahlImBereich(
$eintrag['Azimut_Grad'] ?? null,
-180.0,
180.0,
$pfad . ': Azimut'
),
'inverterId' => $inverterId,
'mpptInput' => self::optionalerText($eintrag['MPPT'] ?? null),
'moduleCount' => $moduleCount,
'modulePowerWp' => $modulePowerWp,
];
}
$batteryItems = [];
$batteryIds = [];
foreach ($batterien as $index => $eintrag) {
$pfad = 'Batterie ' . ($index + 1);
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $batteryIds, $pfad);
$inverterId = self::kennung(
$eintrag['WechselrichterID'] ?? null,
$pfad . ': Wechselrichter-ID'
);
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
$kopplung = self::auswahl(
$eintrag['Kopplung'] ?? null,
['ac', 'dc', 'hybrid'],
$pfad . ': Kopplung'
);
if ($kopplung === 'ac' && !in_array($inverter['type'], ['battery', 'hybrid'], true)) {
throw new InvalidArgumentException(
$pfad . ': AC-Kopplung benoetigt einen Batterie- oder Hybridwechselrichter.'
);
}
if ($kopplung !== 'ac' && $inverter['type'] !== 'hybrid') {
throw new InvalidArgumentException(
$pfad . ': DC- und Hybridkopplung benoetigen einen Hybridwechselrichter.'
);
}
$nominalCapacityKwh = self::positiveZahl(
$eintrag['Nennkapazitaet_kWh'] ?? null,
$pfad . ': Nennkapazitaet'
);
$usableCapacityKwh = self::positiveZahl(
$eintrag['Nutzkapazitaet_kWh'] ?? null,
$pfad . ': Nutzkapazitaet'
);
if ($usableCapacityKwh > $nominalCapacityKwh) {
throw new InvalidArgumentException(
$pfad . ': Nutzkapazitaet darf die Nennkapazitaet nicht uebersteigen.'
);
}
$batteryItems[] = [
'id' => $id,
'name' => self::name($eintrag['Name'] ?? null, $id),
'nominalCapacityKwh' => $nominalCapacityKwh,
'usableCapacityKwh' => $usableCapacityKwh,
'maxChargeKw' => self::positiveZahl(
$eintrag['MaxLadeleistung_kW'] ?? null,
$pfad . ': maximale Ladeleistung'
),
'maxDischargeKw' => self::positiveZahl(
$eintrag['MaxEntladeleistung_kW'] ?? null,
$pfad . ': maximale Entladeleistung'
),
'inverterId' => $inverterId,
'coupling' => $kopplung,
];
}
return [
'version' => self::VERSION,
'installationId' => trim($installationID),
'inverters' => $inverters,
'pvArrays' => $pvArrays,
'batteries' => $batteryItems,
'sharedAcLimits' => self::gemeinsameAcGrenzen(
$inverters,
$pvArrays,
$batteryItems
),
'summary' => self::zusammenfassung($inverters, $pvArrays, $batteryItems),
];
}
/** @return list<array<string, mixed>> */
private static function dekodiereListe(string $json, string $name): array
{
$daten = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)
|| ($daten !== [] && array_keys($daten) !== range(0, count($daten) - 1))
) {
throw new InvalidArgumentException($name . ' muss eine JSON-Liste sein.');
}
foreach ($daten as $eintrag) {
if (!is_array($eintrag)) {
throw new InvalidArgumentException($name . ' enthaelt einen ungueltigen Eintrag.');
}
}
return $daten;
}
/** @param array<string, bool> $verwendet */
private static function eindeutigeKennung($wert, array &$verwendet, string $pfad): string
{
$id = self::kennung($wert, $pfad . ': ID');
if (isset($verwendet[$id])) {
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
}
$verwendet[$id] = true;
return $id;
}
private static function kennung($wert, string $feld): string
{
if (!is_string($wert)
|| preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', trim($wert)) !== 1
) {
throw new InvalidArgumentException(
$feld . ' muss 1 bis 64 Zeichen aus Buchstaben, Zahlen, Punkt, Minus oder Unterstrich enthalten.'
);
}
return trim($wert);
}
private static function name($wert, string $fallback): string
{
if ($wert === null || $wert === '') {
return $fallback;
}
if (!is_string($wert) || strlen(trim($wert)) > 120) {
throw new InvalidArgumentException('Name muss ein Text mit maximal 120 Zeichen sein.');
}
return trim($wert);
}
/** @param list<string> $werte */
private static function auswahl($wert, array $werte, string $feld): string
{
if (!is_string($wert) || !in_array($wert, $werte, true)) {
throw new InvalidArgumentException($feld . ' ist ungueltig.');
}
return $wert;
}
private static function positiveZahl($wert, string $feld): float
{
if (!is_int($wert) && !is_float($wert)) {
throw new InvalidArgumentException($feld . ' muss eine Zahl sein.');
}
if (!is_finite((float) $wert) || (float) $wert <= 0.0) {
throw new InvalidArgumentException($feld . ' muss groesser als 0 sein.');
}
return (float) $wert;
}
private static function optionalePositiveZahl($wert, string $feld): ?float
{
if ($wert === null || $wert === '' || $wert === 0 || $wert === 0.0) {
return null;
}
return self::positiveZahl($wert, $feld);
}
private static function optionalePositiveGanzzahl($wert, string $feld): ?int
{
if ($wert === null || $wert === '' || $wert === 0) {
return null;
}
if (!is_int($wert) || $wert <= 0) {
throw new InvalidArgumentException($feld . ' muss eine positive Ganzzahl sein.');
}
return $wert;
}
private static function zahlImBereich(
$wert,
float $minimum,
float $maximum,
string $feld
): float {
if ((!is_int($wert) && !is_float($wert))
|| !is_finite((float) $wert)
|| (float) $wert < $minimum
|| (float) $wert > $maximum
) {
throw new InvalidArgumentException(
sprintf('%s muss zwischen %g und %g liegen.', $feld, $minimum, $maximum)
);
}
return (float) $wert;
}
private static function optionalerText($wert): ?string
{
if ($wert === null || $wert === '') {
return null;
}
if (!is_string($wert) || strlen(trim($wert)) > 64) {
throw new InvalidArgumentException('MPPT muss ein Text mit maximal 64 Zeichen sein.');
}
return trim($wert);
}
/**
* @param array<string, array<string, mixed>> $wechselrichter
* @return array<string, mixed>
*/
private static function wechselrichter(array $wechselrichter, string $id, string $pfad): array
{
if (!isset($wechselrichter[$id])) {
throw new InvalidArgumentException(
$pfad . ': referenzierter Wechselrichter ist nicht vorhanden.'
);
}
return $wechselrichter[$id];
}
/**
* @param list<array<string, mixed>> $inverters
* @param list<array<string, mixed>> $pvArrays
* @param list<array<string, mixed>> $batteries
* @return list<array<string, mixed>>
*/
private static function gemeinsameAcGrenzen(
array $inverters,
array $pvArrays,
array $batteries
): array {
$result = [];
foreach ($inverters as $inverter) {
if ($inverter['type'] !== 'hybrid') {
continue;
}
$pvIds = [];
foreach ($pvArrays as $pvArray) {
if ($pvArray['inverterId'] === $inverter['id']) {
$pvIds[] = $pvArray['id'];
}
}
$batteryIds = [];
foreach ($batteries as $battery) {
if ($battery['inverterId'] === $inverter['id']) {
$batteryIds[] = $battery['id'];
}
}
if ($pvIds === [] && $batteryIds === []) {
continue;
}
$result[] = [
'inverterId' => $inverter['id'],
'acRatedKw' => $inverter['acRatedKw'],
'pvArrayIds' => $pvIds,
'batteryIds' => $batteryIds,
];
}
return $result;
}
/**
* @param list<array<string, mixed>> $inverters
* @param list<array<string, mixed>> $pvArrays
* @param list<array<string, mixed>> $batteries
* @return array<string, float>
*/
private static function zusammenfassung(
array $inverters,
array $pvArrays,
array $batteries
): array {
$pvInverterIds = [];
$pvDcKwp = 0.0;
foreach ($pvArrays as $pvArray) {
$pvDcKwp += $pvArray['dcKwp'];
$pvInverterIds[$pvArray['inverterId']] = true;
}
$pvAcKw = 0.0;
foreach ($inverters as $inverter) {
if (isset($pvInverterIds[$inverter['id']])) {
$pvAcKw += $inverter['acRatedKw'];
}
}
$usableBatteryKwh = 0.0;
$maxBatteryChargeKw = 0.0;
$maxBatteryDischargeKw = 0.0;
foreach ($batteries as $battery) {
$usableBatteryKwh += $battery['usableCapacityKwh'];
$maxBatteryChargeKw += $battery['maxChargeKw'];
$maxBatteryDischargeKw += $battery['maxDischargeKw'];
}
return [
'pvDcKwp' => $pvDcKwp,
'pvAcKw' => $pvAcKw,
'usableBatteryKwh' => $usableBatteryKwh,
'maxBatteryChargeKw' => $maxBatteryChargeKw,
'maxBatteryDischargeKw' => $maxBatteryDischargeKw,
];
}
}
+254
View File
@@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class BatterieRegler
{
public const TYP_UNKONFIGURIERT = 0;
public const TYP_HERSTELLERUNABHAENGIG = 1;
public const TYP_GOODWE = 2;
public const TYP_SOLAREDGE = 3;
public const TYP_SIGENERGY = 4;
public const STEUERUNG_WECHSELRICHTER = 1;
public const STEUERUNG_ENELIX = 2;
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 als
* durchgaengige Leistungsbereiche in ganzen Watt ab.
*
* @return list<int|array{Von_W: int, Bis_W: int}>
*/
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];
}
$maxLaden = (int) floor($maxLadenW);
$maxEntladen = (int) floor($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::leistungsbereich(-$maxEntladen, 0);
}
if ($ladezustand > $minimum) {
return self::leistungsbereich(-$maxEntladen, $maxLaden);
}
return self::leistungsbereich(0, $maxLaden);
}
if ($ladezustand > 99.0) {
return self::leistungsbereich(-$maxEntladen, 0);
}
if ($ladezustand > $reserve && !$hystereseAktiv) {
return self::leistungsbereich(-$maxEntladen, $maxLaden);
}
if ($ladezustand >= $reserve && $hystereseAktiv) {
return self::leistungsbereich(0, $maxLaden);
}
return [$maxLaden];
}
/**
* Liefert nur die Registerwerte, die der gewaehlte Batterietyp benoetigt.
*
* @return array<string, int|float>
*/
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<string> */
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;
}
/** @return list<int|array{Von_W: int, Bis_W: int}> */
private static function leistungsbereich(int $vonW, int $bisW): array
{
if ($vonW === $bisW) {
return [$vonW];
}
return [['Von_W' => $vonW, 'Bis_W' => $bisW]];
}
private static function begrenzeLeistung(
float $leistungW,
float $maxLadenW,
float $maxEntladenW
): int {
$maxLaden = (int) floor($maxLadenW);
$maxEntladen = (int) floor($maxEntladenW);
return (int) round(min($maxLaden, max(-$maxEntladen, $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.');
}
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
final class EaseeGatewayProtokoll
{
/** @var list<int> */
public const BEOBACHTUNGEN = [
47, 48, 100, 104, 109, 110, 119, 120, 182, 183, 184, 185, 250,
];
public static function seriennummer(string $wert): string
{
return strtoupper(trim($wert));
}
/**
* @param mixed $knoten
* @param list<string> $abonnements
* @return list<array{Seriennummer: string, ID: int, Wert: mixed}>
*/
public static function extrahiereBeobachtungen(
$knoten,
array $abonnements,
string $seriennummer = '',
int $tiefe = 0
): array {
if (!is_array($knoten) || $tiefe > 12) {
return [];
}
$abonnements = array_values(array_unique(array_map(
[self::class, 'seriennummer'],
$abonnements
)));
foreach (['serialNumber', 'SerialNumber', 'chargerId', 'ChargerId', 'mid', 'Mid'] as $feld) {
if (!isset($knoten[$feld]) || !is_scalar($knoten[$feld])) {
continue;
}
$kandidat = self::seriennummer((string) $knoten[$feld]);
if (in_array($kandidat, $abonnements, true)) {
$seriennummer = $kandidat;
break;
}
}
if (self::istListe($knoten)) {
foreach ($knoten as $wert) {
if (!is_string($wert)) {
continue;
}
$kandidat = self::seriennummer($wert);
if (in_array($kandidat, $abonnements, true)) {
$seriennummer = $kandidat;
}
}
}
$id = null;
foreach (['id', 'Id', 'observationId', 'ObservationId'] as $feld) {
if (array_key_exists($feld, $knoten) && is_numeric($knoten[$feld])) {
$id = (int) $knoten[$feld];
break;
}
}
$wertVorhanden = false;
$wert = null;
foreach (['value', 'Value'] as $feld) {
if (array_key_exists($feld, $knoten)) {
$wertVorhanden = true;
$wert = $knoten[$feld];
break;
}
}
$ergebnis = [];
if (
$seriennummer !== ''
&& $wertVorhanden
&& in_array($id, self::BEOBACHTUNGEN, true)
) {
$ergebnis[] = [
'Seriennummer' => $seriennummer,
'ID' => $id,
'Wert' => $wert,
];
}
foreach ($knoten as $kind) {
if (is_array($kind)) {
$ergebnis = array_merge(
$ergebnis,
self::extrahiereBeobachtungen(
$kind,
$abonnements,
$seriennummer,
$tiefe + 1
)
);
}
}
return $ergebnis;
}
/** @param array<mixed> $wert */
private static function istListe(array $wert): bool
{
$index = 0;
foreach ($wert as $schluessel => $_) {
if ($schluessel !== $index++) {
return false;
}
}
return true;
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class EaseeLadestatus
{
public const BETRIEBSMODUS_EASEE = 0;
public const BETRIEBSMODUS_NUR_SOLAR = 1;
/**
* @param array<string, mixed> $beobachtungen
* @return array{
* StatusGueltig: bool,
* FahrzeugVerbunden: bool,
* FahrzeugGeladen: bool,
* Fahrzeugstatus: int,
* Phasenzahl: int,
* Istleistung_W: float,
* Ladestrom_A: float,
* ApiMaximalstrom_A: int,
* Stoerung: bool,
* Stoertext: string
* }
*/
public static function ausBeobachtungen(array $beobachtungen, int $konfigurierterMaximalstrom): array
{
if ($konfigurierterMaximalstrom < 6 || $konfigurierterMaximalstrom > 32) {
throw new InvalidArgumentException(
'MaximalerLadestrom muss zwischen 6 und 32 A liegen.'
);
}
$rohBetriebszustand = $beobachtungen['109'] ?? null;
$statusGueltig = is_numeric($rohBetriebszustand)
&& floor((float) $rohBetriebszustand) === (float) $rohBetriebszustand
&& in_array((int) $rohBetriebszustand, [0, 1, 2, 3, 4, 5, 6, 7, 8], true);
$betriebszustand = $statusGueltig ? (int) $rohBetriebszustand : 0;
$verbunden = $statusGueltig
&& in_array($betriebszustand, [2, 3, 4, 5, 6, 7, 8], true);
$geladen = $betriebszustand === 4;
$fahrzeugstatus = self::fahrzeugstatus($betriebszustand);
$phasenzahl = $verbunden
? self::phasenzahl((int) ($beobachtungen['110'] ?? 0))
: 0;
$leistung = max(0.0, (float) ($beobachtungen['120'] ?? 0.0) * 1000.0);
$stroeme = [];
foreach ([182, 183, 184, 185] as $id) {
if (array_key_exists((string) $id, $beobachtungen)) {
$stroeme[] = abs((float) $beobachtungen[(string) $id]);
}
}
$ladestrom = $stroeme === [] ? 0.0 : max($stroeme);
$grenzen = [$konfigurierterMaximalstrom];
foreach ([47, 104] as $id) {
$grenze = (float) ($beobachtungen[(string) $id] ?? 0.0);
if ($grenze >= 6.0) {
$grenzen[] = (int) floor($grenze);
}
}
$apiMaximalstrom = max(6, min($grenzen));
$fehlercode = (int) ($beobachtungen['119'] ?? 0);
$stoerung = $betriebszustand === 5 || $fehlercode !== 0;
$stoertext = '';
if ($betriebszustand === 5) {
$stoertext = 'Easee meldet einen Fehlerzustand.';
}
if ($fehlercode !== 0) {
$stoertext .= ($stoertext === '' ? '' : ' ') . 'Easee-Fehlercode: ' . $fehlercode . '.';
}
return [
'StatusGueltig' => $statusGueltig,
'FahrzeugVerbunden' => $verbunden,
'FahrzeugGeladen' => $geladen,
'Fahrzeugstatus' => $fahrzeugstatus,
'Phasenzahl' => $phasenzahl,
'Istleistung_W' => $leistung,
'Ladestrom_A' => $ladestrom,
'ApiMaximalstrom_A' => $apiMaximalstrom,
'Stoerung' => $stoerung,
'Stoertext' => trim($stoertext),
];
}
public static function solarladenErzwungen(int $betriebsmodus): bool
{
if (!in_array($betriebsmodus, [
self::BETRIEBSMODUS_EASEE,
self::BETRIEBSMODUS_NUR_SOLAR,
], true)) {
throw new InvalidArgumentException('Unbekannter Easee-Betriebsmodus.');
}
return $betriebsmodus === self::BETRIEBSMODUS_NUR_SOLAR;
}
private static function fahrzeugstatus(int $betriebszustand): int
{
if ($betriebszustand === 1) {
return 1;
}
if (in_array($betriebszustand, [2, 6, 7, 8], true)) {
return 2;
}
if ($betriebszustand === 3) {
return 3;
}
if ($betriebszustand === 4) {
return 4;
}
if ($betriebszustand === 5) {
return 5;
}
return 0;
}
private static function phasenzahl(int $ausgangsphase): int
{
if ($ausgangsphase >= 10 && $ausgangsphase <= 15) {
return 1;
}
if ($ausgangsphase === 30) {
return 3;
}
return 0;
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
final class EinStufigRegler
{
/**
* @return list<int>
*/
public static function leistungsangebot(
int $nennleistung,
int $istleistung,
bool $verfuegbar,
bool $aenderungMoeglich,
bool $mindestlaufzeitErzwungen,
bool $peakbetrieb,
bool $peakSperreBeiMindestlaufzeitAnbieten = true
): array {
if ($nennleistung <= 0) {
throw new InvalidArgumentException('Die Nennleistung muss groesser als 0 sein.');
}
if (!in_array($istleistung, [0, $nennleistung], true)) {
throw new InvalidArgumentException('Die Istleistung muss 0 oder der Nennleistung entsprechen.');
}
if (!$verfuegbar || !$aenderungMoeglich) {
return [$istleistung];
}
if ($mindestlaufzeitErzwungen) {
return $peakbetrieb && $peakSperreBeiMindestlaufzeitAnbieten
? [0, $nennleistung]
: [$nennleistung];
}
if ($peakbetrieb) {
return [0];
}
return [0, $nennleistung];
}
public static function freigabezeitpunkt(
bool $eingeschaltet,
int $bestaetigtSeit,
int $mindesteinschaltdauer,
int $mindestausschaltdauer
): int {
if ($bestaetigtSeit < 0 || $mindesteinschaltdauer < 0 || $mindestausschaltdauer < 0) {
throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.');
}
return $bestaetigtSeit + ($eingeschaltet ? $mindesteinschaltdauer : $mindestausschaltdauer);
}
public static function restMindestzeit(int $freigabezeitpunkt, int $jetzt): int
{
if ($freigabezeitpunkt < 0 || $jetzt < 0) {
throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.');
}
return max(0, $freigabezeitpunkt - $jetzt);
}
public static function mussMindestlaufzeitErzwingen(
int $tageslaufzeit,
int $mindestlaufzeit,
int $sekundenBisTagesende
): bool {
self::pruefeLaufzeiten($tageslaufzeit, $mindestlaufzeit, $sekundenBisTagesende);
$restlaufzeit = max(0, $mindestlaufzeit - $tageslaufzeit);
return $restlaufzeit > 0 && $sekundenBisTagesende <= $restlaufzeit;
}
public static function sekundenBisNaechsteTagespruefung(
int $tageslaufzeit,
int $mindestlaufzeit,
int $sekundenBisTagesende,
bool $eingeschaltet
): int {
self::pruefeLaufzeiten($tageslaufzeit, $mindestlaufzeit, $sekundenBisTagesende);
$restlaufzeit = max(0, $mindestlaufzeit - $tageslaufzeit);
if ($restlaufzeit === 0) {
return max(1, $sekundenBisTagesende);
}
if ($eingeschaltet) {
return max(1, min($restlaufzeit, $sekundenBisTagesende));
}
return max(1, $sekundenBisTagesende - $restlaufzeit);
}
/**
* @return array{Tag: string, Sekunden: int, StandZeit: int}
*/
public static function aktualisiereTageslaufzeit(
string $gespeicherterTag,
int $gespeicherteSekunden,
int $standZeit,
int $jetzt,
bool $warEingeschaltet,
DateTimeZone $zeitzone
): array {
if ($gespeicherteSekunden < 0 || $standZeit < 0 || $jetzt < 0) {
throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.');
}
$jetztDatum = self::datum($jetzt, $zeitzone);
$aktuellerTag = $jetztDatum->format('Y-m-d');
if ($standZeit <= 0 || $standZeit > $jetzt) {
return [
'Tag' => $aktuellerTag,
'Sekunden' => $gespeicherterTag === $aktuellerTag ? $gespeicherteSekunden : 0,
'StandZeit' => $jetzt,
];
}
$standDatum = self::datum($standZeit, $zeitzone);
$sekunden = $gespeicherteSekunden;
if ($gespeicherterTag !== $aktuellerTag || $standDatum->format('Y-m-d') !== $aktuellerTag) {
$sekunden = 0;
if ($warEingeschaltet) {
$tagesbeginn = $jetztDatum->setTime(0, 0)->getTimestamp();
$sekunden = max(0, $jetzt - max($standZeit, $tagesbeginn));
}
} elseif ($warEingeschaltet) {
$sekunden += $jetzt - $standZeit;
}
$tagesbeginn = $jetztDatum->setTime(0, 0);
$tagesdauer = $tagesbeginn->modify('+1 day')->getTimestamp() - $tagesbeginn->getTimestamp();
return [
'Tag' => $aktuellerTag,
'Sekunden' => min($sekunden, $tagesdauer),
'StandZeit' => $jetzt,
];
}
public static function sekundenBisTagesende(int $jetzt, DateTimeZone $zeitzone): int
{
if ($jetzt < 0) {
throw new InvalidArgumentException('Der Zeitpunkt darf nicht negativ sein.');
}
$datum = self::datum($jetzt, $zeitzone);
$tagesende = $datum->setTime(0, 0)->modify('+1 day');
return max(1, $tagesende->getTimestamp() - $jetzt);
}
private static function pruefeLaufzeiten(
int $tageslaufzeit,
int $mindestlaufzeit,
int $sekundenBisTagesende
): void {
if ($tageslaufzeit < 0 || $mindestlaufzeit < 0 || $mindestlaufzeit > 86400) {
throw new InvalidArgumentException('Laufzeiten sind ausserhalb des gueltigen Bereichs.');
}
if ($sekundenBisTagesende <= 0) {
throw new InvalidArgumentException('Sekunden bis Tagesende muss groesser als 0 sein.');
}
}
private static function datum(int $zeitpunkt, DateTimeZone $zeitzone): DateTimeImmutable
{
return (new DateTimeImmutable('@' . $zeitpunkt))->setTimezone($zeitzone);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
/**
* Verteilt eine gemeinsame Einspeisegrenze auf regelbare PV-Wechselrichter.
*
* Netzleistung: positiv Bezug, negativ Einspeisung.
*/
final class EinspeiseRegler
{
/**
* @param list<array{ID: string, Nennleistung_W: float|int, Istleistung_W: float|int}> $wechselrichter
* @param array<string, float|int> $letzteGrenzen
* @return array{
* Netzabweichung_W: float,
* ZielGesamtleistung_W: float,
* Abregelbedarf_W: float,
* Grenzen_W: array<string, float>
* }
*/
public static function verteile(
float $netzleistung,
float $einspeisegrenze,
array $wechselrichter,
array $letzteGrenzen = []
): array {
if ($einspeisegrenze < 0.0) {
throw new InvalidArgumentException('Einspeisegrenze darf nicht negativ sein.');
}
if ($wechselrichter === []) {
throw new InvalidArgumentException('Mindestens ein regelbarer Wechselrichter ist erforderlich.');
}
$nennleistungGesamt = 0.0;
$istleistungGesamt = 0.0;
$bisherigeGrenzeGesamt = 0.0;
$normalisiert = [];
$kennungen = [];
foreach ($wechselrichter as $eintrag) {
$id = $eintrag['ID'] ?? null;
$nennleistung = $eintrag['Nennleistung_W'] ?? null;
$istleistung = $eintrag['Istleistung_W'] ?? null;
if (!is_string($id) || trim($id) === '' || isset($kennungen[$id])) {
throw new InvalidArgumentException('Wechselrichter-ID fehlt oder ist mehrfach vorhanden.');
}
if (!is_numeric($nennleistung) || (float) $nennleistung <= 0.0) {
throw new InvalidArgumentException('Wechselrichter-Nennleistung muss groesser als 0 sein.');
}
if (!is_numeric($istleistung) || (float) $istleistung < 0.0) {
throw new InvalidArgumentException('Wechselrichter-Istleistung darf nicht negativ sein.');
}
$nennleistung = (float) $nennleistung;
$istleistung = min((float) $istleistung, $nennleistung);
$bisherigeGrenze = $letzteGrenzen[$id] ?? $nennleistung;
if (!is_numeric($bisherigeGrenze)) {
throw new InvalidArgumentException('Gespeicherte Wechselrichtergrenze ist ungueltig.');
}
$bisherigeGrenze = max(0.0, min((float) $bisherigeGrenze, $nennleistung));
$normalisiert[] = [
'ID' => $id,
'Nennleistung_W' => $nennleistung,
'Istleistung_W' => $istleistung,
];
$kennungen[$id] = true;
$nennleistungGesamt += $nennleistung;
$istleistungGesamt += $istleistung;
$bisherigeGrenzeGesamt += $bisherigeGrenze;
}
$netzabweichung = $netzleistung + $einspeisegrenze;
$zielGesamt = $bisherigeGrenzeGesamt;
$nachIstleistungVerteilen = false;
if ($netzabweichung < 0.0) {
$nachIstleistungVerteilen = $istleistungGesamt > 0.0;
$zielGesamt = min($bisherigeGrenzeGesamt, $istleistungGesamt) + $netzabweichung;
} elseif ($netzabweichung > 0.0) {
$zielGesamt = $bisherigeGrenzeGesamt + $netzabweichung;
}
$zielGesamt = max(0.0, min($zielGesamt, $nennleistungGesamt));
$grenzen = [];
foreach ($normalisiert as $eintrag) {
$anteil = $nachIstleistungVerteilen
? $eintrag['Istleistung_W'] / $istleistungGesamt
: $eintrag['Nennleistung_W'] / $nennleistungGesamt;
$grenzen[$eintrag['ID']] = round(
min($eintrag['Nennleistung_W'], $zielGesamt * $anteil),
3
);
}
return [
'Netzabweichung_W' => $netzabweichung,
'ZielGesamtleistung_W' => round($zielGesamt, 3),
'Abregelbedarf_W' => round(max(0.0, $istleistungGesamt - $zielGesamt), 3),
'Grenzen_W' => $grenzen,
];
}
public static function stellwert(float $grenzeW, float $nennleistungW, string $art): float
{
if ($nennleistungW <= 0.0 || $grenzeW < 0.0) {
throw new InvalidArgumentException('Leistungsgrenzen sind ungueltig.');
}
$grenzeW = min($grenzeW, $nennleistungW);
if ($art === 'percent') {
return round($grenzeW / $nennleistungW * 100.0, 2);
}
if ($art === 'watt') {
return round($grenzeW, 1);
}
throw new InvalidArgumentException('Begrenzungsart muss percent oder watt sein.');
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
final class EnergieMessung
{
private const WATTSEKUNDEN_PRO_KWH = 3600000.0;
public static function hausleistung(float $pv, float $netz, float $batterie): float
{
return max(
0.0,
self::endlich($pv) + self::endlich($netz) - self::endlich($batterie)
);
}
public static function hausenergie(
float $pv,
float $netzbezug,
float $netzeinspeisung,
float $batterieLaden,
float $batterieEntladen
): float {
return max(
0.0,
self::endlich($pv)
+ self::endlich($netzbezug)
+ self::endlich($batterieEntladen)
- self::endlich($netzeinspeisung)
- self::endlich($batterieLaden)
);
}
/**
* @param array{PV: float, Haus: float, Netz: float, Batterie: float} $leistungen
* @return array{
* PVEnergie: float,
* Hausenergie: float,
* NetzbezugEnergie: float,
* EinspeisungEnergie: float,
* BatterieLadenEnergie: float,
* BatterieEntladenEnergie: float
* }
*/
public static function energieDeltas(array $leistungen, int $sekunden): array
{
$faktor = max(0, $sekunden) / self::WATTSEKUNDEN_PRO_KWH;
$pv = self::endlich($leistungen['PV']);
$haus = self::endlich($leistungen['Haus']);
$netz = self::endlich($leistungen['Netz']);
$batterie = self::endlich($leistungen['Batterie']);
return [
'PVEnergie' => max(0.0, $pv) * $faktor,
'Hausenergie' => max(0.0, $haus) * $faktor,
'NetzbezugEnergie' => max(0.0, $netz) * $faktor,
'EinspeisungEnergie' => max(0.0, -$netz) * $faktor,
'BatterieLadenEnergie' => max(0.0, $batterie) * $faktor,
'BatterieEntladenEnergie' => max(0.0, -$batterie) * $faktor,
];
}
private static function endlich(float $wert): float
{
return is_finite($wert) ? $wert : 0.0;
}
}
+208
View File
@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
use RuntimeException;
final class LadestationAdapter
{
public const GO_E_ALT = 1;
public const GO_E_GEMINI = 2;
public const SMART_ME_PICO = 3;
/**
* @param array<string, string> $konfiguration
* @return array<string, mixed>
*/
public static function statusAnfrage(int $geraetetyp, array $konfiguration): array
{
if ($geraetetyp === self::GO_E_ALT) {
return self::anfrage('GET', self::lokaleBasisUrl($konfiguration) . '/mqtt?payload=');
}
if ($geraetetyp === self::GO_E_GEMINI) {
return self::anfrage('GET', self::lokaleBasisUrl($konfiguration) . '/api/status');
}
if ($geraetetyp === self::SMART_ME_PICO) {
$id = self::pflichtwert($konfiguration, 'GeraeteID');
return self::anfrage(
'GET',
'https://api.smart-me.com/pico/charging/' . rawurlencode($id),
$konfiguration
);
}
throw new InvalidArgumentException('Unbekannter Geraetetyp.');
}
/**
* @param array<string, string> $konfiguration
* @return list<array<string, mixed>>
*/
public static function steuerAnfragen(
int $geraetetyp,
array $konfiguration,
int $ladestrom
): array {
if ($ladestrom < 0 || $ladestrom > 32 || ($ladestrom > 0 && $ladestrom < 6)) {
throw new InvalidArgumentException('Ladestrom muss 0 oder 6 bis 32 A betragen.');
}
if ($geraetetyp === self::GO_E_ALT) {
$basis = self::lokaleBasisUrl($konfiguration) . '/mqtt?payload=';
if ($ladestrom === 0) {
return [self::anfrage('GET', $basis . 'alw=0')];
}
return [
self::anfrage('GET', $basis . 'alw=1'),
self::anfrage('GET', $basis . 'amp=' . $ladestrom),
];
}
if ($geraetetyp === self::GO_E_GEMINI) {
$basis = self::lokaleBasisUrl($konfiguration) . '/api/set?';
if ($ladestrom === 0) {
return [self::anfrage('GET', $basis . 'frc=1&sua=false')];
}
return [
self::anfrage('GET', $basis . 'frc=2'),
self::anfrage('GET', $basis . 'amp=' . $ladestrom),
];
}
if ($geraetetyp === self::SMART_ME_PICO) {
$seriennummer = self::pflichtwert($konfiguration, 'Seriennummer');
return [self::anfrage(
'POST',
'https://api.smart-me.com/pico/loadmanagementgroup/current/'
. rawurlencode($seriennummer)
. '?current=' . ($ladestrom * 1000),
$konfiguration
)];
}
throw new InvalidArgumentException('Unbekannter Geraetetyp.');
}
/**
* @return array{Rohstatus: int, Istleistung_W: float}
*/
public static function dekodiereStatus(int $geraetetyp, string $antwort): array
{
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new RuntimeException('Die Geraeteantwort ist kein JSON-Objekt.');
}
if ($geraetetyp === self::GO_E_ALT || $geraetetyp === self::GO_E_GEMINI) {
if (!isset($daten['car'], $daten['nrg'])
|| !is_numeric($daten['car'])
|| !is_array($daten['nrg'])
|| !isset($daten['nrg'][11])
|| !is_numeric($daten['nrg'][11])
) {
throw new RuntimeException('Die go-e-Antwort enthaelt nicht alle Statusfelder.');
}
$faktor = $geraetetyp === self::GO_E_ALT ? 10.0 : 1.0;
return [
'Rohstatus' => (int) $daten['car'],
'Istleistung_W' => (float) $daten['nrg'][11] * $faktor,
];
}
if ($geraetetyp === self::SMART_ME_PICO) {
if (!isset($daten['State'], $daten['ActiveChargingPower'])
|| !is_numeric($daten['State'])
|| !is_numeric($daten['ActiveChargingPower'])
) {
throw new RuntimeException('Die Pico-Antwort enthaelt nicht alle Statusfelder.');
}
return [
'Rohstatus' => (int) $daten['State'],
'Istleistung_W' => round((float) $daten['ActiveChargingPower'] * 1000.0),
];
}
throw new InvalidArgumentException('Unbekannter Geraetetyp.');
}
/**
* @param array<string, string> $konfiguration
* @return array<string, mixed>
*/
private static function anfrage(
string $methode,
string $url,
array $konfiguration = []
): array {
$anfrage = ['Methode' => $methode, 'URL' => $url];
if (str_starts_with($url, 'https://api.smart-me.com/')) {
$anfrage['Benutzername'] = self::pflichtwert($konfiguration, 'Benutzername');
$anfrage['Passwort'] = self::pflichtwert($konfiguration, 'Passwort');
}
return $anfrage;
}
/** @param array<string, string> $konfiguration */
private static function lokaleBasisUrl(array $konfiguration): string
{
$adresse = trim(self::pflichtwert($konfiguration, 'Geraeteadresse'));
if (str_contains($adresse, '/') || str_contains($adresse, '?')) {
throw new InvalidArgumentException('Geraeteadresse muss ein Hostname oder eine IP-Adresse sein.');
}
return 'http://' . $adresse;
}
/** @param array<string, string> $konfiguration */
private static function pflichtwert(array $konfiguration, string $name): string
{
$wert = trim($konfiguration[$name] ?? '');
if ($wert === '') {
throw new InvalidArgumentException($name . ' darf nicht leer sein.');
}
return $wert;
}
}
final class LadestationTransport
{
/** @var callable(array<string, mixed>): string */
private $anfrageAusfuehren;
/** @param callable(array<string, mixed>): string $anfrageAusfuehren */
public function __construct(callable $anfrageAusfuehren)
{
$this->anfrageAusfuehren = $anfrageAusfuehren;
}
/** @param array<string, string> $konfiguration */
public function statusLesen(int $geraetetyp, array $konfiguration): string
{
return ($this->anfrageAusfuehren)(
LadestationAdapter::statusAnfrage($geraetetyp, $konfiguration)
);
}
/** @param array<string, string> $konfiguration */
public function ladestromSetzen(
int $geraetetyp,
array $konfiguration,
int $ladestrom
): void {
foreach (LadestationAdapter::steuerAnfragen($geraetetyp, $konfiguration, $ladestrom) as $anfrage) {
($this->anfrageAusfuehren)($anfrage);
}
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class LadestationRegler
{
public const PHASEN_UNBEKANNT = 0;
public const PHASEN_EIN = 1;
public const PHASEN_DREI = 3;
/** @var list<int> */
private const LEISTUNGEN_EINPHASIG = [
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
* aus der Enelix-1-Ladestation und bleiben bewusst kompatibel.
*
* @param array{Rohstatus: int, Istleistung_W: float} $rohstatus
* @return array<string, int|float|bool>
*/
public static function normalisiereStatus(
array $rohstatus,
bool $warVerbunden,
bool $warGeladen = false,
bool $ladungErwartet = true
): array {
$verbunden = $rohstatus['Rohstatus'] !== 1;
$leistung = max(0.0, $rohstatus['Istleistung_W']);
$phasen = self::PHASEN_UNBEKANNT;
$ladestrom = 0.0;
$geladen = false;
if ($verbunden) {
$phasen = $leistung > 7500.0 ? self::PHASEN_DREI : self::PHASEN_EIN;
$nenner = $phasen === self::PHASEN_EIN ? 230.0 : 1.71 * 400.0;
$ladestrom = $leistung / $nenner;
$ermittelterMaximalstrom = 2.5 + $ladestrom;
$geladen = $warGeladen
|| ($warVerbunden && $ladungErwartet && $ermittelterMaximalstrom < 6.0);
}
if (!$verbunden) {
$fahrzeugstatus = 1;
} elseif ($geladen) {
$fahrzeugstatus = 4;
} elseif ($leistung > 0.0) {
$fahrzeugstatus = 3;
} else {
$fahrzeugstatus = 2;
}
return [
'FahrzeugVerbunden' => $verbunden,
'FahrzeugGeladen' => $geladen,
'Fahrzeugstatus' => $fahrzeugstatus,
'Phasenzahl' => $phasen,
'Istleistung_W' => $leistung,
'Ladestrom_A' => $ladestrom,
];
}
/** @return list<int> */
public static function leistungsangebot(
bool $aktiv,
bool $ladefreigabe,
bool $verbunden,
bool $geladen,
int $phasenzahl,
int $maximalerLadestrom,
bool $solarladen,
bool $peakbetrieb
): array {
if (!$aktiv || !$ladefreigabe || !$verbunden || $geladen) {
return [0];
}
$stufen = self::leistungsstufen($phasenzahl, $maximalerLadestrom);
if ($stufen === []) {
return [0];
}
if ($solarladen) {
return $peakbetrieb ? [0] : array_merge([0], $stufen);
}
if ($peakbetrieb) {
return array_merge([0], $stufen);
}
return [$stufen[count($stufen) - 1]];
}
public static function stromFuerLeistung(int $leistung, int $phasenzahl): int
{
if ($leistung <= 0) {
return 0;
}
$stufen = self::stufenFuerPhasen($phasenzahl);
if ($leistung < $stufen[0]) {
return 0;
}
$strom = 6;
foreach ($stufen as $index => $schwelle) {
if ($leistung < $schwelle) {
break;
}
$strom = 6 + $index;
}
return min(32, $strom);
}
/** @return list<int> */
private static function leistungsstufen(int $phasenzahl, int $maximalerLadestrom): array
{
if ($maximalerLadestrom < 6 || $maximalerLadestrom > 32) {
throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.');
}
return array_slice(self::stufenFuerPhasen($phasenzahl), 0, $maximalerLadestrom - 5);
}
/** @return list<int> */
private static function stufenFuerPhasen(int $phasenzahl): array
{
if ($phasenzahl === self::PHASEN_EIN) {
return self::LEISTUNGEN_EINPHASIG;
}
if ($phasenzahl === self::PHASEN_DREI) {
return self::LEISTUNGEN_DREIPHASIG;
}
throw new InvalidArgumentException('Phasenzahl muss 1 oder 3 sein.');
}
}
+242
View File
@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use DateTimeImmutable;
use InvalidArgumentException;
use Throwable;
final class Lizenzpruefung
{
public const MANAGER_STANDARD = 'manager_standard';
public const MANAGER_PEAK = 'manager_peak';
public const PROGNOSE_PV = 'forecast_pv';
public const PROGNOSE_VERBRAUCH = 'forecast_load';
public const NETZFAHRPLAN = 'grid_schedule';
public static function normalisiereCode(string $code): string
{
return strtoupper((string) preg_replace('/[^A-Z0-9]/i', '', $code));
}
public static function istLizenzcodeGueltig(string $code): bool
{
return preg_match(
'/^ENX[A-HJ-NP-Z2-9]{16}$/',
self::normalisiereCode($code)
) === 1;
}
/**
* @param array<string, mixed> $lease
*/
public static function pruefeLease(
array $lease,
string $installationID,
int $lastspitzenmodus,
?int $zeitpunkt = null
): void {
if (($lease['status'] ?? null) !== 'active') {
throw new InvalidArgumentException('Die Lizenz ist nicht aktiv.');
}
if (($lease['installationId'] ?? null) !== $installationID) {
throw new InvalidArgumentException('Die Lizenz gehoert zu einer anderen Installation.');
}
if (isset($lease['development']) && !is_bool($lease['development'])) {
throw new InvalidArgumentException('Der Entwicklungsstatus der Lizenz ist ungueltig.');
}
$ausgestelltAm = self::zeitstempel($lease, 'issuedAt');
$erneuernAb = self::zeitstempel($lease, 'refreshAfter');
$offlineBis = self::zeitstempel($lease, 'offlineUntil');
if ($ausgestelltAm > $erneuernAb || $erneuernAb > $offlineBis) {
throw new InvalidArgumentException('Die Lizenzzeitpunkte sind widerspruechlich.');
}
if (($zeitpunkt ?? time()) >= $offlineBis) {
throw new InvalidArgumentException('Die Offline-Freigabe ist abgelaufen.');
}
$berechtigungen = $lease['entitlements'] ?? null;
if (!is_array($berechtigungen)) {
throw new InvalidArgumentException('Die Lizenz enthaelt keine Berechtigungen.');
}
$standard = false;
$peak = false;
foreach ($berechtigungen as $berechtigung) {
if (!is_array($berechtigung)
|| ($berechtigung['status'] ?? null) !== 'active'
|| !is_int($berechtigung['quantity'] ?? null)
|| $berechtigung['quantity'] < 1
) {
continue;
}
$kennung = $berechtigung['catalog_key'] ?? $berechtigung['sku'] ?? null;
$standard = $standard || $kennung === self::MANAGER_STANDARD;
$peak = $peak || $kennung === self::MANAGER_PEAK;
}
if (!$standard && !$peak) {
throw new InvalidArgumentException('Die Manager-Berechtigung fehlt.');
}
if ($lastspitzenmodus !== 0 && !$peak) {
throw new InvalidArgumentException('Peak Shaving ist nicht lizenziert.');
}
}
/**
* Liefert die aufsummierte aktive Berechtigung fuer einen Katalogeintrag.
*
* @param array<string, mixed> $lease
*/
public static function berechtigungsMenge(array $lease, string $kennung): int
{
$berechtigungen = $lease['entitlements'] ?? null;
if (!is_array($berechtigungen)) {
return 0;
}
$menge = 0;
foreach ($berechtigungen as $berechtigung) {
if (!is_array($berechtigung)
|| ($berechtigung['status'] ?? null) !== 'active'
|| !is_int($berechtigung['quantity'] ?? null)
|| $berechtigung['quantity'] < 1
) {
continue;
}
$berechtigungsKennung = $berechtigung['catalog_key']
?? $berechtigung['sku']
?? null;
if ($berechtigungsKennung === $kennung) {
$menge += $berechtigung['quantity'];
}
}
return $menge;
}
/**
* Rechnet freie, bestehende Einzellizenzen auf nicht separat lizenzierte Waermepumpen an.
*
* @param array<string, mixed> $lease
* @param array<string, int> $verwendeteMengen
* @return array<string, int>
*/
public static function beruecksichtigeBestandslizenzFuerWaermepumpen(
array $lease,
array $verwendeteMengen
): array {
$waermepumpen = $verwendeteMengen['heat_pump'] ?? 0;
if ($waermepumpen <= 0) {
return $verwendeteMengen;
}
$nichtSeparatLizenziert = max(
0,
$waermepumpen - self::berechtigungsMenge($lease, 'heat_pump')
);
$verbraucher = $verwendeteMengen['consumer_single'] ?? 0;
$freieBestandslizenzen = max(
0,
self::berechtigungsMenge($lease, 'consumer_single') - $verbraucher
);
$angerechnet = min($nichtSeparatLizenziert, $freieBestandslizenzen);
if ($angerechnet === 0) {
return $verwendeteMengen;
}
$verwendeteMengen['heat_pump'] = $waermepumpen - $angerechnet;
$verwendeteMengen['consumer_single'] = $verbraucher + $angerechnet;
return $verwendeteMengen;
}
/**
* @param array<string, mixed> $lease
* @param array<string, int> $verwendeteMengen
*/
public static function pruefeVerbrauchermengen(array $lease, array $verwendeteMengen): void
{
foreach ($verwendeteMengen as $kennung => $verwendet) {
if ($verwendet < 0) {
throw new InvalidArgumentException(
'Die verwendete Menge fuer ' . $kennung . ' darf nicht negativ sein.'
);
}
$lizenziert = self::berechtigungsMenge($lease, $kennung);
if ($verwendet > $lizenziert) {
throw new InvalidArgumentException(sprintf(
'Lizenzkontingent fuer %s ueberschritten: %d verwendet, %d lizenziert.',
$kennung,
$verwendet,
$lizenziert
));
}
}
}
/**
* @param array<string, mixed> $lease
*/
public static function istLeaseGueltig(
array $lease,
string $installationID,
int $lastspitzenmodus,
?int $zeitpunkt = null
): bool {
try {
self::pruefeLease($lease, $installationID, $lastspitzenmodus, $zeitpunkt);
return true;
} catch (Throwable $fehler) {
return false;
}
}
/**
* @param array<string, mixed> $lease
*/
public static function erneuerungFaellig(array $lease, ?int $zeitpunkt = null): bool
{
try {
return ($zeitpunkt ?? time()) >= self::zeitstempel($lease, 'refreshAfter');
} catch (Throwable $fehler) {
return true;
}
}
/**
* @param array<string, mixed> $lease
*/
public static function offlineBis(array $lease): int
{
return self::zeitstempel($lease, 'offlineUntil');
}
/**
* @param array<string, mixed> $lease
*/
public static function erneuernAb(array $lease): int
{
return self::zeitstempel($lease, 'refreshAfter');
}
/**
* @param array<string, mixed> $lease
*/
private static function zeitstempel(array $lease, string $feld): int
{
$wert = $lease[$feld] ?? null;
if (!is_string($wert) || trim($wert) === '') {
throw new InvalidArgumentException($feld . ' fehlt in der Lizenz.');
}
try {
return (new DateTimeImmutable($wert))->getTimestamp();
} catch (Throwable $fehler) {
throw new InvalidArgumentException($feld . ' ist kein gueltiger ISO-8601-Zeitpunkt.', 0, $fehler);
}
}
}
+542
View File
@@ -0,0 +1,542 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
trait ManagerEnergieTrait
{
private function konfiguriereEnergiearchiv(): void
{
$archivID = $this->archivID();
if ($archivID === 0) {
$this->protokolliere('Energiearchiv', 'Kein Archiv Control vorhanden.');
return;
}
$aktiv = $this->ReadPropertyBoolean('EnergieaufzeichnungAktiv');
$geaendert = [];
$leistungstyp = $this->verdichtungstyp(
$this->ReadPropertyInteger('LeistungsaufzeichnungMinuten')
);
$energietyp = $this->verdichtungstyp(
$this->ReadPropertyInteger('EnergieaufzeichnungMinuten')
);
foreach (self::LEISTUNGS_VARIABLEN as $ident) {
$variableID = $this->GetIDForIdent($ident);
AC_SetLoggingStatus($archivID, $variableID, $aktiv);
AC_SetAggregationType($archivID, $variableID, 0);
AC_SetGraphStatus($archivID, $variableID, $aktiv);
$regeln = [-1 => $leistungstyp];
$loeschen = $this->ReadPropertyInteger('LeistungLoeschenMonate');
if ($loeschen > 0) {
$regeln[$loeschen] = 7;
}
if ($aktiv && $this->setzeVerdichtung($archivID, $variableID, $regeln)) {
$geaendert[] = $variableID;
}
}
foreach (self::ENERGIE_VARIABLEN as $ident) {
$variableID = $this->GetIDForIdent($ident);
AC_SetLoggingStatus($archivID, $variableID, $aktiv);
AC_SetAggregationType($archivID, $variableID, 1);
AC_SetGraphStatus($archivID, $variableID, $aktiv);
$regeln = [-1 => $energietyp];
$verdichten = $this->ReadPropertyInteger('EnergieVerdichtenMonate');
if ($verdichten > 0) {
$regeln[$verdichten] = 3;
}
$loeschen = $this->ReadPropertyInteger('EnergieLoeschenMonate');
if ($loeschen > 0) {
$regeln[$loeschen] = 7;
}
if ($aktiv && $this->setzeVerdichtung($archivID, $variableID, $regeln)) {
$geaendert[] = $variableID;
}
}
IPS_ApplyChanges($archivID);
foreach (array_unique($geaendert) as $variableID) {
AC_ReAggregateVariable($archivID, $variableID);
}
}
/** @param array<int, int> $regeln */
private function setzeVerdichtung(int $archivID, int $variableID, array $regeln): bool
{
ksort($regeln);
$aktuell = [];
$eintraege = AC_GetCompaction($archivID, $variableID);
if (is_array($eintraege)) {
foreach ($eintraege as $eintrag) {
$monat = (int) ($eintrag['MonthOffset'] ?? -999);
$typ = (int) ($eintrag['CompactionType'] ?? -1);
if ($monat >= -1 && $typ >= 0) {
$aktuell[$monat] = $typ;
}
}
}
ksort($aktuell);
if ($aktuell === $regeln) {
return false;
}
foreach (array_keys($aktuell) as $monat) {
AC_SetCompaction($archivID, $variableID, $monat, -1);
}
foreach ($regeln as $monat => $typ) {
AC_SetCompaction($archivID, $variableID, $monat, $typ);
}
return true;
}
private function verdichtungstyp(int $minuten): int
{
return [1 => 0, 5 => 1, 60 => 2][$minuten] ?? 0;
}
private function archivID(): int
{
$archive = IPS_GetInstanceListByModuleID(self::ARCHIVE_MODULE_ID);
return $archive === [] ? 0 : (int) $archive[0];
}
private function synchronisiereEnergievisualisierungen(): void
{
$this->synchronisiereEnergyPie($this->ReadPropertyBoolean('EnergyPieAnzeigen'));
$this->synchronisiereEnergiediagramme(
$this->ReadPropertyBoolean('EnergiediagrammeAnzeigen')
);
$this->synchronisiereFunFacts($this->ReadPropertyBoolean('FunFactsAnzeigen'));
$this->synchronisiereEnergiefluss($this->ReadPropertyBoolean('EnergieflussAnzeigen'));
}
private function synchronisiereEnergyPie(bool $anzeigen): void
{
if ($anzeigen) {
$this->erstelleEnergyPie();
return;
}
$this->entferneInstanzUnterobjekt('EnergyPie', self::ENERGY_PIE_MODULE_ID);
}
private function synchronisiereEnergiediagramme(bool $anzeigen): void
{
if ($anzeigen) {
$this->erstelleEnergiediagramme();
return;
}
$this->entferneDiagrammUnterobjekt('Leistungsdiagramm');
$this->entferneDiagrammUnterobjekt('Energiediagramm');
}
private function synchronisiereFunFacts(bool $anzeigen): void
{
if ($anzeigen) {
$this->erstelleFunFacts();
return;
}
$variableID = $this->findeUnterobjekt('EnergieFunFacts');
if ($variableID === 0) {
return;
}
if ((int) IPS_GetObject($variableID)['ObjectType'] !== 2) {
throw new RuntimeException('Unterobjekt EnergieFunFacts ist keine Variable.');
}
$this->UnregisterVariable('EnergieFunFacts');
}
private function synchronisiereEnergiefluss(bool $anzeigen): void
{
if ($anzeigen) {
$this->erstelleEnergiefluss();
return;
}
$instanzID = $this->findeUnterobjekt('Energiefluss');
if ($instanzID === 0) {
return;
}
$objekt = IPS_GetObject($instanzID);
$modulID = $objekt['ObjectType'] === 1
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
: '';
if ($modulID !== self::ENERGY_FLOW_MODULE_ID) {
throw new RuntimeException('Unterobjekt Energiefluss hat einen unpassenden Typ.');
}
IPS_SetHidden($instanzID, true);
}
private function entferneInstanzUnterobjekt(string $ident, string $modulID): void
{
$instanzID = $this->findeUnterobjekt($ident);
if ($instanzID === 0) {
return;
}
$objekt = IPS_GetObject($instanzID);
$istModulID = $objekt['ObjectType'] === 1
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
: '';
if ($istModulID !== $modulID) {
throw new RuntimeException('Unterobjekt ' . $ident . ' hat einen unpassenden Typ.');
}
IPS_DeleteInstance($instanzID);
}
private function entferneDiagrammUnterobjekt(string $ident): void
{
$medienID = $this->findeUnterobjekt($ident);
if ($medienID === 0) {
return;
}
$objekt = IPS_GetObject($medienID);
if ($objekt['ObjectType'] !== 5 || (int) IPS_GetMedia($medienID)['MediaType'] !== 4) {
throw new RuntimeException('Unterobjekt ' . $ident . ' ist kein Diagramm.');
}
IPS_DeleteMedia($medienID, true);
}
private function erstelleEnergyPie(): string
{
if (!in_array(self::ENERGY_PIE_MODULE_ID, IPS_GetModuleList(), true)) {
throw new RuntimeException(
'Das Modul Energy Pie aus Enelix Utils ist nicht installiert.'
);
}
$instanzID = $this->findeUnterobjekt('EnergyPie');
if ($instanzID === 0) {
$instanzID = IPS_CreateInstance(self::ENERGY_PIE_MODULE_ID);
IPS_SetParent($instanzID, $this->InstanceID);
IPS_SetIdent($instanzID, 'EnergyPie');
} else {
$objekt = IPS_GetObject($instanzID);
$modulID = $objekt['ObjectType'] === 1
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
: '';
if ($modulID !== self::ENERGY_PIE_MODULE_ID) {
throw new RuntimeException('Unterobjekt EnergyPie hat einen unpassenden Typ.');
}
}
IPS_SetName($instanzID, 'Energy Pie');
IPS_SetPosition($instanzID, 500);
IPS_SetHidden($instanzID, false);
foreach ([
'ProduktionZaehlerID' => $this->GetIDForIdent('PVEnergie'),
'EinspeisungZaehlerID' => $this->GetIDForIdent('EinspeisungEnergie'),
'NetzbezugZaehlerID' => $this->GetIDForIdent('NetzbezugEnergie'),
'VerbrauchZaehlerID' => $this->GetIDForIdent('Hausenergie'),
'Zaehlerfaktor' => 1.0,
] as $property => $wert) {
IPS_SetProperty($instanzID, $property, $wert);
}
IPS_ApplyChanges($instanzID);
return 'Energy Pie wurde erstellt oder aktualisiert.';
}
private function erstelleEnergiefluss(): string
{
if (!in_array(self::ENERGY_FLOW_MODULE_ID, IPS_GetModuleList(), true)) {
throw new RuntimeException('Das Symcon-Modul Energy Distribution ist nicht installiert.');
}
$instanzID = $this->findeUnterobjekt('Energiefluss');
if ($instanzID === 0) {
$instanzID = IPS_CreateInstance(self::ENERGY_FLOW_MODULE_ID);
IPS_SetParent($instanzID, $this->InstanceID);
IPS_SetIdent($instanzID, 'Energiefluss');
} else {
$objekt = IPS_GetObject($instanzID);
$modulID = $objekt['ObjectType'] === 1
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
: '';
if ($modulID !== self::ENERGY_FLOW_MODULE_ID) {
throw new RuntimeException('Unterobjekt Energiefluss hat einen unpassenden Typ.');
}
}
IPS_SetName($instanzID, 'Energiefluss');
IPS_SetPosition($instanzID, 540);
IPS_SetHidden($instanzID, false);
$knoten = [
$this->energieflussKnoten('PVLeistungArchiv', 'PV-Anlage', 'solar'),
$this->energieflussKnoten('NetzleistungArchiv', 'Stromnetz', 'grid'),
$this->energieflussKnoten('HausverbrauchLeistungArchiv', 'Hausverbrauch', 'consumer'),
$this->energieflussKnoten('BatterieleistungArchiv', 'Batteriespeicher', 'battery'),
];
IPS_SetProperty($instanzID, 'Calculation', true);
IPS_SetProperty(
$instanzID,
'Config',
json_encode($knoten, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
);
IPS_SetProperty($instanzID, 'EnableLimits', false);
IPS_SetProperty($instanzID, 'Restrictions', '[]');
IPS_ApplyChanges($instanzID);
return 'Der Energiefluss wurde erstellt oder aktualisiert.';
}
/** @return array<string, mixed> */
private function energieflussKnoten(string $ident, string $name, string $typ): array
{
return [
'VariableConfigType' => 0,
'VariableID' => $this->GetIDForIdent($ident),
'SecondaryVariableID' => 1,
'Name' => $name,
'Type' => $typ,
'Grouped' => false,
'CalculationType' => 0,
'InfoVariableID' => 1,
'Invert' => false,
];
}
private function erstelleEnergiediagramme(): string
{
$leistung = [
$this->chartDatensatz('PVLeistungArchiv', 'PV', '#F4C542', '~Power'),
$this->chartDatensatz('HausverbrauchLeistungArchiv', 'Haus', '#22A06B', '~Power'),
$this->chartDatensatz('NetzleistungArchiv', 'Netz', '#E95D5D', '~Power'),
$this->chartDatensatz('BatterieleistungArchiv', 'Batterie', '#8B5CF6', '~Power'),
];
$energie = [
$this->chartDatensatz('PVEnergie', 'PV', '#F4C542', '~Electricity'),
$this->chartDatensatz('Hausenergie', 'Haus', '#22A06B', '~Electricity'),
$this->chartDatensatz('NetzbezugEnergie', 'Netzbezug', '#E95D5D', '~Electricity'),
$this->chartDatensatz('EinspeisungEnergie', 'Einspeisung', '#2F80ED', '~Electricity'),
$this->chartDatensatz('BatterieLadenEnergie', 'Batterie laden', '#8B5CF6', '~Electricity'),
$this->chartDatensatz('BatterieEntladenEnergie', 'Batterie entladen', '#F59E0B', '~Electricity'),
];
$this->erstelleDiagramm('Leistungsdiagramm', 'Leistungen', $leistung, 510);
$this->erstelleDiagramm('Energiediagramm', 'Energien', $energie, 520);
return 'Die Diagramme Leistungen und Energien wurden erstellt oder aktualisiert.';
}
/** @param list<array<string, mixed>> $datensaetze */
private function erstelleDiagramm(
string $ident,
string $name,
array $datensaetze,
int $position
): int {
$medienID = $this->findeUnterobjekt($ident);
if ($medienID === 0) {
$medienID = IPS_CreateMedia(4);
IPS_SetParent($medienID, $this->InstanceID);
IPS_SetIdent($medienID, $ident);
}
$objekt = IPS_GetObject($medienID);
if ($objekt['ObjectType'] !== 5 || (int) IPS_GetMedia($medienID)['MediaType'] !== 4) {
throw new RuntimeException('Unterobjekt ' . $ident . ' ist kein Diagramm.');
}
IPS_SetName($medienID, $name);
IPS_SetPosition($medienID, $position);
IPS_SetHidden($medienID, false);
IPS_SetMediaFile($medienID, 'media/' . $medienID . '.chart', false);
IPS_SetMediaContent(
$medienID,
base64_encode(json_encode(['datasets' => $datensaetze], JSON_THROW_ON_ERROR))
);
return $medienID;
}
/** @return array<string, mixed> */
private function chartDatensatz(
string $ident,
string $titel,
string $farbe,
string $profil
): array {
return [
'variableID' => $this->GetIDForIdent($ident),
'fillColor' => 'clear',
'strokeColor' => $farbe,
'timeOffset' => 0,
'visible' => true,
'title' => $titel,
'type' => 'line',
'side' => 'left',
'profile' => $profil,
];
}
private function findeUnterobjekt(string $ident): int
{
$objektID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
return is_int($objektID) && $objektID > 0 ? $objektID : 0;
}
private function erstelleFunFacts(): string
{
$this->RegisterVariableString('EnergieFunFacts', 'Energy Facts', '~HTMLBox', 530);
$variableID = $this->GetIDForIdent('EnergieFunFacts');
IPS_SetHidden($variableID, false);
$this->aktualisiereFunFacts($variableID);
return 'Energy Facts wurden erstellt oder aktualisiert.';
}
private function aktualisiereFunFacts(int $variableID): void
{
$archivID = $this->archivID();
if ($archivID === 0) {
SetValue($variableID, '<div style="padding:16px">Archiv nicht verfuegbar.</div>');
return;
}
$heute = strtotime('today');
$jahr = mktime(0, 0, 0, 1, 1, (int) date('Y'));
$jetzt = time();
$pvID = $this->GetIDForIdent('PVEnergie');
$hausID = $this->GetIDForIdent('Hausenergie');
$bezugID = $this->GetIDForIdent('NetzbezugEnergie');
$exportID = $this->GetIDForIdent('EinspeisungEnergie');
$pvHeute = $this->energieSumme($archivID, $pvID, $heute, $jetzt);
$pvJahr = $this->energieSumme($archivID, $pvID, $jahr, $jetzt);
$pvTotal = max(0.0, (float) GetValue($pvID));
[$besterTag, $besterTagZeit] = $this->maximalerTageswert($archivID, $pvID);
[$maxPV, $maxPVZeit] = $this->maximaleLeistung(
$archivID,
$this->GetIDForIdent('PVLeistungArchiv')
);
[$maxHausTag, $maxHausTagZeit] = $this->maximalerTageswert($archivID, $hausID);
[$maxHaus, $maxHausZeit] = $this->maximaleLeistung(
$archivID,
$this->GetIDForIdent('HausverbrauchLeistungArchiv')
);
[$maxBezug, $maxBezugZeit] = $this->maximalerTageswert($archivID, $bezugID);
[$maxExport, $maxExportZeit] = $this->maximalerTageswert($archivID, $exportID);
$werte = [
'{{PV_HEUTE}}' => $this->formatiereEnergie($pvHeute),
'{{PV_JAHR}}' => $this->formatiereEnergie($pvJahr),
'{{PV_TOTAL}}' => $this->formatiereEnergie($pvTotal),
'{{BESTER_TAG}}' => $this->formatiereEnergie($besterTag),
'{{BESTER_TAG_DATUM}}' => $this->formatiereDatum($besterTagZeit),
'{{MAX_PV}}' => $this->formatiereLeistung($maxPV),
'{{MAX_PV_DATUM}}' => $this->formatiereDatum($maxPVZeit),
'{{MAX_HAUS_TAG}}' => $this->formatiereEnergie($maxHausTag),
'{{MAX_HAUS_TAG_DATUM}}' => $this->formatiereDatum($maxHausTagZeit),
'{{MAX_HAUS}}' => $this->formatiereLeistung($maxHaus),
'{{MAX_HAUS_DATUM}}' => $this->formatiereDatum($maxHausZeit),
'{{MAX_BEZUG}}' => $this->formatiereEnergie($maxBezug),
'{{MAX_BEZUG_DATUM}}' => $this->formatiereDatum($maxBezugZeit),
'{{MAX_EXPORT}}' => $this->formatiereEnergie($maxExport),
'{{MAX_EXPORT_DATUM}}' => $this->formatiereDatum($maxExportZeit),
'{{HEIZOEL}}' => number_format($pvTotal / 10.0, 0, ',', "'") . ' Liter',
'{{FLUGKM}}' => number_format($pvTotal * 2.4, 0, ',', "'") . ' km',
];
SetValue($variableID, strtr($this->funFactsHtml(), $werte));
}
private function energieSumme(int $archivID, int $variableID, int $start, int $ende): float
{
$werte = @AC_GetAggregatedValues($archivID, $variableID, 1, $start, $ende, 0);
if (!is_array($werte)) {
return 0.0;
}
return array_sum(array_map(
static fn (array $wert): float => max(0.0, (float) ($wert['Avg'] ?? 0.0)),
$werte
));
}
/** @return array{0: float, 1: int} */
private function maximalerTageswert(int $archivID, int $variableID): array
{
$werte = @AC_GetAggregatedValues($archivID, $variableID, 1, 0, time(), 0);
$maximum = 0.0;
$zeit = 0;
if (is_array($werte)) {
foreach ($werte as $wert) {
$kandidat = max(0.0, (float) ($wert['Avg'] ?? 0.0));
if ($kandidat > $maximum) {
$maximum = $kandidat;
$zeit = (int) ($wert['TimeStamp'] ?? 0);
}
}
}
return [$maximum, $zeit];
}
/** @return array{0: float, 1: int} */
private function maximaleLeistung(int $archivID, int $variableID): array
{
$werte = @AC_GetAggregatedValues($archivID, $variableID, 1, 0, time(), 0);
$maximum = 0.0;
$zeit = 0;
if (is_array($werte)) {
foreach ($werte as $wert) {
$kandidat = max(0.0, (float) ($wert['Max'] ?? 0.0));
if ($kandidat > $maximum) {
$maximum = $kandidat;
$zeit = (int) ($wert['MaxTime'] ?? $wert['TimeStamp'] ?? 0);
}
}
}
return [$maximum, $zeit];
}
private function formatiereEnergie(float $wert): string
{
if ($wert >= 1000.0) {
return number_format($wert / 1000.0, 2, ',', "'") . ' MWh';
}
return number_format($wert, 1, ',', "'") . ' kWh';
}
private function formatiereLeistung(float $wert): string
{
if ($wert >= 10000.0) {
return number_format($wert / 1000.0, 1, ',', "'") . ' kW';
}
return number_format($wert, 0, ',', "'") . ' W';
}
private function formatiereDatum(int $zeit): string
{
return $zeit > 0 ? date('d.m.Y', $zeit) : 'Noch keine Daten';
}
private function funFactsHtml(): string
{
return <<<'HTML'
<style>
.enx-facts{box-sizing:border-box;font-family:system-ui,-apple-system,sans-serif;color:#182230;padding:16px;background:#f4f7f6}
.enx-facts *{box-sizing:border-box}.enx-facts-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
.enx-fact-panel{min-width:0;background:#fff;border:1px solid #dfe7e4;border-radius:8px;overflow:hidden;box-shadow:0 4px 18px rgba(17,44,36,.08)}
.enx-fact-head{padding:15px 18px 13px;border-bottom:3px solid #22a06b;display:flex;align-items:center;justify-content:space-between}
.enx-fact-head b{font-size:18px}.enx-fact-head span{font-size:11px;letter-spacing:.08em;color:#60736c}
.enx-fact-body{padding:5px 18px 10px}.enx-fact-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;padding:11px 0;border-bottom:1px solid #edf1ef}
.enx-fact-row:last-child{border-bottom:0}.enx-fact-label{min-width:0;color:#52635d;font-size:13px}.enx-fact-value{text-align:right;font-size:15px;font-weight:700;white-space:nowrap}
.enx-fact-date{display:block;color:#81908b;font-size:11px;font-weight:500;margin-top:2px}.enx-fact-panel.grid .enx-fact-head{border-color:#2f80ed}
.enx-fact-panel.impact .enx-fact-head{border-color:#f4c542}.enx-impact{padding:18px}.enx-impact-value{font-size:30px;font-weight:750;line-height:1.15}
.enx-impact-label{font-size:12px;color:#60736c;margin:5px 0 20px}.enx-impact-note{font-size:11px;color:#81908b;line-height:1.45}
@media(max-width:820px){.enx-facts-grid{grid-template-columns:1fr}.enx-facts{padding:10px}}
</style>
<div class="enx-facts"><div class="enx-facts-grid">
<section class="enx-fact-panel"><div class="enx-fact-head"><b>Solar</b><span>ERZEUGUNG</span></div><div class="enx-fact-body">
<div class="enx-fact-row"><span class="enx-fact-label">Heute</span><span class="enx-fact-value">{{PV_HEUTE}}</span></div>
<div class="enx-fact-row"><span class="enx-fact-label">Dieses Jahr</span><span class="enx-fact-value">{{PV_JAHR}}</span></div>
<div class="enx-fact-row"><span class="enx-fact-label">Total</span><span class="enx-fact-value">{{PV_TOTAL}}</span></div>
<div class="enx-fact-row"><span class="enx-fact-label">Bester Tag</span><span class="enx-fact-value">{{BESTER_TAG}}<small class="enx-fact-date">{{BESTER_TAG_DATUM}}</small></span></div>
<div class="enx-fact-row"><span class="enx-fact-label">Max. Leistung</span><span class="enx-fact-value">{{MAX_PV}}<small class="enx-fact-date">{{MAX_PV_DATUM}}</small></span></div>
</div></section>
<section class="enx-fact-panel grid"><div class="enx-fact-head"><b>Netz &amp; Haus</b><span>SPITZENWERTE</span></div><div class="enx-fact-body">
<div class="enx-fact-row"><span class="enx-fact-label">Max. Tagesverbrauch</span><span class="enx-fact-value">{{MAX_HAUS_TAG}}<small class="enx-fact-date">{{MAX_HAUS_TAG_DATUM}}</small></span></div>
<div class="enx-fact-row"><span class="enx-fact-label">Max. Hauslast</span><span class="enx-fact-value">{{MAX_HAUS}}<small class="enx-fact-date">{{MAX_HAUS_DATUM}}</small></span></div>
<div class="enx-fact-row"><span class="enx-fact-label">Max. Tagesbezug</span><span class="enx-fact-value">{{MAX_BEZUG}}<small class="enx-fact-date">{{MAX_BEZUG_DATUM}}</small></span></div>
<div class="enx-fact-row"><span class="enx-fact-label">Max. Einspeisung</span><span class="enx-fact-value">{{MAX_EXPORT}}<small class="enx-fact-date">{{MAX_EXPORT_DATUM}}</small></span></div>
</div></section>
<section class="enx-fact-panel impact"><div class="enx-fact-head"><b>Wirkung</b><span>VERGLEICHE</span></div><div class="enx-impact">
<div class="enx-impact-value">{{HEIZOEL}}</div><div class="enx-impact-label">rechnerisch kompensiertes Heizoel</div>
<div class="enx-impact-value">{{FLUGKM}}</div><div class="enx-impact-label">rechnerisch kompensierte Flugkilometer</div>
<div class="enx-impact-note">Vergleichswerte auf Basis von 10 kWh pro Liter Heizoel und 2,4 Flugkilometern pro erzeugter kWh.</div>
</div></section>
</div></div>
HTML;
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
/**
* Reine Regel- und Verteilfunktion des Managers.
*
* Die Klasse kennt keine IP-Symcon-Funktionen und kann deshalb unabhaengig
* vom Laufzeitsystem getestet werden. Alle Leistungen verwenden die
* Vorzeichenkonvention des Vertrags: positiv Bezug, negativ Abgabe.
*/
final class ManagerRegler
{
public const BETRIEBSART_INAKTIV = 'Inaktiv';
public const BETRIEBSART_PV = Nachrichtenvertrag::BETRIEBSART_PV;
public const BETRIEBSART_PEAK = Nachrichtenvertrag::BETRIEBSART_PEAK;
/**
* @param array<int, float|int> $monatsgrenzen Monatsnummer 1 bis 12 als Schluessel
*/
public static function wirksameLastspitzengrenze(
int $modus,
float $konstanteGrenze,
array $monatsgrenzen,
int $monat
): ?float {
if ($modus === 0) {
return null;
}
if ($modus === 1) {
return $konstanteGrenze;
}
if ($modus !== 2) {
throw new InvalidArgumentException('Lastspitzenmodus muss 0, 1 oder 2 sein.');
}
if ($monat < 1 || $monat > 12 || !array_key_exists($monat, $monatsgrenzen)) {
throw new InvalidArgumentException('Fuer den aktuellen Monat fehlt eine Lastspitzengrenze.');
}
return (float) $monatsgrenzen[$monat];
}
public static function bestimmeBetriebsart(
bool $aktiv,
bool $netzleistungGueltig,
float $netzleistung,
string $bisherigeBetriebsart,
bool $peakAktiv,
float $sollwertSolarladen,
float $wirksameLastspitzengrenze,
float $umschaltdifferenzProzent
): string {
if (!$aktiv || !$netzleistungGueltig) {
return self::BETRIEBSART_INAKTIV;
}
if (!$peakAktiv) {
return self::BETRIEBSART_PV;
}
if ($umschaltdifferenzProzent < 0 || $umschaltdifferenzProzent > 100) {
throw new InvalidArgumentException('Umschaltdifferenz muss zwischen 0 und 100 Prozent liegen.');
}
$kandidat = $bisherigeBetriebsart;
if ($netzleistung < $sollwertSolarladen) {
$kandidat = self::BETRIEBSART_PV;
} elseif ($netzleistung > $wirksameLastspitzengrenze) {
$kandidat = self::BETRIEBSART_PEAK;
} elseif (!in_array($kandidat, [self::BETRIEBSART_PV, self::BETRIEBSART_PEAK], true)) {
$kandidat = self::BETRIEBSART_PV;
}
if ($kandidat === $bisherigeBetriebsart
|| !in_array($bisherigeBetriebsart, [self::BETRIEBSART_PV, self::BETRIEBSART_PEAK], true)
|| $umschaltdifferenzProzent === 0.0
) {
return $kandidat;
}
$pvKorrektur = $sollwertSolarladen - $netzleistung;
$peakKorrektur = $wirksameLastspitzengrenze - $netzleistung;
$bezugswert = max(abs($pvKorrektur), abs($peakKorrektur), 1.0);
$differenzProzent = abs($pvKorrektur - $peakKorrektur) / $bezugswert * 100.0;
return $differenzProzent >= $umschaltdifferenzProzent
? $kandidat
: $bisherigeBetriebsart;
}
/**
* @param list<array<string, mixed>> $verbraucher Vollstaendige, bereits gepruefte Verbraucherdaten
* @return array{
* Verteilbudget_W: int,
* AktuelleVerbraucherleistung_W: int,
* NichtSteuerbar_W: int,
* Sollwerte: array<int, int>,
* Restabweichung_W: int
* }
*/
public static function verteile(
float $netzleistung,
float $zielnetzleistung,
array $verbraucher,
string $prioritaetsfeld
): array {
if (!in_array($prioritaetsfeld, ['PrioritaetPV', 'PrioritaetPeak'], true)) {
throw new InvalidArgumentException('Unbekanntes Prioritaetsfeld.');
}
$aktuelleLeistung = 0;
$nichtSteuerbar = 0;
$steuerbar = [];
foreach ($verbraucher as $eintrag) {
self::pruefeVerteilEintrag($eintrag, $prioritaetsfeld);
$leistung = self::aktuelleLeistung($eintrag['Daten']);
$aktuelleLeistung += $leistung;
$daten = $eintrag['Daten'];
if (!$daten['Verfuegbar'] || !$daten['AenderungMoeglich'] || $daten['Leistungswerte_W'] === []) {
$nichtSteuerbar += $leistung;
continue;
}
$steuerbar[] = [
'InstanzID' => $eintrag['InstanzID'],
'Prioritaet' => $daten[$prioritaetsfeld],
'Leistungswerte_W' => $daten['Leistungswerte_W'],
];
}
usort($steuerbar, static function (array $links, array $rechts): int {
return [$links['Prioritaet'], $links['InstanzID']] <=> [$rechts['Prioritaet'], $rechts['InstanzID']];
});
$verteilbudget = (int) round($aktuelleLeistung + $zielnetzleistung - $netzleistung);
$sollwerte = [];
$minimumSumme = 0;
foreach ($steuerbar as $eintrag) {
$minimum = self::kleinsterLeistungswert($eintrag['Leistungswerte_W']);
$sollwerte[$eintrag['InstanzID']] = $minimum;
$minimumSumme += $minimum;
}
$verfuegbar = max(0, $verteilbudget - $nichtSteuerbar - $minimumSumme);
foreach ($steuerbar as $eintrag) {
$instanzID = $eintrag['InstanzID'];
$minimum = $sollwerte[$instanzID];
$neuerSollwert = self::groessterLeistungswertBis(
$eintrag['Leistungswerte_W'],
$minimum + $verfuegbar
);
$verfuegbar -= $neuerSollwert - $minimum;
$sollwerte[$instanzID] = $neuerSollwert;
}
$zugeteilt = $nichtSteuerbar + array_sum($sollwerte);
return [
'Verteilbudget_W' => $verteilbudget,
'AktuelleVerbraucherleistung_W' => $aktuelleLeistung,
'NichtSteuerbar_W' => $nichtSteuerbar,
'Sollwerte' => $sollwerte,
'Restabweichung_W' => $verteilbudget - $zugeteilt,
];
}
/**
* @param array<string, mixed> $daten
*/
public static function angenommenerSollwert(array $daten): ?int
{
foreach ($daten['Zustand'] as $zustand) {
if ($zustand['Kennung'] === 'Sollleistung_W') {
return is_int($zustand['Wert']) ? $zustand['Wert'] : null;
}
}
return null;
}
/**
* @param array<string, mixed> $daten
*/
private static function aktuelleLeistung(array $daten): int
{
if ($daten['Leistungsquelle'] !== Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN
&& is_numeric($daten['Istleistung_W'])
) {
return (int) round((float) $daten['Istleistung_W']);
}
return self::angenommenerSollwert($daten) ?? 0;
}
/**
* @param array<string, mixed> $eintrag
*/
private static function pruefeVerteilEintrag(array $eintrag, string $prioritaetsfeld): void
{
if (!isset($eintrag['InstanzID'], $eintrag['Daten'])
|| !is_int($eintrag['InstanzID'])
|| $eintrag['InstanzID'] <= 0
|| !is_array($eintrag['Daten'])
) {
throw new InvalidArgumentException('Verbrauchereintrag ist ungueltig.');
}
Nachrichtenvertrag::pruefeVerbraucherdaten($eintrag['Daten']);
if (!array_key_exists($prioritaetsfeld, $eintrag['Daten'])) {
throw new InvalidArgumentException('Prioritaet fehlt im Verbrauchereintrag.');
}
}
/**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
*/
private static function kleinsterLeistungswert(array $leistungswerte): int
{
$erster = $leistungswerte[0];
return is_int($erster) ? $erster : $erster['Von_W'];
}
/**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
*/
private static function groessterLeistungswertBis(array $leistungswerte, int $obergrenze): int
{
$gewaehlt = self::kleinsterLeistungswert($leistungswerte);
foreach ($leistungswerte as $eintrag) {
if (is_int($eintrag)) {
if ($eintrag > $obergrenze) {
break;
}
$gewaehlt = $eintrag;
continue;
}
if ($eintrag['Von_W'] > $obergrenze) {
break;
}
$gewaehlt = min($eintrag['Bis_W'], $obergrenze);
}
return $gewaehlt;
}
}
+2 -1
View File
@@ -9,8 +9,9 @@ interface ManagerSchnittstelle
/** /**
* Empfaengt den vollstaendigen aktuellen Datensatz eines Verbrauchers. * Empfaengt den vollstaendigen aktuellen Datensatz eines Verbrauchers.
* *
* Dies ist die einzige fachliche Empfangsmethode des Managers.
*
* @param array<string, mixed> $daten * @param array<string, mixed> $daten
*/ */
public function VerbraucherdatenEmpfangen(array $daten): void; public function VerbraucherdatenEmpfangen(array $daten): void;
} }
+49 -4
View File
@@ -8,7 +8,10 @@ use InvalidArgumentException;
final class Nachrichtenvertrag final class Nachrichtenvertrag
{ {
public const VERSION = '3.0'; public const VERSION = '4.0';
public const BETRIEBSART_PV = 'PV';
public const BETRIEBSART_PEAK = 'Peak';
public const LEISTUNGSQUELLE_NICHT_VORHANDEN = 0; public const LEISTUNGSQUELLE_NICHT_VORHANDEN = 0;
public const LEISTUNGSQUELLE_BERECHNET = 1; public const LEISTUNGSQUELLE_BERECHNET = 1;
@@ -19,11 +22,12 @@ final class Nachrichtenvertrag
*/ */
public static function pruefeManagerdaten(array $daten): void public static function pruefeManagerdaten(array $daten): void
{ {
self::pruefeSchluessel($daten, ['Kopf', 'Sollleistung_W'], 'Managerdaten'); self::pruefeSchluessel($daten, ['Kopf', 'Betriebsart', 'Sollleistung_W'], 'Managerdaten');
self::pruefeKopf($daten['Kopf']); self::pruefeKopf($daten['Kopf']);
self::pruefeBetriebsart($daten['Betriebsart']);
if (!is_int($daten['Sollleistung_W'])) { if ($daten['Sollleistung_W'] !== null && !is_int($daten['Sollleistung_W'])) {
throw new InvalidArgumentException('Sollleistung_W muss eine Ganzzahl sein.'); throw new InvalidArgumentException('Sollleistung_W muss eine Ganzzahl oder null sein.');
} }
} }
@@ -36,6 +40,7 @@ final class Nachrichtenvertrag
$daten, $daten,
[ [
'Kopf', 'Kopf',
'Betriebsart',
'PrioritaetPV', 'PrioritaetPV',
'PrioritaetPeak', 'PrioritaetPeak',
'Leistungswerte_W', 'Leistungswerte_W',
@@ -49,6 +54,7 @@ final class Nachrichtenvertrag
); );
self::pruefeKopf($daten['Kopf']); self::pruefeKopf($daten['Kopf']);
self::pruefeBetriebsart($daten['Betriebsart']);
self::pruefePrioritaet($daten['PrioritaetPV'], 'PrioritaetPV'); self::pruefePrioritaet($daten['PrioritaetPV'], 'PrioritaetPV');
self::pruefePrioritaet($daten['PrioritaetPeak'], 'PrioritaetPeak'); self::pruefePrioritaet($daten['PrioritaetPeak'], 'PrioritaetPeak');
self::pruefeLeistungswerte($daten['Leistungswerte_W']); self::pruefeLeistungswerte($daten['Leistungswerte_W']);
@@ -64,6 +70,33 @@ final class Nachrichtenvertrag
self::pruefeZustand($daten['Zustand']); self::pruefeZustand($daten['Zustand']);
} }
/**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
*/
public static function enthaeltLeistungswert(array $leistungswerte, int $leistung): bool
{
foreach ($leistungswerte as $eintrag) {
if (is_int($eintrag)) {
if ($eintrag === $leistung) {
return true;
}
continue;
}
if (is_array($eintrag)
&& isset($eintrag['Von_W'], $eintrag['Bis_W'])
&& is_int($eintrag['Von_W'])
&& is_int($eintrag['Bis_W'])
&& $leistung >= $eintrag['Von_W']
&& $leistung <= $eintrag['Bis_W']
) {
return true;
}
}
return false;
}
/** /**
* @param mixed $kopf * @param mixed $kopf
*/ */
@@ -93,6 +126,18 @@ final class Nachrichtenvertrag
} }
} }
/** @param mixed $betriebsart */
private static function pruefeBetriebsart($betriebsart): void
{
if (!is_string($betriebsart) || !in_array(
$betriebsart,
[self::BETRIEBSART_PV, self::BETRIEBSART_PEAK],
true
)) {
throw new InvalidArgumentException('Betriebsart muss PV oder Peak sein.');
}
}
/** /**
* @param mixed $prioritaet * @param mixed $prioritaet
*/ */
+214
View File
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class PufferspeicherRegler
{
public const MINIMUM_AUS = 0;
public const MINIMUM_STATISCH = 1;
public const MINIMUM_DIFFERENZ = 2;
/**
* @return list<array{Stufe: int, Leistung_W: int, KontaktID: int}>
*/
public static function dekodiereLeistungsstufen(string $json): array
{
$werte = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($werte) || !self::istListe($werte)) {
throw new InvalidArgumentException('LeistungsStufen muss eine JSON-Liste sein.');
}
$ergebnis = [];
$leistungen = [];
$kontakte = [];
foreach ($werte as $index => $eintrag) {
if (!is_array($eintrag)) {
throw new InvalidArgumentException('Jede Leistungsstufe muss ein Objekt sein.');
}
$stufe = $eintrag['Stufe'] ?? $index + 1;
$leistung = $eintrag['Leistung'] ?? null;
$kontakt = $eintrag['Schaltkontakt_Stufe'] ?? null;
if (!is_int($stufe) || $stufe <= 0) {
throw new InvalidArgumentException('Stufe muss eine positive Ganzzahl sein.');
}
if (!is_int($leistung) || $leistung <= 0) {
throw new InvalidArgumentException('Leistung muss eine positive Ganzzahl sein.');
}
if (!is_int($kontakt) || $kontakt <= 0) {
throw new InvalidArgumentException('Schaltkontakt_Stufe muss eine positive Variablen-ID sein.');
}
if (isset($leistungen[$leistung])) {
throw new InvalidArgumentException('Leistungen muessen eindeutig sein.');
}
if (isset($kontakte[$kontakt])) {
throw new InvalidArgumentException('Schaltkontakte duerfen nur einer Stufe zugeordnet sein.');
}
$leistungen[$leistung] = true;
$kontakte[$kontakt] = true;
$ergebnis[] = [
'Stufe' => $stufe,
'Leistung_W' => $leistung,
'KontaktID' => $kontakt,
];
}
usort(
$ergebnis,
static fn (array $links, array $rechts): int => $links['Leistung_W'] <=> $rechts['Leistung_W']
);
return $ergebnis;
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
* @return list<int>
*/
public static function leistungswerte(array $stufen): array
{
return array_merge([0], array_column($stufen, 'Leistung_W'));
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
*/
public static function stufeFuerLeistung(array $stufen, int $leistung): int
{
if ($leistung === 0) {
return 0;
}
foreach ($stufen as $stufe) {
if ($stufe['Leistung_W'] === $leistung) {
return $stufe['Stufe'];
}
}
throw new InvalidArgumentException('Die Sollleistung entspricht keiner konfigurierten Leistungsstufe.');
}
public static function solltemperatur(
float $aussentemperatur,
float $fusspunkt,
float $steigung,
float $minimum,
float $maximum
): float {
if ($steigung < 0.0) {
throw new InvalidArgumentException('HeizkurvenSteigung darf nicht negativ sein.');
}
if ($minimum > $maximum) {
throw new InvalidArgumentException('Heizkurvenminimum darf nicht groesser als das Maximum sein.');
}
$soll = $fusspunkt + $steigung * (20.0 - $aussentemperatur);
return min($maximum, max($minimum, $soll));
}
public static function einschaltschwelle(
float $solltemperatur,
float $hysterese,
int $minimumModus,
float $statischeMindesttemperatur,
float $mindesttemperaturDifferenz
): float {
if ($hysterese <= 0.0) {
throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.');
}
if ($minimumModus === self::MINIMUM_AUS) {
return $solltemperatur - $hysterese;
}
if ($minimumModus === self::MINIMUM_STATISCH) {
return min($solltemperatur, $statischeMindesttemperatur);
}
if ($minimumModus === self::MINIMUM_DIFFERENZ) {
if ($mindesttemperaturDifferenz < 0.0) {
throw new InvalidArgumentException('MindesttemperaturDifferenz darf nicht negativ sein.');
}
return $solltemperatur - $mindesttemperaturDifferenz;
}
throw new InvalidArgumentException('MindesttemperaturModus muss 0, 1 oder 2 sein.');
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
* @return list<int>
*/
public static function leistungsangebot(
bool $aktiv,
bool $peakbetrieb,
bool $temperaturenGueltig,
float $puffertemperatur,
float $einschaltschwelle,
array $stufen
): array {
if (!$aktiv || $peakbetrieb || !$temperaturenGueltig || $stufen === []) {
return [0];
}
if ($puffertemperatur < $einschaltschwelle) {
return self::leistungswerte($stufen);
}
return [0];
}
public static function fusspunktAusWaermepumpenwert(
float $waermepumpenSolltemperatur,
float $aussentemperatur,
float $steigung
): float {
if ($steigung < 0.0) {
throw new InvalidArgumentException('HeizkurvenSteigung darf nicht negativ sein.');
}
return $waermepumpenSolltemperatur - $steigung * (20.0 - $aussentemperatur);
}
public static function pt1(float $bisher, float $eingang, int $deltaSekunden, int $zeitkonstante): float
{
if ($deltaSekunden <= 0 || $zeitkonstante <= 0) {
throw new InvalidArgumentException('PT1-Zeiten muessen groesser als 0 sein.');
}
$alpha = $deltaSekunden / ($zeitkonstante + $deltaSekunden);
return $bisher + $alpha * ($eingang - $bisher);
}
public static function restzeitBisLastwechsel(
int $letzterLastwechsel,
int $jetzt,
int $sperrzeitSekunden
): int {
if ($sperrzeitSekunden < 0) {
throw new InvalidArgumentException('LastwechselSperrzeit darf nicht negativ sein.');
}
if ($letzterLastwechsel <= 0) {
return 0;
}
return max(0, $sperrzeitSekunden - max(0, $jetzt - $letzterLastwechsel));
}
public static function energieKWh(float $leistungW, int $dauerSekunden): float
{
if ($dauerSekunden < 0) {
throw new InvalidArgumentException('Dauer darf nicht negativ sein.');
}
return $leistungW * $dauerSekunden / 3600000.0;
}
/** @param array<mixed> $wert */
private static function istListe(array $wert): bool
{
return $wert === [] || array_keys($wert) === range(0, count($wert) - 1);
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class StoerungsSnapshot
{
public const VERSION = '1.0';
private const SCHWEREGRADE = [
'critical' => 0,
'error' => 1,
'warning' => 2,
'info' => 3,
];
/**
* @param list<array<string, mixed>> $meldungen
* @return array<string, mixed>
*/
public static function erstellen(
string $installationID,
int $managerID,
string $rolle,
bool $managerAktiv,
array $meldungen,
?int $zeitpunkt = null
): array {
if (preg_match(
'/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
$installationID
) !== 1) {
throw new InvalidArgumentException('Die Installations-ID muss eine UUIDv4 sein.');
}
if ($managerID <= 0) {
throw new InvalidArgumentException('Die Manager-ID muss positiv sein.');
}
if (!in_array($rolle, ['standalone', 'main', 'submanager'], true)) {
throw new InvalidArgumentException('Die Managerrolle ist ungueltig.');
}
$eindeutig = [];
foreach ($meldungen as $meldung) {
$normalisiert = self::normalisiereMeldung($meldung);
$schluessel = $normalisiert['sourceId'] . "\0" . $normalisiert['code'];
$eindeutig[$schluessel] = $normalisiert;
}
$meldungen = array_values($eindeutig);
usort($meldungen, static function (array $links, array $rechts): int {
$schweregrad = self::SCHWEREGRADE[$links['severity']]
<=> self::SCHWEREGRADE[$rechts['severity']];
if ($schweregrad !== 0) {
return $schweregrad;
}
return [$links['sourceName'], $links['code']]
<=> [$rechts['sourceName'], $rechts['code']];
});
// Das Portal akzeptiert hoechstens 100 Meldungen; die hoechste Prioritaet bleibt erhalten.
$meldungen = array_slice($meldungen, 0, 100);
return [
'version' => self::VERSION,
'installationId' => strtolower($installationID),
'capturedAt' => gmdate('Y-m-d\TH:i:s\Z', $zeitpunkt ?? time()),
'manager' => [
'instanceId' => $managerID,
'role' => $rolle,
'active' => $managerAktiv,
],
'faults' => $meldungen,
];
}
/** @param array<string, mixed> $snapshot */
public static function zustandHash(array $snapshot): string
{
unset($snapshot['capturedAt']);
return hash('sha256', json_encode(
$snapshot,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
));
}
/**
* @param array<string, mixed> $meldung
* @return array<string, string>
*/
private static function normalisiereMeldung(array $meldung): array
{
$felder = ['sourceType', 'sourceId', 'sourceName', 'code', 'severity', 'message'];
if (array_diff($felder, array_keys($meldung)) !== []
|| array_diff(array_keys($meldung), $felder) !== []
) {
throw new InvalidArgumentException('Eine Stoerungsmeldung hat ungueltige Felder.');
}
$normalisiert = [];
foreach ($felder as $feld) {
if (!is_string($meldung[$feld])) {
throw new InvalidArgumentException('Stoerungsfelder muessen Texte sein.');
}
$normalisiert[$feld] = trim((string) preg_replace('/\s+/', ' ', $meldung[$feld]));
}
if ($normalisiert['sourceType'] === ''
|| $normalisiert['sourceId'] === ''
|| $normalisiert['sourceName'] === ''
|| $normalisiert['code'] === ''
|| $normalisiert['message'] === ''
) {
throw new InvalidArgumentException('Stoerungsfelder duerfen nicht leer sein.');
}
if (!array_key_exists($normalisiert['severity'], self::SCHWEREGRADE)) {
throw new InvalidArgumentException('Der Stoerungsschweregrad ist ungueltig.');
}
if (strlen($normalisiert['sourceType']) > 40
|| strlen($normalisiert['sourceId']) > 120
|| strlen($normalisiert['sourceName']) > 160
|| strlen($normalisiert['code']) > 120
|| strlen($normalisiert['message']) > 500
) {
throw new InvalidArgumentException('Mindestens ein Stoerungsfeld ist zu lang.');
}
return $normalisiert;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
/**
* Registriert die gemeinsamen Properties und Variablen aller EMS-Verbraucher.
*
* Das Trait ist noch keine vollstaendige Steuerungsimplementierung. Die
* konkreten Module ergaenzen Geraeteanbindung, Leistungsangebot und Zustand.
*/
trait VerbraucherBasisTrait
{
protected function registriereVerbraucherBasis(): void
{
$this->RegisterPropertyInteger('PrioritaetPV', 0);
$this->RegisterPropertyInteger('PrioritaetPeak', 0);
$this->RegisterPropertyInteger('Meldeintervall', 10);
$this->RegisterPropertyInteger('VorgabeTimeout', 120);
$this->RegisterPropertyBoolean('EinstellungenInVisu', false);
$this->RegisterPropertyBoolean('LoggingEin', false);
$this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10);
$this->EnableAction('Aktiv');
}
protected function registriereVerbraucherDiagnose(): void
{
$this->RegisterVariableFloat('Istleistung', 'Istleistung', '', 20);
$this->RegisterVariableInteger('Leistungsquelle', 'Leistungsquelle', '', 30);
$this->RegisterVariableInteger('Sollleistung', 'Sollleistung', '', 40);
$this->RegisterVariableBoolean('SollwertGueltig', 'Sollwert gueltig', '~Switch', 50);
$this->RegisterVariableBoolean('Verfuegbar', 'Verfuegbar', '~Switch', 60);
$this->RegisterVariableBoolean('AenderungMoeglich', 'Aenderung moeglich', '~Switch', 70);
$this->RegisterVariableBoolean('Stoerung', 'Stoerung', '', 80);
$this->RegisterVariableString('Stoertext', 'Stoertext', '', 90);
}
}
+3 -2
View File
@@ -7,10 +7,11 @@ namespace Belevo\EnelixEMS;
interface VerbraucherSchnittstelle interface VerbraucherSchnittstelle
{ {
/** /**
* Empfaengt die aktuelle Leistungsvorgabe des Managers. * Empfaengt Kopf und Sollleistung_W als vollstaendigen Managerdatensatz.
*
* Dies ist die einzige fachliche Empfangsmethode des Verbrauchers.
* *
* @param array<string, mixed> $daten * @param array<string, mixed> $daten
*/ */
public function ManagerdatenEmpfangen(array $daten): void; public function ManagerdatenEmpfangen(array $daten): void;
} }
+190
View File
@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class WaermepumpenRegler
{
public const KONTAKTART_SPERRE_ERHOEHUNG = 0;
public const KONTAKTART_SG_READY = 1;
public const RUECKMELDUNG_LEISTUNG = 0;
public const RUECKMELDUNG_BETRIEB = 1;
public const STEUERZUSTAND_NORMAL = 0;
public const STEUERZUSTAND_SPERRE = 1;
public const STEUERZUSTAND_ERHOEHUNG = 2;
/** @return array{Kontakt1: bool, Kontakt2: bool} */
public static function kontaktwerte(int $kontaktart, int $steuerzustand): array
{
self::pruefeKontaktart($kontaktart);
self::pruefeSteuerzustand($steuerzustand);
return [
'Kontakt1' => $steuerzustand === self::STEUERZUSTAND_SPERRE,
'Kontakt2' => $steuerzustand === self::STEUERZUSTAND_ERHOEHUNG,
];
}
/** @param bool|int|float $rueckmeldung */
public static function laeuft($rueckmeldung, int $rueckmeldungsart, float $laufschwelle): bool
{
self::pruefeRueckmeldungsart($rueckmeldungsart);
if ($laufschwelle < 0) {
throw new InvalidArgumentException('Die Laufschwelle darf nicht negativ sein.');
}
if ($rueckmeldungsart === self::RUECKMELDUNG_BETRIEB) {
return (bool) $rueckmeldung;
}
return abs((float) $rueckmeldung) >= $laufschwelle;
}
/** @param bool|int|float $rueckmeldung */
public static function istleistung(
$rueckmeldung,
int $rueckmeldungsart,
int $nennleistung
): float {
self::pruefeRueckmeldungsart($rueckmeldungsart);
self::pruefeNennleistung($nennleistung);
if ($rueckmeldungsart === self::RUECKMELDUNG_BETRIEB) {
return (bool) $rueckmeldung ? (float) $nennleistung : 0.0;
}
return max(0.0, (float) $rueckmeldung);
}
/** @return list<int> */
public static function leistungsangebot(
int $nennleistung,
string $betriebsart,
bool $laeuft,
bool $verfuegbar,
bool $mindestlaufzeitAktiv,
bool $mindestsperrzeitAktiv,
bool $sperrenZulaessig,
bool $startenZulaessig,
bool $sperrerholung
): array {
self::pruefeNennleistung($nennleistung);
if (!in_array($betriebsart, [
Nachrichtenvertrag::BETRIEBSART_PV,
Nachrichtenvertrag::BETRIEBSART_PEAK,
], true)) {
throw new InvalidArgumentException('Die Betriebsart ist ungueltig.');
}
if (!$verfuegbar) {
return [$laeuft ? $nennleistung : 0];
}
if ($sperrerholung || $mindestlaufzeitAktiv) {
return [$laeuft ? $nennleistung : 0];
}
if ($betriebsart === Nachrichtenvertrag::BETRIEBSART_PV) {
return ($laeuft || (!$mindestsperrzeitAktiv && $startenZulaessig))
? [0, $nennleistung]
: [0];
}
return ($laeuft && $sperrenZulaessig)
? [0, $nennleistung]
: [$laeuft ? $nennleistung : 0];
}
public static function restzeit(int $beginn, int $dauer, int $jetzt): int
{
if ($beginn < 0 || $dauer < 0 || $jetzt < 0) {
throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.');
}
if ($beginn === 0 || $dauer === 0) {
return 0;
}
return max(0, $beginn + $dauer - $jetzt);
}
/**
* @param list<array{Von: int, Bis: int}> $historie
* @return list<array{Von: int, Bis: int}>
*/
public static function bereinigeSperrhistorie(array $historie, int $jetzt): array
{
if ($jetzt < 0) {
throw new InvalidArgumentException('Der Zeitpunkt darf nicht negativ sein.');
}
$grenze = $jetzt - 86400;
$ergebnis = [];
foreach ($historie as $intervall) {
$von = (int) ($intervall['Von'] ?? -1);
$bis = (int) ($intervall['Bis'] ?? -1);
if ($von < 0 || $bis < $von || $bis <= $grenze) {
continue;
}
$ergebnis[] = ['Von' => max($von, $grenze), 'Bis' => min($bis, $jetzt)];
}
return $ergebnis;
}
/** @param list<array{Von: int, Bis: int}> $historie */
public static function sperrsekunden24h(
array $historie,
int $jetzt,
?int $aktiverBeginn = null
): int {
$sekunden = 0;
foreach (self::bereinigeSperrhistorie($historie, $jetzt) as $intervall) {
$sekunden += $intervall['Bis'] - $intervall['Von'];
}
if ($aktiverBeginn !== null && $aktiverBeginn > 0) {
$sekunden += max(0, $jetzt - max($aktiverBeginn, $jetzt - 86400));
}
return min(86400, $sekunden);
}
private static function pruefeKontaktart(int $kontaktart): void
{
if (!in_array($kontaktart, [
self::KONTAKTART_SPERRE_ERHOEHUNG,
self::KONTAKTART_SG_READY,
], true)) {
throw new InvalidArgumentException('Die Kontaktart ist ungueltig.');
}
}
private static function pruefeRueckmeldungsart(int $rueckmeldungsart): void
{
if (!in_array($rueckmeldungsart, [
self::RUECKMELDUNG_LEISTUNG,
self::RUECKMELDUNG_BETRIEB,
], true)) {
throw new InvalidArgumentException('Die Rueckmeldungsart ist ungueltig.');
}
}
private static function pruefeSteuerzustand(int $steuerzustand): void
{
if (!in_array($steuerzustand, [
self::STEUERZUSTAND_NORMAL,
self::STEUERZUSTAND_SPERRE,
self::STEUERZUSTAND_ERHOEHUNG,
], true)) {
throw new InvalidArgumentException('Der Steuerzustand ist ungueltig.');
}
}
private static function pruefeNennleistung(int $nennleistung): void
{
if ($nennleistung <= 0) {
throw new InvalidArgumentException('Die Nennleistung muss groesser als 0 sein.');
}
}
}
+269
View File
@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use DateTimeImmutable;
use InvalidArgumentException;
/**
* Reine Hilfsfunktionen des mehrstufigen Warmwassererwaermers.
*
* Die Klasse kennt keine IP-Symcon-Funktionen und ist dadurch unabhaengig
* vom Laufzeitsystem testbar.
*/
final class WarmwasserRegler
{
/**
* @return list<array{Stufe: int, Leistung_W: int, KontaktID: int}>
*/
public static function dekodiereLeistungsstufen(string $json): array
{
$werte = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($werte) || !self::istListe($werte)) {
throw new InvalidArgumentException('LeistungsStufen muss eine JSON-Liste sein.');
}
$ergebnis = [];
$leistungen = [];
$kontakte = [];
foreach ($werte as $index => $eintrag) {
if (!is_array($eintrag)) {
throw new InvalidArgumentException('Jede Leistungsstufe muss ein Objekt sein.');
}
$stufe = $eintrag['Stufe'] ?? $index + 1;
$leistung = $eintrag['Leistung'] ?? null;
$kontakt = $eintrag['Schaltkontakt_Stufe'] ?? null;
if (!is_int($stufe) || $stufe <= 0) {
throw new InvalidArgumentException('Stufe muss eine positive Ganzzahl sein.');
}
if (!is_int($leistung) || $leistung <= 0) {
throw new InvalidArgumentException('Leistung muss eine positive Ganzzahl sein.');
}
if (!is_int($kontakt) || $kontakt <= 0) {
throw new InvalidArgumentException('Schaltkontakt_Stufe muss eine positive Variablen-ID sein.');
}
if (isset($leistungen[$leistung])) {
throw new InvalidArgumentException('Leistungen muessen eindeutig sein.');
}
if (isset($kontakte[$kontakt])) {
throw new InvalidArgumentException('Schaltkontakte duerfen nur einer Stufe zugeordnet sein.');
}
$leistungen[$leistung] = true;
$kontakte[$kontakt] = true;
$ergebnis[] = [
'Stufe' => $stufe,
'Leistung_W' => $leistung,
'KontaktID' => $kontakt,
];
}
usort(
$ergebnis,
static fn (array $links, array $rechts): int => $links['Leistung_W'] <=> $rechts['Leistung_W']
);
return $ergebnis;
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
* @return list<int>
*/
public static function leistungswerte(array $stufen): array
{
return array_merge([0], array_column($stufen, 'Leistung_W'));
}
/**
* @param list<int> $leistungswerte
* @return list<int>
*/
public static function leistungsangebot(
bool $peakbetrieb,
float $temperatur,
float $mindesttemperatur,
float $maximaltemperatur,
float $hysterese,
bool $stufeAktiv,
array $leistungswerte
): array {
if ($hysterese < 0.0 || $leistungswerte === []) {
throw new InvalidArgumentException('Hysterese und Leistungswerte sind ungueltig.');
}
$mindestladung = $temperatur < $mindesttemperatur
|| ($temperatur < $mindesttemperatur + $hysterese && $stufeAktiv);
if ($peakbetrieb) {
return $mindestladung ? $leistungswerte : [0];
}
if ($mindestladung) {
return [max($leistungswerte)];
}
if ($temperatur < $maximaltemperatur - $hysterese
|| ($temperatur < $maximaltemperatur && $stufeAktiv)
) {
return $leistungswerte;
}
return [0];
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
*/
public static function stufeFuerLeistung(array $stufen, int $leistung): int
{
if ($leistung === 0) {
return 0;
}
foreach ($stufen as $stufe) {
if ($stufe['Leistung_W'] === $leistung) {
return $stufe['Stufe'];
}
}
throw new InvalidArgumentException('Die Sollleistung entspricht keiner konfigurierten Leistungsstufe.');
}
public static function pt1(float $bisher, float $eingang, int $deltaSekunden, int $zeitkonstante): float
{
if ($deltaSekunden <= 0) {
throw new InvalidArgumentException('Das Zeitintervall muss groesser als 0 sein.');
}
if ($zeitkonstante <= 0) {
throw new InvalidArgumentException('Die Zeitkonstante muss groesser als 0 sein.');
}
$alpha = $deltaSekunden / ($zeitkonstante + $deltaSekunden);
return $bisher + $alpha * ($eingang - $bisher);
}
public static function restzeitBisLastwechsel(
int $letzterLastwechsel,
int $jetzt,
int $sperrzeitSekunden
): int {
if ($sperrzeitSekunden < 0) {
throw new InvalidArgumentException('Die Lastwechsel-Sperrzeit darf nicht negativ sein.');
}
if ($letzterLastwechsel <= 0) {
return 0;
}
$verstrichen = max(0, $jetzt - $letzterLastwechsel);
return max(0, $sperrzeitSekunden - $verstrichen);
}
public static function energieKWh(float $leistungW, int $dauerSekunden): float
{
if ($dauerSekunden < 0) {
throw new InvalidArgumentException('Die Dauer darf nicht negativ sein.');
}
return $leistungW * $dauerSekunden / 3600000.0;
}
public static function kannZielErreichen(
float $volumenLiter,
float $isttemperatur,
float $zieltemperatur,
int $verbleibendeSekunden,
int $maximalleistungW
): bool {
if ($zieltemperatur <= $isttemperatur) {
return true;
}
if ($volumenLiter <= 0 || $verbleibendeSekunden < 0 || $maximalleistungW <= 0) {
return false;
}
$erforderlicheWaermeJ = 4186.0 * $volumenLiter * ($zieltemperatur - $isttemperatur);
$verfuegbareWaermeJ = $maximalleistungW * $verbleibendeSekunden;
return $verfuegbareWaermeJ >= $erforderlicheWaermeJ;
}
/**
* @return array{0: float, 1: float}
*/
public static function legionellenTemperaturgrenzen(
bool $aktiv,
int $sekundenSeitAbschluss,
int $minimalintervallTage,
int $maximalintervallTage,
float $mindesttemperatur,
float $maximaltemperatur,
float $legionellentemperatur
): array {
if (!$aktiv) {
return [$mindesttemperatur, $maximaltemperatur];
}
if ($sekundenSeitAbschluss >= $minimalintervallTage * 86400) {
$maximaltemperatur = $legionellentemperatur;
}
if ($sekundenSeitAbschluss >= $maximalintervallTage * 86400) {
$mindesttemperatur = $legionellentemperatur;
}
return [$mindesttemperatur, $maximaltemperatur];
}
/**
* @return array{Zeitpunkt: int, Solltemperatur: float}|null
*/
public static function naechsterZeitplaneintrag(string $json, DateTimeImmutable $jetzt): ?array
{
if (trim($json) === '') {
return null;
}
$werte = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($werte) || !self::istListe($werte)) {
throw new InvalidArgumentException('Zeitplan muss eine JSON-Liste sein.');
}
$naechster = null;
foreach ($werte as $eintrag) {
if (!is_array($eintrag)
|| !is_string($eintrag['Uhrzeit'] ?? null)
|| !is_numeric($eintrag['Solltemperatur'] ?? null)
|| !preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', $eintrag['Uhrzeit'])
) {
throw new InvalidArgumentException('Zeitplaneintraege benoetigen Uhrzeit HH:MM und Solltemperatur.');
}
[$stunde, $minute] = array_map('intval', explode(':', $eintrag['Uhrzeit']));
$zeitpunkt = $jetzt->setTime($stunde, $minute, 0);
if ($zeitpunkt <= $jetzt) {
$zeitpunkt = $zeitpunkt->modify('+1 day');
}
if ($naechster === null || $zeitpunkt < $naechster['Datum']) {
$naechster = [
'Datum' => $zeitpunkt,
'Solltemperatur' => (float) $eintrag['Solltemperatur'],
];
}
}
if ($naechster === null) {
return null;
}
return [
'Zeitpunkt' => $naechster['Datum']->getTimestamp(),
'Solltemperatur' => $naechster['Solltemperatur'],
];
}
/** @param array<mixed> $wert */
private static function istListe(array $wert): bool
{
return $wert === [] || array_keys($wert) === range(0, count($wert) - 1);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
failOnWarning="true"
failOnRisky="true">
<testsuites>
<testsuite name="Verbraucher 1-Stufig">
<directory>tests/VerbraucherEinStufig</directory>
</testsuite>
</testsuites>
</phpunit>
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\Anlagentopologie;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class AnlagentopologieTest extends TestCase
{
public function testHybridwechselrichterVerknuepftPvUndBatterieMitGemeinsamerAcGrenze(): void
{
$topologie = Anlagentopologie::ausJson(
json_encode([[
'ID' => 'hybrid-1',
'Name' => 'Hybrid Ost',
'Typ' => 'hybrid',
'ACNennleistung_kW' => 10.0,
]], JSON_THROW_ON_ERROR),
json_encode([
[
'ID' => 'pv-ost',
'Name' => 'Ostdach',
'DCLeistung_kWp' => 6.4,
'Neigung_Grad' => 25.0,
'Azimut_Grad' => -90.0,
'WechselrichterID' => 'hybrid-1',
'MPPT' => 'A',
'Modulanzahl' => 16,
'Modulleistung_Wp' => 400.0,
],
[
'ID' => 'pv-west',
'Name' => 'Westdach',
'DCLeistung_kWp' => 6.4,
'Neigung_Grad' => 25.0,
'Azimut_Grad' => 90.0,
'WechselrichterID' => 'hybrid-1',
'MPPT' => 'B',
'Modulanzahl' => 16,
'Modulleistung_Wp' => 400.0,
],
], JSON_THROW_ON_ERROR),
json_encode([[
'ID' => 'bat-1',
'Name' => 'Hausspeicher',
'Nennkapazitaet_kWh' => 13.5,
'Nutzkapazitaet_kWh' => 12.8,
'MaxLadeleistung_kW' => 5.0,
'MaxEntladeleistung_kW' => 5.0,
'WechselrichterID' => 'hybrid-1',
'Kopplung' => 'hybrid',
]], JSON_THROW_ON_ERROR),
'2a518707-8d62-4d97-82ce-606c95726500'
);
self::assertSame(Anlagentopologie::VERSION, $topologie['version']);
self::assertSame(12.8, $topologie['summary']['pvDcKwp']);
self::assertSame(10.0, $topologie['summary']['pvAcKw']);
self::assertSame(12.8, $topologie['summary']['usableBatteryKwh']);
self::assertArrayNotHasKey('acExportLimitKw', $topologie['inverters'][0]);
self::assertArrayNotHasKey('acImportLimitKw', $topologie['inverters'][0]);
self::assertSame([[
'inverterId' => 'hybrid-1',
'acRatedKw' => 10.0,
'pvArrayIds' => ['pv-ost', 'pv-west'],
'batteryIds' => ['bat-1'],
]], $topologie['sharedAcLimits']);
}
public function testLeereTopologieBleibtFuerBestandsinstallationenGueltig(): void
{
$topologie = Anlagentopologie::ausJson(
'[]',
'[]',
'[]',
'2a518707-8d62-4d97-82ce-606c95726500'
);
self::assertSame([], $topologie['inverters']);
self::assertSame([], $topologie['pvArrays']);
self::assertSame([], $topologie['batteries']);
self::assertSame(0.0, $topologie['summary']['pvDcKwp']);
}
public function testPvFlaecheBenoetigtEinenVorhandenenPassendenWechselrichter(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('referenzierter Wechselrichter ist nicht vorhanden');
Anlagentopologie::ausJson(
'[]',
json_encode([[
'ID' => 'pv-1',
'DCLeistung_kWp' => 5.0,
'Neigung_Grad' => 30.0,
'Azimut_Grad' => 0.0,
'WechselrichterID' => 'wr-fehlt',
]], JSON_THROW_ON_ERROR),
'[]',
'2a518707-8d62-4d97-82ce-606c95726500'
);
}
public function testDcBatterieBenoetigtEinenHybridwechselrichter(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('DC- und Hybridkopplung');
Anlagentopologie::ausJson(
json_encode([[
'ID' => 'bat-wr',
'Typ' => 'battery',
'ACNennleistung_kW' => 5.0,
]], JSON_THROW_ON_ERROR),
'[]',
json_encode([[
'ID' => 'bat-1',
'Nennkapazitaet_kWh' => 10.0,
'Nutzkapazitaet_kWh' => 9.0,
'MaxLadeleistung_kW' => 4.0,
'MaxEntladeleistung_kW' => 4.0,
'WechselrichterID' => 'bat-wr',
'Kopplung' => 'dc',
]], JSON_THROW_ON_ERROR),
'2a518707-8d62-4d97-82ce-606c95726500'
);
}
public function testModuldatenMuessenGemeinsamGesetztWerden(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Modulanzahl und Modulleistung');
Anlagentopologie::ausJson(
json_encode([[
'ID' => 'pv-wr',
'Typ' => 'pv',
'ACNennleistung_kW' => 5.0,
]], JSON_THROW_ON_ERROR),
json_encode([[
'ID' => 'pv-1',
'DCLeistung_kWp' => 6.0,
'Neigung_Grad' => 30.0,
'Azimut_Grad' => 0.0,
'WechselrichterID' => 'pv-wr',
'Modulanzahl' => 15,
'Modulleistung_Wp' => 0.0,
]], JSON_THROW_ON_ERROR),
'[]',
'2a518707-8d62-4d97-82ce-606c95726500'
);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class BatterieModulstrukturTest extends TestCase
{
private const MODULPFAD = __DIR__ . '/../Batterie/';
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
{
foreach (['module.json', 'form.json', 'module.php', 'README.md'] as $datei) {
self::assertFileExists(self::MODULPFAD . $datei);
}
$metadaten = json_decode(
(string) file_get_contents(self::MODULPFAD . 'module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame('{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}', $metadaten['id']);
self::assertSame('Batterie', $metadaten['name']);
self::assertSame('Enelix', $metadaten['vendor']);
self::assertSame(3, $metadaten['type']);
}
public function testAllePropertiesSindImFormularEnthalten(): void
{
$basis = (string) file_get_contents(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
preg_match_all(
'/RegisterProperty(?:Integer|Float|Boolean|String)\(\'([^\']+)\'/',
$basis . $modul,
$treffer
);
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
foreach (array_values(array_unique($treffer[1])) as $property) {
self::assertStringContainsString(
'"name": "' . $property . '"',
$formular,
$property
);
}
}
public function testModulIstEreignisbasiertUndVerwendetVertrag40(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul);
self::assertStringContainsString('public function MessageSink(', $modul);
self::assertStringContainsString('RegisterMessage($neueID, self::VM_UPDATE)', $modul);
self::assertStringContainsString("'Betriebsart' =>", $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}')
);
}
}
+210
View File
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\BatterieRegler;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class BatterieReglerTest extends TestCase
{
public function testLeistungsbereichBleibtInnerhalbDynamischerGrenzen(): void
{
self::assertSame(
[['Von_W' => -999, 'Bis_W' => 1000]],
BatterieRegler::leistungsangebot(
true, true, false, 50.0, 20.0, 10.0, false,
0.0, 0.0, 1000.9, 999.9
)
);
}
public function testHystereseSchaltetAnDenBisherigenGrenzen(): void
{
self::assertTrue(BatterieRegler::hystereseAktiv(20.0, 20.0, 2.0, false));
self::assertTrue(BatterieRegler::hystereseAktiv(21.0, 20.0, 2.0, true));
self::assertFalse(BatterieRegler::hystereseAktiv(22.1, 20.0, 2.0, true));
}
public function testPvAngebotVerwendetStufenloseBereicheMitBisherigerSocLogik(): void
{
self::assertSame(
[['Von_W' => -1000, 'Bis_W' => 1000]],
BatterieRegler::leistungsangebot(
true, true, false, 50.0, 20.0, 10.0, false,
0.0, 0.0, 1000.0, 1000.0
)
);
self::assertSame(
[1000],
BatterieRegler::leistungsangebot(
true, true, false, 19.0, 20.0, 10.0, true,
0.0, 0.0, 1000.0, 1000.0
)
);
self::assertSame(
[['Von_W' => -1000, 'Bis_W' => 0]],
BatterieRegler::leistungsangebot(
true, true, false, 100.0, 20.0, 10.0, false,
0.0, 0.0, 1000.0, 1000.0
)
);
}
public function testPeakAngebotBeruecksichtigtNetzUndAktuelleBatterieleistung(): void
{
self::assertSame(
[-500],
BatterieRegler::leistungsangebot(
true, true, true, 50.0, 20.0, 10.0, false,
700.0, 200.0, 1000.0, 1000.0
)
);
}
public function testPeakEinschraenkungenWerdenAlsBereicheGemeldet(): void
{
self::assertSame(
[['Von_W' => -1000, 'Bis_W' => 0]],
BatterieRegler::leistungsangebot(
true, true, true, 21.0, 20.0, 10.0, true,
0.0, 0.0, 1000.0, 1000.0
)
);
self::assertSame(
[['Von_W' => 0, 'Bis_W' => 1000]],
BatterieRegler::leistungsangebot(
true, true, true, 10.0, 20.0, 10.0, true,
0.0, 0.0, 1000.0, 1000.0
)
);
}
public function testWechselrichtersteuerungBietetNurNull(): void
{
self::assertSame(
[0],
BatterieRegler::leistungsangebot(
true, false, false, 50.0, 20.0, 10.0, false,
0.0, 0.0, 1000.0, 1000.0
)
);
}
public function testHerstellerunabhaengigeRegisterwerte(): void
{
self::assertSame(
[
'Ladeleistung' => 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::leistungsangebot(
true, true, false, 50.0, 20.0, 10.0, false,
0.0, 0.0, -1.0, 1000.0
);
}
}
+220
View File
@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require_once dirname(__DIR__) . '/examples/Demoanlage/bootstrap.php';
final class DemoanlageBootstrapTest extends TestCase
{
public function testEnergieflussEnthaeltAlleAnlagenteile(): void
{
$html = enelixDemoRenderFlow([
'hour' => 12.5,
'pv' => 6500.0,
'base' => 900.0,
'grid' => -800.0,
'boiler1' => 2400,
'boiler2' => 1500,
'dryer' => 900,
'buffer' => 1800,
'battery' => -1200,
'temp1' => 51.2,
'temp2' => 48.7,
'bufferTemp' => 39.4,
'batterySoc' => 63.5,
'manager' => 'PV',
]);
self::assertStringContainsString('Enelix 2 Energiefluss', $html);
self::assertStringContainsString('Boiler 1', $html);
self::assertStringContainsString('Boiler 2', $html);
self::assertStringContainsString('Entfeuchter', $html);
self::assertStringContainsString('Pufferspeicher', $html);
self::assertStringContainsString('Batterie', $html);
self::assertStringContainsString('63,5 %', $html);
self::assertStringContainsString('Einspeisung', $html);
self::assertStringContainsString('12:30', $html);
}
public function testDiagrammStelltMehrereZeitreihenDar(): void
{
$html = enelixDemoRenderChart(
'Leistungsverlauf',
[
['name' => 'PV', 'color' => '#f4c542', 'values' => [
['time' => 100, 'value' => 1000.0],
['time' => 200, 'value' => 3000.0],
]],
['name' => 'Netz', 'color' => '#e95d5d', 'values' => [
['time' => 100, 'value' => -500.0],
['time' => 200, 'value' => 500.0],
]],
],
'W',
100,
200
);
self::assertStringContainsString('<svg', $html);
self::assertStringContainsString('Leistungsverlauf', $html);
self::assertStringContainsString('PV', $html);
self::assertStringContainsString('Netz', $html);
self::assertSame(2, substr_count($html, '<polyline'));
}
public function testNativeEnergieflussKnotenHatVollstaendigesSchema(): void
{
self::assertSame([
'VariableConfigType' => 0,
'VariableID' => 12345,
'SecondaryVariableID' => 1,
'Name' => 'PV-Anlage',
'Type' => 'solar',
'Grouped' => false,
'CalculationType' => 0,
'InfoVariableID' => 54321,
'Invert' => false,
], enelixDemoEnergyNode(12345, 'PV-Anlage', 'solar', 54321));
self::assertSame(
1,
enelixDemoEnergyNode(12345, 'PV-Anlage', 'solar')['InfoVariableID']
);
}
public function testNativeDiagrammDatenreiheHatSymconFormat(): void
{
self::assertSame([
'variableID' => 12345,
'fillColor' => 'clear',
'strokeColor' => '#f4c542',
'timeOffset' => 0,
'visible' => true,
'title' => 'PV',
'type' => 'line',
'side' => 'left',
'profile' => '~Power',
], enelixDemoChartDataset(12345, 'PV', '#f4c542', '~Power'));
}
public function testDashboardVerwendetNativeEnergieverteilungUndNeueBoilerlisten(): void
{
$source = file_get_contents(dirname(__DIR__) . '/examples/Demoanlage/bootstrap.php');
self::assertIsString($source);
self::assertStringContainsString("IPS_SetHidden(\$legacyFlow, true);", $source);
self::assertStringContainsString("'EnergieverteilungNativ'", $source);
self::assertStringContainsString(
'enelixDemoConfigureVisualization($root, $energyDistribution);',
$source
);
self::assertStringContainsString('ENELIX_DEMO_DUMMY_MODULE', $source);
self::assertStringContainsString("'Boiler1Liste'", $source);
self::assertStringContainsString("'Boiler2Liste'", $source);
self::assertStringContainsString("'PufferListe'", $source);
self::assertStringContainsString("'BatterieListe'", $source);
self::assertStringContainsString('ENELIX_DEMO_BUFFER_MODULE', $source);
self::assertStringContainsString('ENELIX_DEMO_BATTERY_MODULE', $source);
self::assertStringContainsString("'AnlagenWechselrichter'", $source);
self::assertStringContainsString("'AnlagenPVFlaechen'", $source);
self::assertStringContainsString("'AnlagenBatterien'", $source);
self::assertStringContainsString("'EnergyPieAnzeigen' => true", $source);
self::assertStringContainsString("'EnergiediagrammeAnzeigen' => true", $source);
self::assertStringContainsString("'FunFactsAnzeigen' => true", $source);
self::assertStringContainsString("'EnergieflussAnzeigen' => true", $source);
self::assertStringContainsString("'LeistungVariableID' => \$batteryActual", $source);
self::assertStringContainsString("'SOCVariableID' => \$batterySoc", $source);
self::assertStringContainsString("'PrognoseSendeintervall' => 60", $source);
self::assertStringContainsString("'IstleistungVariableID' => \$inverterActual", $source);
self::assertStringContainsString("'BegrenzungVariableID' => \$inverterLimit", $source);
self::assertStringContainsString("'Begrenzungsart' => 'watt'", $source);
self::assertStringContainsString("'EinspeisebegrenzungAktiv' => true", $source);
self::assertStringContainsString("'Einspeisegrenze' => 3000.0", $source);
self::assertStringContainsString("'PrognoseAktiv' => false", $source);
self::assertStringContainsString("'NetzfahrplanAktiv' => false", $source);
self::assertStringNotContainsString("'Einspeisetoleranz'", $source);
self::assertStringContainsString("'demo-hybrid-1'", $source);
self::assertStringContainsString("'demo-pv-1'", $source);
self::assertStringContainsString("'demo-battery-1'", $source);
self::assertStringContainsString("'EnergieflussPVkW'", $source);
self::assertStringContainsString("'~Power'", $source);
self::assertStringContainsString("'~Electricity'", $source);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowPv, 'PV-Anlage', 'solar')",
$source
);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowBase, 'Haus-Grundlast', 'consumer')",
$source
);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowDryer, 'Entfeuchter', 'consumer')",
$source
);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowBuffer, 'Pufferspeicher', 'consumer', \$bufferTemp)",
$source
);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowBattery, 'Batteriespeicher', 'battery', \$batterySoc)",
$source
);
self::assertStringContainsString(
"\$consumerIds = [\$boiler1, \$boiler2, \$dryer, \$buffer, \$battery]",
$source
);
self::assertStringContainsString("'consumerIds' => \$consumerIds", $source);
self::assertStringContainsString("'VerbraucherZuordnung' => '[]'", $source);
self::assertStringContainsString(
"enelixDemoEnsureChart(\$history, 'LeistungsdiagrammNativ'",
$source
);
self::assertStringContainsString('IPS_SetHidden($powerChart, true);', $source);
self::assertStringContainsString(
"enelixDemoLinkInstanceVariables(\$boiler1List, \$boiler1, \$temp1);",
$source
);
self::assertStringContainsString(
"foreach (['Boiler1Link', 'Boiler2Link'] as \$oldLinkIdent)",
$source
);
}
public function testDynamischeUmgebungBleibtBegrenztUndVeraendertDenLastgang(): void
{
$static = enelixDemoEnvironment(12.0, 1000.0, 30, false);
self::assertSame(['base' => 1000.0, 'cloud' => 30.0, 'outside' => 6.0], $static);
$morning = enelixDemoEnvironment(7.5, 1000.0, 30, true);
$night = enelixDemoEnvironment(2.0, 1000.0, 30, true);
self::assertGreaterThan($night['base'], $morning['base']);
self::assertGreaterThanOrEqual(0.0, $morning['cloud']);
self::assertLessThanOrEqual(100.0, $morning['cloud']);
self::assertNotSame($morning['outside'], $night['outside']);
}
public function testBatterieregisterWerdenMitKorrektemVorzeichenAbgebildet(): void
{
self::assertSame(1500.0, enelixDemoBatteryRegisterPower(2, 11, 1500.0));
self::assertSame(-1500.0, enelixDemoBatteryRegisterPower(2, 12, 1500.0));
self::assertSame(0.0, enelixDemoBatteryRegisterPower(1, 11, 1500.0));
self::assertSame(0.0, enelixDemoBatteryRegisterPower(2, 0, 1500.0));
}
public function testWechselrichtergrenzeBegrenztNurDieTatsaechlichePvLeistung(): void
{
self::assertSame(3000.0, enelixDemoLimitPvPower(8000.0, 3000.0));
self::assertSame(2500.0, enelixDemoLimitPvPower(2500.0, 3000.0));
self::assertSame(0.0, enelixDemoLimitPvPower(-100.0, 3000.0));
self::assertSame(0.0, enelixDemoLimitPvPower(8000.0, -100.0));
}
public function testRollbackIstExplizitBestaetigungspflichtig(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Rollback nur mit');
enelixDemoRemove();
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class DokumentationsstrukturTest extends TestCase
{
public function testWaermepumpeDokumentiertDenImplementiertenStand(): void
{
$pfad = __DIR__ . '/../docs/module/Waermepumpe/README.md';
self::assertFileExists($pfad);
$inhalt = file_get_contents($pfad);
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: Implementiert', $inhalt);
self::assertStringContainsString('Konfiguration', $inhalt);
self::assertStringContainsString('Adaption aus Enelix 1', $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');
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: implementiert', $inhalt);
self::assertStringContainsString('Managerkommunikation', $inhalt);
}
public function testLadestationStandAloneDokumentiertDenImplementiertenStand(): void
{
$inhalt = file_get_contents(__DIR__ . '/../docs/module/Ladestation-Stand-Alone/README.md');
self::assertNotFalse($inhalt);
self::assertStringContainsStringIgnoringCase('Status: implementiert', $inhalt);
self::assertStringContainsString('Properties', $inhalt);
self::assertStringContainsString('Fake-HTTP-Transport', $inhalt);
}
public function testEaseeModuleDokumentierenDenImplementiertenStand(): void
{
foreach (['Easee-Gateway', 'Ladestation-Gateway'] as $modul) {
$inhalt = file_get_contents(
__DIR__ . '/../docs/module/' . $modul . '/README.md'
);
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: implementiert', $inhalt);
self::assertStringContainsString('Properties', $inhalt);
self::assertStringContainsString('Tests', $inhalt);
}
}
public function testSchnittstellenDokumentiertSind(): void
{
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle.md');
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle-Easee-Gateway.md');
self::assertFileExists(__DIR__ . '/../docs/Obere-Anschluesse.md');
self::assertFileExists(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
}
public function testManagerDokumentiertDenImplementiertenStand(): void
{
$inhalt = file_get_contents(__DIR__ . '/../docs/module/Manager/README.md');
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: Implementiert', $inhalt);
self::assertStringContainsString('Verteilalgorithmus', $inhalt);
}
public function testVerbraucherEinStufigDokumentiertDenImplementiertenStand(): void
{
$detailInhalt = file_get_contents(
__DIR__ . '/../docs/module/Verbraucher-1-Stufig/README.md'
);
self::assertNotFalse($detailInhalt);
self::assertStringContainsString('Status: implementiert', $detailInhalt);
self::assertStringContainsString('Umschaltabstand', $detailInhalt);
self::assertStringContainsString('Installation und Inbetriebnahme', $detailInhalt);
$modulInhalt = file_get_contents(__DIR__ . '/../VerbraucherEinStufig/README.md');
self::assertNotFalse($modulInhalt);
self::assertStringContainsString('Verhalten ohne separate Rueckmeldung', $modulInhalt);
self::assertStringContainsString('Mindesteinschaltdauer', $modulInhalt);
self::assertStringContainsString(
'composer check:verbraucher-einstufig',
$modulInhalt
);
}
public function testWarmwassererwaermerDokumentiertDenImplementiertenStand(): void
{
$inhalt = file_get_contents(__DIR__ . '/../docs/module/Wassererwaermer/README.md');
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: implementiert', $inhalt);
self::assertStringContainsString('Properties', $inhalt);
self::assertStringContainsString('Managerkommunikation', $inhalt);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
use PHPUnit\Framework\TestCase;
final class EaseeGatewayProtokollTest extends TestCase
{
public function testVerschachtelteSignalRBeobachtungenWerdenExtrahiert(): void
{
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
'arguments' => [
'EH123456',
[
['id' => 109, 'value' => 3],
['ObservationId' => 110, 'Value' => 30],
],
],
], ['EH123456']);
self::assertSame([
['Seriennummer' => 'EH123456', 'ID' => 109, 'Wert' => 3],
['Seriennummer' => 'EH123456', 'ID' => 110, 'Wert' => 30],
], $beobachtungen);
}
public function testNichtAbonnierteStationenUndUnbekannteIdsWerdenIgnoriert(): void
{
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
[
'serialNumber' => 'EH999999',
'id' => 109,
'value' => 3,
],
[
'serialNumber' => 'EH123456',
'id' => 999,
'value' => 1,
],
], ['EH123456']);
self::assertSame([], $beobachtungen);
}
public function 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 testSeriennummerWirdNormalisiert(): void
{
self::assertSame(
'EH123456',
EaseeGatewayProtokoll::seriennummer(' eh123456 ')
);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\EaseeLadestatus;
use PHPUnit\Framework\TestCase;
final class EaseeLadestatusTest extends TestCase
{
public function testFahrzeugUndEinphasigesLadenKommenDirektAusApiWerten(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 3,
'110' => 10,
'120' => 2.3,
'182' => 10.1,
], 16);
self::assertTrue($status['FahrzeugVerbunden']);
self::assertFalse($status['FahrzeugGeladen']);
self::assertSame(3, $status['Fahrzeugstatus']);
self::assertSame(1, $status['Phasenzahl']);
self::assertEqualsWithDelta(2300.0, $status['Istleistung_W'], 0.01);
self::assertEqualsWithDelta(10.1, $status['Ladestrom_A'], 0.01);
}
public function testDreiphasigesLadenWirdNichtAusLeistungGeschaetzt(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 3,
'110' => 30,
'120' => 1.0,
'182' => 2.0,
'183' => 2.2,
'184' => 2.1,
], 32);
self::assertSame(3, $status['Phasenzahl']);
self::assertEqualsWithDelta(2.2, $status['Ladestrom_A'], 0.01);
}
public function testZweiphasigerApiWertWirdNichtAlsEinOderDreiGeschaetzt(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 3,
'110' => 20,
'120' => 4.6,
], 16);
self::assertSame(0, $status['Phasenzahl']);
}
public function testGetrenntUndGeladenFolgenDemEaseeBetriebszustand(): void
{
$getrennt = EaseeLadestatus::ausBeobachtungen(['109' => 1, '110' => 30], 16);
$geladen = EaseeLadestatus::ausBeobachtungen(['109' => 4, '110' => 30], 16);
self::assertFalse($getrennt['FahrzeugVerbunden']);
self::assertSame(0, $getrennt['Phasenzahl']);
self::assertTrue($geladen['FahrzeugVerbunden']);
self::assertTrue($geladen['FahrzeugGeladen']);
self::assertSame(4, $geladen['Fahrzeugstatus']);
}
public function testUnbekannterApiBetriebszustandGibtRegelungNichtFrei(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 99,
'110' => 30,
], 16);
self::assertFalse($status['StatusGueltig']);
self::assertFalse($status['FahrzeugVerbunden']);
self::assertSame(0, $status['Fahrzeugstatus']);
self::assertSame(0, $status['Phasenzahl']);
}
public function testApiUndKabelgrenzenBegrenzenDenKonfiguriertenStrom(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 2,
'110' => 10,
'47' => 25.0,
'104' => 13.7,
], 32);
self::assertSame(13, $status['ApiMaximalstrom_A']);
}
public function testNurSolarladenIstFestErzwungen(): void
{
self::assertFalse(EaseeLadestatus::solarladenErzwungen(0));
self::assertTrue(EaseeLadestatus::solarladenErzwungen(1));
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class EaseeModuleStrukturTest extends TestCase
{
public function testGatewayUndLadestationSindVollstaendigeModule(): void
{
foreach (['EaseeGateway', 'LadestationGateway'] as $modul) {
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
self::assertFileExists(__DIR__ . '/../' . $modul . '/' . $datei);
}
}
}
public function testDatenSchnittstellenPassenZusammen(): void
{
$gateway = $this->module('EaseeGateway');
$ladestation = $this->module('LadestationGateway');
self::assertContains(
'{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}',
$gateway['implemented']
);
self::assertContains(
'{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}',
$gateway['childRequirements']
);
self::assertContains(
'{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}',
$ladestation['parentRequirements']
);
self::assertContains(
'{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}',
$ladestation['implemented']
);
}
public function testZugangsdatenLiegenNurImGateway(): void
{
$gatewayForm = (string) file_get_contents(
__DIR__ . '/../EaseeGateway/form.json'
);
$ladestationForm = (string) file_get_contents(
__DIR__ . '/../LadestationGateway/form.json'
);
self::assertStringContainsString('PasswordTextBox', $gatewayForm);
self::assertStringNotContainsString('PasswordTextBox', $ladestationForm);
self::assertStringNotContainsString('Username', $ladestationForm);
}
public function testLadestationVerwendetApiStatusUndGemeinsamenVertrag(): void
{
$modul = (string) file_get_contents(
__DIR__ . '/../LadestationGateway/module.php'
);
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
self::assertStringContainsString('EaseeLadestatus::ausBeobachtungen', $modul);
self::assertStringContainsString(
"RegisterPropertyBoolean('Ladefreigabe', true)",
$modul
);
self::assertStringContainsString("'FahrzeugVerbunden'", $modul);
self::assertStringContainsString("'Phasenzahl'", $modul);
self::assertStringNotContainsString('7500', $modul);
self::assertStringNotContainsString('Abfrageintervall', $modul);
}
/** @return array<string, mixed> */
private function module(string $name): array
{
return json_decode(
(string) file_get_contents(__DIR__ . '/../' . $name . '/module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\EinspeiseRegler;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class EinspeiseReglerTest extends TestCase
{
public function testUeberschreitungWirdAufDieGesamtanlageBegrenzt(): void
{
$ergebnis = EinspeiseRegler::verteile(
-15000.0,
10000.0,
[
['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 10000],
['ID' => 'wr-2', 'Nennleistung_W' => 5000, 'Istleistung_W' => 5000],
]
);
self::assertSame(10000.0, $ergebnis['ZielGesamtleistung_W']);
self::assertSame(5000.0, $ergebnis['Abregelbedarf_W']);
self::assertSame(6666.667, $ergebnis['Grenzen_W']['wr-1']);
self::assertSame(3333.333, $ergebnis['Grenzen_W']['wr-2']);
}
public function testAbregelungFolgtDerTatsaechlichenErzeugung(): void
{
$ergebnis = EinspeiseRegler::verteile(
-10000.0,
5000.0,
[
['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 10000],
['ID' => 'wr-2', 'Nennleistung_W' => 10000, 'Istleistung_W' => 0],
]
);
self::assertSame(5000.0, $ergebnis['Grenzen_W']['wr-1']);
self::assertSame(0.0, $ergebnis['Grenzen_W']['wr-2']);
}
public function testFreierNetzspielraumHebtEineBestehendeBegrenzungAn(): void
{
$ergebnis = EinspeiseRegler::verteile(
-6000.0,
10000.0,
[
['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 4000],
['ID' => 'wr-2', 'Nennleistung_W' => 10000, 'Istleistung_W' => 2000],
],
['wr-1' => 5000, 'wr-2' => 5000]
);
self::assertSame(14000.0, $ergebnis['ZielGesamtleistung_W']);
self::assertSame(7000.0, $ergebnis['Grenzen_W']['wr-1']);
self::assertSame(7000.0, $ergebnis['Grenzen_W']['wr-2']);
}
public function testRegelungFolgtAuchKleinenAbweichungenStufenlos(): void
{
$ergebnis = EinspeiseRegler::verteile(
-10050.0,
10000.0,
[['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 9000]],
['wr-1' => 8000]
);
self::assertSame(7950.0, $ergebnis['ZielGesamtleistung_W']);
}
public function testStellwerteWerdenInProzentOderWattAusgegeben(): void
{
self::assertSame(62.5, EinspeiseRegler::stellwert(5000.0, 8000.0, 'percent'));
self::assertSame(5000.0, EinspeiseRegler::stellwert(5000.0, 8000.0, 'watt'));
}
public function testUngueltigeBegrenzungsartWirdAbgelehnt(): void
{
$this->expectException(InvalidArgumentException::class);
EinspeiseRegler::stellwert(5000.0, 8000.0, 'volt');
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\EnergieMessung;
use PHPUnit\Framework\TestCase;
final class EnergieMessungTest extends TestCase
{
public function testIntegriertVierLeistungenMitVorzeichenrichtung(): void
{
$werte = EnergieMessung::energieDeltas([
'PV' => 7200.0,
'Haus' => 3600.0,
'Netz' => -1800.0,
'Batterie' => 900.0,
], 300);
self::assertEqualsWithDelta(0.6, $werte['PVEnergie'], 0.000001);
self::assertEqualsWithDelta(0.3, $werte['Hausenergie'], 0.000001);
self::assertSame(0.0, $werte['NetzbezugEnergie']);
self::assertEqualsWithDelta(0.15, $werte['EinspeisungEnergie'], 0.000001);
self::assertEqualsWithDelta(0.075, $werte['BatterieLadenEnergie'], 0.000001);
self::assertSame(0.0, $werte['BatterieEntladenEnergie']);
}
public function testTrenntBezugUndBatterieentladung(): void
{
$werte = EnergieMessung::energieDeltas([
'PV' => -10.0,
'Haus' => -10.0,
'Netz' => 1000.0,
'Batterie' => -2000.0,
], 3600);
self::assertSame(0.0, $werte['PVEnergie']);
self::assertSame(0.0, $werte['Hausenergie']);
self::assertSame(1.0, $werte['NetzbezugEnergie']);
self::assertSame(0.0, $werte['EinspeisungEnergie']);
self::assertSame(0.0, $werte['BatterieLadenEnergie']);
self::assertSame(2.0, $werte['BatterieEntladenEnergie']);
}
public function testBerechnetHausverbrauchAusDerAnlagenbilanz(): void
{
self::assertSame(2700.0, EnergieMessung::hausleistung(4200.0, -800.0, 700.0));
self::assertSame(3500.0, EnergieMessung::hausleistung(2000.0, 500.0, -1000.0));
self::assertSame(0.0, EnergieMessung::hausleistung(500.0, -1200.0, 0.0));
}
public function testBerechnetHausenergieAusDenZaehlerdeltas(): void
{
self::assertSame(8.5, EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0));
self::assertSame(0.0, EnergieMessung::hausenergie(1.0, 0.0, 5.0, 0.0, 0.0));
}
public function testIgnoriertUngueltigeDauerUndNichtEndlicheWerte(): void
{
$werte = EnergieMessung::energieDeltas([
'PV' => INF,
'Haus' => NAN,
'Netz' => 1000.0,
'Batterie' => -1000.0,
], -1);
foreach ($werte as $wert) {
self::assertSame(0.0, $wert);
}
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\LadestationAdapter;
use Belevo\EnelixEMS\LadestationTransport;
use PHPUnit\Framework\TestCase;
final class LadestationAdapterTest extends TestCase
{
/**
* @dataProvider statusFaelle
* @param array<string, string> $konfiguration
* @param array{Rohstatus: int, Istleistung_W: float} $erwartet
*/
public function testStatusaufrufUndDekodierungFuerAlleGeraete(
int $geraetetyp,
array $konfiguration,
string $antwort,
string $erwarteteUrl,
array $erwartet
): void {
$aufrufe = [];
$transport = new LadestationTransport(static function (array $anfrage) use (
&$aufrufe,
$antwort
): string {
$aufrufe[] = $anfrage;
return $antwort;
});
$rohstatus = LadestationAdapter::dekodiereStatus(
$geraetetyp,
$transport->statusLesen($geraetetyp, $konfiguration)
);
self::assertSame($erwarteteUrl, $aufrufe[0]['URL']);
self::assertSame('GET', $aufrufe[0]['Methode']);
self::assertSame($erwartet, $rohstatus);
}
/** @return iterable<string, array<mixed>> */
public function statusFaelle(): iterable
{
yield 'go-e alt' => [
LadestationAdapter::GO_E_ALT,
$this->lokaleKonfiguration(),
$this->goEAntwort(2, 1100),
'http://192.0.2.10/mqtt?payload=',
['Rohstatus' => 2, 'Istleistung_W' => 11000.0],
];
yield 'go-e Gemini' => [
LadestationAdapter::GO_E_GEMINI,
$this->lokaleKonfiguration(),
$this->goEAntwort(2, 2300),
'http://192.0.2.10/api/status',
['Rohstatus' => 2, 'Istleistung_W' => 2300.0],
];
yield 'Smart-me Pico' => [
LadestationAdapter::SMART_ME_PICO,
$this->picoKonfiguration(),
json_encode([
'State' => 2,
'ActiveChargingPower' => 11.0,
'MaxAllowedChargingCurrent' => 16,
], JSON_THROW_ON_ERROR),
'https://api.smart-me.com/pico/charging/pico-id',
['Rohstatus' => 2, 'Istleistung_W' => 11000.0],
];
}
/**
* @dataProvider steuerFaelle
* @param array<string, string> $konfiguration
* @param list<array{Methode: string, URL: string}> $erwarteteAufrufe
*/
public function testSteueraufrufeWerdenUeberFakeTransportGeprueft(
int $geraetetyp,
array $konfiguration,
int $strom,
array $erwarteteAufrufe
): void {
$aufrufe = [];
$transport = new LadestationTransport(static function (array $anfrage) use (&$aufrufe): string {
unset($anfrage['Benutzername'], $anfrage['Passwort']);
$aufrufe[] = $anfrage;
return '{}';
});
$transport->ladestromSetzen($geraetetyp, $konfiguration, $strom);
self::assertSame($erwarteteAufrufe, $aufrufe);
}
/** @return iterable<string, array<mixed>> */
public function steuerFaelle(): iterable
{
yield 'go-e alt laden' => [
LadestationAdapter::GO_E_ALT,
$this->lokaleKonfiguration(),
16,
[
['Methode' => 'GET', 'URL' => 'http://192.0.2.10/mqtt?payload=alw=1'],
['Methode' => 'GET', 'URL' => 'http://192.0.2.10/mqtt?payload=amp=16'],
],
];
yield 'go-e Gemini stoppen' => [
LadestationAdapter::GO_E_GEMINI,
$this->lokaleKonfiguration(),
0,
[[
'Methode' => 'GET',
'URL' => 'http://192.0.2.10/api/set?frc=1&sua=false',
]],
];
yield 'Smart-me Pico laden' => [
LadestationAdapter::SMART_ME_PICO,
$this->picoKonfiguration(),
16,
[[
'Methode' => 'POST',
'URL' => 'https://api.smart-me.com/pico/loadmanagementgroup/current/serial-1?current=16000',
]],
];
}
public function testPicoAnfragenVerwendenBasicAuth(): void
{
$anfrage = LadestationAdapter::statusAnfrage(
LadestationAdapter::SMART_ME_PICO,
$this->picoKonfiguration()
);
self::assertSame('api-user', $anfrage['Benutzername']);
self::assertSame(hash('sha256', __FILE__), $anfrage['Passwort']);
}
/** @return array<string, string> */
private function lokaleKonfiguration(): array
{
return [
'Geraeteadresse' => '192.0.2.10',
'GeraeteID' => '',
'Seriennummer' => '',
'Benutzername' => '',
'Passwort' => '',
];
}
/** @return array<string, string> */
private function picoKonfiguration(): array
{
return [
'Geraeteadresse' => '',
'GeraeteID' => 'pico-id',
'Seriennummer' => 'serial-1',
'Benutzername' => 'api-user',
'Passwort' => hash('sha256', __FILE__),
];
}
private function goEAntwort(int $status, float $leistung): string
{
$nrg = array_fill(0, 12, 0);
$nrg[11] = $leistung;
return json_encode(['car' => $status, 'nrg' => $nrg], JSON_THROW_ON_ERROR);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\LadestationRegler;
use PHPUnit\Framework\TestCase;
final class LadestationReglerTest extends TestCase
{
public function testFahrzeugUndDreiPhasenWerdenWieInEnelixEinsErkannt(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 11000.0,
], false);
self::assertTrue($status['FahrzeugVerbunden']);
self::assertFalse($status['FahrzeugGeladen']);
self::assertSame(3, $status['Fahrzeugstatus']);
self::assertSame(LadestationRegler::PHASEN_DREI, $status['Phasenzahl']);
}
public function testEinphasigesFahrzeugWirdUnterhalb7500WErkannt(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 2300.0,
], false);
self::assertSame(LadestationRegler::PHASEN_EIN, $status['Phasenzahl']);
self::assertEqualsWithDelta(10.0, $status['Ladestrom_A'], 0.01);
}
public function testGeladenWirdNachVerbindungUnterSechsAmpereErkannt(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 0.0,
], true);
self::assertTrue($status['FahrzeugGeladen']);
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 testBetriebsartUndSolarladenBestimmenDasLeistungsangebot(): void
{
$solarPV = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, true, false);
$normalPV = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, false, false);
$solarPeak = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, true, true);
$normalPeak = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, false, true);
self::assertSame(0, $solarPV[0]);
self::assertCount(12, $solarPV);
self::assertSame([3850], $normalPV);
self::assertSame([0], $solarPeak);
self::assertSame($solarPV, $normalPeak);
}
public function testPeakKannJedesFahrzeugAufNullSetzen(): void
{
self::assertSame(0, LadestationRegler::stromFuerLeistung(0, 3));
self::assertSame(6, LadestationRegler::stromFuerLeistung(4150, 3));
self::assertSame(16, LadestationRegler::stromFuerLeistung(11000, 3));
}
}
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class LadestationStandAloneModulstrukturTest extends TestCase
{
private const MODULPFAD = __DIR__ . '/../LadestationStandAlone/';
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
{
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
self::assertFileExists(self::MODULPFAD . $datei);
}
$modul = json_decode(
(string) file_get_contents(self::MODULPFAD . 'module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame('LadestationStandAlone', $modul['name']);
self::assertSame('8.0', json_decode(
(string) file_get_contents(__DIR__ . '/../library.json'),
true,
512,
JSON_THROW_ON_ERROR
)['compatibility']['version']);
}
public function testGemeinsamerVerbrauchervertragWirdVerwendet(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
self::assertStringContainsString('use VerbraucherBasisTrait;', $modul);
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul);
self::assertStringContainsString('Nachrichtenvertrag::pruefeVerbraucherdaten', $modul);
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul);
}
public function testAlleDreiStandAloneGeraeteUndSichereZugangsdatenSindKonfiguriert(): void
{
$form = json_decode(
(string) file_get_contents(self::MODULPFAD . 'form.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$json = json_encode($form, JSON_THROW_ON_ERROR);
self::assertStringContainsString('go-e Charger (alte API)', $json);
self::assertStringContainsString('go-e Charger Gemini', $json);
self::assertStringContainsString('Gemini flex', $json);
self::assertStringContainsString('smart-me Pico', $json);
self::assertStringContainsString('PasswordTextBox', $json);
self::assertStringNotContainsString('Testmodus', $json);
}
public function testVisualisierungsbuttonsLoggingUndDiagnoseSindVorhanden(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString(
"RegisterPropertyBoolean('Ladefreigabe', true)",
$modul
);
self::assertStringContainsString("EnableAction('Ladefreigabe')", $modul);
self::assertStringContainsString("EnableAction('Solarladen')", $modul);
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul);
self::assertStringContainsString("ReadPropertyBoolean('EinstellungenInVisu')", $modul);
self::assertStringContainsString('UnregisterVariable($ident)', $modul);
}
}
+4
View File
@@ -15,7 +15,11 @@ final class LibraryMetadataTest extends TestCase
$daten = json_decode($inhalt, true, 512, JSON_THROW_ON_ERROR); $daten = json_decode($inhalt, true, 512, JSON_THROW_ON_ERROR);
self::assertSame('Enelix EMS', $daten['name']); self::assertSame('Enelix EMS', $daten['name']);
self::assertSame('Enelix', $daten['author']);
self::assertSame('8.0', $daten['compatibility']['version']); self::assertSame('8.0', $daten['compatibility']['version']);
self::assertSame('0.1', $daten['version']);
self::assertSame(1, $daten['build']);
self::assertGreaterThan(0, $daten['date']);
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']); self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']);
} }
} }
+267
View File
@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\Lizenzpruefung;
use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class LizenzpruefungTest extends TestCase
{
private const INSTALLATION_ID = '123e4567-e89b-42d3-a456-426614174000';
public function testStandardLizenzGibtGrundregelungFrei(): void
{
Lizenzpruefung::pruefeLease(
$this->lease('manager_standard'),
self::INSTALLATION_ID,
0,
$this->zeit('2026-09-19T12:00:00Z')
);
self::assertTrue(true);
}
public function testPeakLizenzGibtLastspitzenmodusFrei(): void
{
Lizenzpruefung::pruefeLease(
$this->lease('manager_peak'),
self::INSTALLATION_ID,
2,
$this->zeit('2026-09-19T12:00:00Z')
);
self::assertTrue(true);
}
public function testStandardLizenzSperrtLastspitzenmodus(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Peak Shaving ist nicht lizenziert.');
Lizenzpruefung::pruefeLease(
$this->lease('manager_standard'),
self::INSTALLATION_ID,
1,
$this->zeit('2026-09-19T12:00:00Z')
);
}
public function testAbgelaufeneOfflineFreigabeWirdAbgelehnt(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Offline-Freigabe ist abgelaufen.');
Lizenzpruefung::pruefeLease(
$this->lease('manager_peak'),
self::INSTALLATION_ID,
0,
$this->zeit('2026-10-03T12:00:00Z')
);
}
public function testAndereInstallationWirdAbgelehnt(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('anderen Installation');
Lizenzpruefung::pruefeLease(
$this->lease('manager_standard'),
'223e4567-e89b-42d3-a456-426614174000',
0,
$this->zeit('2026-09-19T12:00:00Z')
);
}
public function testErneuerungWirdAbRefreshAfterFaellig(): void
{
$lease = $this->lease('manager_standard');
self::assertFalse(Lizenzpruefung::erneuerungFaellig(
$lease,
$this->zeit('2026-09-20T11:59:59Z')
));
self::assertTrue(Lizenzpruefung::erneuerungFaellig(
$lease,
$this->zeit('2026-09-20T12:00:00Z')
));
}
public function testLeaseOhneManagerBerechtigungWirdAbgelehnt(): void
{
$lease = $this->lease('consumer_single');
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Manager-Berechtigung fehlt.');
Lizenzpruefung::pruefeLease(
$lease,
self::INSTALLATION_ID,
0,
$this->zeit('2026-09-19T12:00:00Z')
);
}
public function testZweiLizenzierteBoilerWerdenFreigegeben(): void
{
$lease = $this->lease('manager_standard');
$lease['entitlements'][] = [
'sku' => 'ENX-BOILER-MULTI',
'catalog_key' => 'boiler_multi',
'quantity' => 2,
'status' => 'active',
];
self::assertSame(2, Lizenzpruefung::berechtigungsMenge($lease, 'boiler_multi'));
Lizenzpruefung::pruefeVerbrauchermengen($lease, ['boiler_multi' => 2]);
self::assertTrue(true);
}
public function testDritterBoilerBeiZweiLizenzenWirdAbgelehnt(): void
{
$lease = $this->lease('manager_standard');
$lease['entitlements'][] = [
'sku' => 'ENX-BOILER-MULTI',
'catalog_key' => 'boiler_multi',
'quantity' => 2,
'status' => 'active',
];
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(
'Lizenzkontingent fuer boiler_multi ueberschritten: 3 verwendet, 2 lizenziert.'
);
Lizenzpruefung::pruefeVerbrauchermengen($lease, ['boiler_multi' => 3]);
}
public function testVerbraucherkontingenteWerdenJeTypSummiertUndGetrenntGeprueft(): void
{
$lease = $this->lease('manager_standard');
$lease['entitlements'] = array_merge($lease['entitlements'], [
[
'catalog_key' => 'boiler_multi',
'quantity' => 1,
'status' => 'active',
],
[
'catalog_key' => 'boiler_multi',
'quantity' => 1,
'status' => 'active',
],
[
'catalog_key' => 'battery',
'quantity' => 1,
'status' => 'active',
],
[
'catalog_key' => 'battery',
'quantity' => 10,
'status' => 'revoked',
],
]);
self::assertSame(2, Lizenzpruefung::berechtigungsMenge($lease, 'boiler_multi'));
self::assertSame(1, Lizenzpruefung::berechtigungsMenge($lease, 'battery'));
Lizenzpruefung::pruefeVerbrauchermengen($lease, [
'boiler_multi' => 2,
'battery' => 1,
]);
self::assertTrue(true);
}
public function testSeparateWaermepumpenlizenzBleibtUnveraendert(): void
{
$lease = $this->lease('manager_standard');
$lease['entitlements'][] = [
'catalog_key' => 'heat_pump',
'quantity' => 1,
'status' => 'active',
];
$verwendung = ['heat_pump' => 1];
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
$lease,
$verwendung
);
self::assertSame($verwendung, $pruefmengen);
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
}
public function testFreieBestandslizenzDecktEineWaermepumpeAb(): void
{
$lease = $this->lease('manager_standard');
$lease['entitlements'][] = [
'catalog_key' => 'consumer_single',
'quantity' => 1,
'status' => 'active',
];
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
$lease,
['heat_pump' => 1]
);
self::assertSame(['heat_pump' => 0, 'consumer_single' => 1], $pruefmengen);
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
}
public function testBestandslizenzDarfNichtDoppeltVerwendetWerden(): void
{
$lease = $this->lease('manager_standard');
$lease['entitlements'][] = [
'catalog_key' => 'consumer_single',
'quantity' => 1,
'status' => 'active',
];
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
$lease,
['consumer_single' => 1, 'heat_pump' => 1]
);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Lizenzkontingent fuer heat_pump ueberschritten');
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
}
public function testLizenzcodeWirdWieAufDemServerNormalisiert(): void
{
self::assertSame(
'ENX23456789ABCDEFGH',
Lizenzpruefung::normalisiereCode('enx-2345-6789-abcd-efgh')
);
self::assertTrue(Lizenzpruefung::istLizenzcodeGueltig('ENX-2345-6789-ABCD-EFGH'));
self::assertFalse(Lizenzpruefung::istLizenzcodeGueltig('ENX-OOOO-OOOO-OOOO-OOOO'));
}
/**
* @return array<string, mixed>
*/
private function lease(string $sku): array
{
return [
'status' => 'active',
'development' => true,
'installationId' => self::INSTALLATION_ID,
'orderId' => '323e4567-e89b-42d3-a456-426614174000',
'entitlements' => [[
'sku' => $sku,
'catalog_key' => $sku,
'quantity' => 1,
'status' => 'active',
]],
'issuedAt' => '2026-09-19T12:00:00.000Z',
'refreshAfter' => '2026-09-20T12:00:00.000Z',
'offlineUntil' => '2026-10-03T12:00:00.000Z',
];
}
private function zeit(string $wert): int
{
return (new DateTimeImmutable($wert))->getTimestamp();
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class ManagerEnergieModulstrukturTest extends TestCase
{
public function testManagerKonfiguriertMessquellenUndArchivregeln(): void
{
$modul = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
$trait = (string) file_get_contents(__DIR__ . '/../libs/ManagerEnergieTrait.php');
$formular = (string) file_get_contents(__DIR__ . '/../Manager/form.json');
foreach ([
"RegisterPropertyBoolean('EnergyPieAnzeigen', false)",
"RegisterPropertyBoolean('EnergiediagrammeAnzeigen', false)",
"RegisterPropertyBoolean('FunFactsAnzeigen', false)",
"RegisterPropertyBoolean('EnergieflussAnzeigen', false)",
"RegisterPropertyInteger('LeistungsaufzeichnungMinuten', 1)",
"RegisterPropertyInteger('LeistungLoeschenMonate', 12)",
"RegisterPropertyInteger('EnergieaufzeichnungMinuten', 5)",
"RegisterPropertyInteger('EnergieVerdichtenMonate', 12)",
"RegisterPropertyInteger('EnergieLoeschenMonate', 0)",
] as $erwartet) {
self::assertStringContainsString($erwartet, $modul);
}
foreach ([
'AC_SetLoggingStatus',
'AC_SetAggregationType',
'AC_SetCompaction',
'AC_ReAggregateVariable',
'IPS_CreateMedia(4)',
'IPS_SetMediaContent',
'ENERGY_PIE_MODULE_ID',
'ENERGY_FLOW_MODULE_ID',
'energieflussKnoten',
'IPS_DeleteInstance',
'IPS_DeleteMedia',
] as $erwartet) {
self::assertStringContainsString($erwartet, $modul . $trait);
}
foreach ([
'Energieaufzeichnung',
'FormEnergyPieAnzeigen',
'FormEnergiediagrammeAnzeigen',
'FormFunFactsAnzeigen',
'FormEnergieflussAnzeigen',
'MesswerteAnzeigen',
'IstleistungVariableID',
'LeistungVariableID',
'SOCVariableID',
'NetzbezugEnergieVariableID',
'NetzeinspeisungEnergieVariableID',
'ErzeugungsenergieVariableID',
'LadeenergieVariableID',
'EntladeenergieVariableID',
] as $erwartet) {
self::assertStringContainsString($erwartet, $formular);
}
}
public function testManagerFuehrtAlleMessvariablenUndRichtungszaehler(): void
{
$modul = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
foreach ([
'PVLeistungArchiv',
'HausverbrauchLeistungArchiv',
'NetzleistungArchiv',
'BatterieleistungArchiv',
'PVEnergie',
'Hausenergie',
'NetzbezugEnergie',
'EinspeisungEnergie',
'BatterieLadenEnergie',
'BatterieEntladenEnergie',
] as $ident) {
self::assertStringContainsString("'" . $ident . "'", $modul);
}
self::assertStringContainsString("RegisterTimer(
'MesswerteAufzeichnen'", $modul);
self::assertStringContainsString('EnergieMessung::energieDeltas', $modul);
self::assertStringContainsString('EnergieMessung::hausenergie', $modul);
self::assertStringContainsString("RegisterAttributeString('MessungZaehlerstaende'", $modul);
}
public function testFunFactsSindResponsivUndErklaerenUmrechnungen(): void
{
$trait = (string) file_get_contents(__DIR__ . '/../libs/ManagerEnergieTrait.php');
self::assertStringContainsString('grid-template-columns:repeat(3,minmax(0,1fr))', $trait);
self::assertStringContainsString('@media(max-width:820px)', $trait);
self::assertStringContainsString('10 kWh pro Liter Heizoel', $trait);
self::assertStringContainsString('2,4 Flugkilometern', $trait);
self::assertStringContainsString('AC_GetAggregatedValues', $trait);
}
}

Some files were not shown because too many files have changed in this diff Show More