56 Commits
Author SHA1 Message Date
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
118 changed files with 22080 additions and 43 deletions
+1 -1
View File
@@ -21,6 +21,6 @@ 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: 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/
+172
View File
@@ -0,0 +1,172 @@
# Batterie
IP-Symcon-Modul fuer Batteriespeicher im Enelix EMS. Das Modul uebernimmt die
Leistungsangebote und SoC-Logik der bisherigen Enelix-Batterie, arbeitet aber
ereignisbasiert und schreibt Stellwerte direkt in ausgewaehlte
IP-Symcon-Registervariablen.
## Funktionen
- positive Leistung bedeutet Laden, negative Leistung bedeutet Entladen
- 250-W-Grundraster mit 50-W-Feinwerten zwischen -200 W und +200 W
- unterschiedliche Leistungsangebote fuer PV- und Peakbetrieb
- Reserve- und Mindestladezustand mit konfigurierbarer Hysterese
- direkte Registeransteuerung fuer herstellerunabhaengige Batterien, GoodWe,
SolarEdge und Sigenergy
- Umschaltung zwischen Wechselrichter- und Enelix-Steuerung
- ereignisbasierte Reaktion auf alle ausgewaehlten Messwerte
- Managerkommunikation ueber den Enelix-Vertrag 4.0
- Sollwert-Timeout, zeitbasierte Aenderungssperre und periodische Vollmeldung
- optionale Diagnosevariablen und Debug-Logging
Es gibt keinen zyklischen Regel- oder Berechnungstimer. Timer werden nur fuer
Vollmeldungen, den Ablauf einer Manager-Vorgabe und das Ende der
Aenderungssperre verwendet.
## Voraussetzungen
- IP-Symcon ab Version 8.0
- ein eingerichteter Enelix Manager
- numerische Variablen fuer Ladezustand, Netzleistung, Batterieleistung sowie
maximale Lade- und Entladeleistung
- numerische Registervariablen mit Standard- oder benutzerdefinierter Aktion
## Vorzeichen und Einheiten
| Wert | Positiv | Negativ | Einheit |
| --- | --- | --- | --- |
| Manager-Sollleistung | Laden | Entladen | W |
| Batterieleistung | Laden | Entladen | W |
| Netzleistung | Netzbezug | Einspeisung | W |
| Ladezustand | - | - | % |
Sigenergy-Leistungsregister werden in kW beschrieben. Alle anderen
Leistungsregister werden in W beschrieben.
## Batterietypen und Register
| Batterietyp | Management | Modus | Leistungsregister |
| --- | --- | --- | --- |
| Herstellerunabhaengig | WR 0, Enelix 1 | Laden 0, Entladen 1 | getrennt Laden/Entladen in W |
| GoodWe | WR 1, Enelix 2 | Laden 11, Entladen 12 | gemeinsam, absoluter Wert in W |
| SolarEdge | WR 1, Enelix 4 | Laden 3, Entladen 4 | getrennt Laden/Entladen in W |
| Sigenergy | WR 0, Enelix 1 | Laden 3, Entladen 6 | getrennt Laden/Entladen in kW |
Bei Wechselrichtersteuerung bietet die Batterie dem Manager nur [0] an. Das
Modul setzt die Leistungsregister auf 0 und schreibt den passenden
Automatikcode in das Managementregister.
Die Konfigurationsmaske zeigt nach Wahl des Batterietyps nur die benoetigten
Registerauswahlen:
- GoodWe: Management, Modus und gemeinsames Leistungsregister
- alle anderen Typen: Management, Modus, Ladeleistung und Entladeleistung
## Messwerte
| Property | Beschreibung |
| --- | --- |
| MaxLadeleistungVariableID | Aktuell zulaessige maximale Ladeleistung in W |
| MaxEntladeleistungVariableID | Aktuell zulaessige maximale Entladeleistung in W |
| LadezustandVariableID | Ladezustand in % |
| NetzleistungVariableID | Netzbezug beziehungsweise Einspeisung in W |
| IstleistungVariableID | Aktuelle Batterieleistung in W |
| MesswertMaxAlter | Maximales Alter jedes Messwertes in Sekunden |
Alle Messwerte muessen als Integer- oder Floatvariable vorliegen. Fehlt ein
Messwert, ist er veraltet oder liegt der Ladezustand ausserhalb von 0 bis
100 %, meldet sich die Batterie als nicht verfuegbar und bietet nur [0] an.
## Ladezustandslogik
ReserveLadezustand entspricht der bisherigen Peakshaving-Reserve.
MindestLadezustand schuetzt vor weiterer Entladung.
LadezustandHysterese ersetzt die bisher fest im Code hinterlegte
2-Prozent-Hysterese.
Die bisherigen betriebsartabhaengigen Angebote bleiben erhalten:
- Im PV-Betrieb steht oberhalb der Reserve das gesamte Raster zur Verfuegung.
- Unterhalb der Reserve wird die maximale Ladeleistung angeboten.
- Bei vollem Speicher werden nur Entladung und 0 angeboten.
- Im Peakbetrieb wird oberhalb der Reserve der aus Netz- und aktueller
Batterieleistung berechnete Entladewert angeboten.
- Innerhalb der Reserve-Hysterese und unterhalb des Mindestladezustands werden
Laden und Entladen wie bisher eingeschraenkt.
## Manager und Zeitverhalten
Das Modul verwendet den gemeinsamen Enelix-Nachrichtenvertrag 4.0.
Betriebsart ist PV oder Peak. Ein Paket mit Sollleistung_W=null
kuendigt nur die Betriebsart an und loest die Neuberechnung des Angebots aus.
Aenderungssperre ist die ereignisbasierte Entsprechung des frueheren
Idle-Counters. Nach einer echten Sollwertaenderung meldet das Modul
AenderungMoeglich=false. Ein einmaliger Timer hebt die Sperre nach Ablauf
der konfigurierten Sekunden wieder auf.
VorgabeTimeout setzt die Register auf 0, wenn der Manager eine Vorgabe
nicht rechtzeitig erneuert. Meldeintervall erzeugt nur eine periodische
Vollmeldung und fuehrt keinen unabhaengigen Regelzyklus aus.
## Sichtbare Variablen
Immer vorhanden:
- Aktiv: lokale EMS-Freigabe
- Ladestatus: 0 unbekannt, 1 ruhend, 2 laden, 3 entladen
Bei aktivierten Diagnosevariablen werden zusaetzlich unter anderem
Istleistung, Sollleistung, Ladezustand, Messwertgueltigkeit, Hysterese,
Leistungsgrenzen, Energie, Leistungsangebot und der letzte Registerbefehl
angezeigt.
LoggingEin aktiviert zusaetzliche Meldungen im IP-Symcon-Debugprotokoll.
Die Schaltflaeche **Messwerte und Angebot aktualisieren** loest eine sofortige
ereignisartige Aktualisierung aus. **Register auf sicheren Zustand setzen**
verwirft die aktuelle Vorgabe und schreibt eine Leistung von 0.
## Inbetriebnahme
1. Batterietyp und Steuerung auswaehlen.
2. Alle fuenf Messwertvariablen zuordnen.
3. Die automatisch eingeblendeten Registervariablen zuordnen.
4. Reserve, Mindestladezustand und Hysterese pruefen.
5. Diagnosevariablen und Logging fuer die Erstinbetriebnahme aktivieren.
6. Die Batterie im Manager zuordnen.
7. Aktiv einschalten.
8. Unter Aufsicht je einen Lade-, Entlade- und Nullsollwert senden.
9. Registercodes, Vorzeichen, Einheiten und reale Wechselrichterreaktion
kontrollieren.
10. Abschliessend die Wechselrichtersteuerung waehlen und pruefen, dass alle
Leistungsregister auf 0 sowie das Managementregister auf Automatik gehen.
## Fehlerbilder
- **Konfiguration ungueltig:** Variablentypen, Aktionszuordnung und die fuer
den Batterietyp erforderlichen Register pruefen.
- **Messwerte fehlen oder sind veraltet:** Variablenaktualisierung und
MesswertMaxAlter kontrollieren.
- **Register konnten nicht geschrieben werden:** Standard- oder
benutzerdefinierte Aktion der Zielvariablen sowie die Geraetekommunikation
pruefen.
- **Sollwert wird abgewiesen:** Der Wert muss im zuletzt gemeldeten Angebot
enthalten und die Aenderungssperre muss abgelaufen sein.
## Tests
~~~bash
composer test -- --filter 'Batterie(Regler|Modulstruktur)Test'
composer symcon:single -- Batterie
composer check
~~~
Der Symcon-Test prueft die Modulinstanz, direkte GoodWe-Registerbefehle,
Laden und Entladen, Messwertereignisse sowie die Rueckgabe an die
Wechselrichtersteuerung.
## Weiterfuehrende Dokumentation
- [Ausfuehrliche Modulbeschreibung](../docs/module/Batterie/README.md)
- [Manager-Verbraucher-Schnittstelle](../docs/Schnittstelle.md)
- [Migration von Enelix 1](../docs/migration/Batterie.md)
+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"
}
+1175
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
# Easee Gateway
Gemeinsames IP-Symcon-Splittermodul fuer ein Easee-Nutzerkonto. Es verwaltet
Anmeldung, Token, Ereignisverbindung und REST-Stromvorgaben fuer mehrere
Ladestationen.
Konfiguration, Betrieb und Fehlerbehandlung:
[Moduldokumentation](../docs/module/Easee-Gateway/README.md)
Kind-Gateway-Vertrag:
[Schnittstellenbeschreibung](../docs/Schnittstelle-Easee-Gateway.md)
+52
View File
@@ -0,0 +1,52 @@
{
"elements": [
{
"type": "CheckBox",
"name": "Active",
"caption": "Gateway aktiv"
},
{
"type": "ValidationTextBox",
"name": "Username",
"caption": "Easee Benutzername"
},
{
"type": "PasswordTextBox",
"name": "Password",
"caption": "Easee Passwort"
},
{
"type": "CheckBox",
"name": "VerifyCertificate",
"caption": "TLS-Zertifikat pruefen"
},
{
"type": "Label",
"caption": "Ein Gateway wird von allen Easee-Ladestationen desselben Kontos gemeinsam verwendet."
}
],
"actions": [
{
"type": "Button",
"caption": "Verbindung neu aufbauen",
"onClick": "IPS_RequestAction($id, \"Reconnect\", false);"
}
],
"status": [
{
"code": 201,
"icon": "error",
"caption": "Easee-Zugangsdaten fehlen"
},
{
"code": 202,
"icon": "error",
"caption": "Easee-Anmeldung fehlgeschlagen"
},
{
"code": 203,
"icon": "error",
"caption": "Easee-Ereignisverbindung fehlgeschlagen"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"id": "{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}",
"name": "EaseeGateway",
"type": 2,
"vendor": "Enelix",
"aliases": [
"Easee Gateway"
],
"parentRequirements": [
"{79827379-F36E-4ADA-8A95-5F8D1DC92FA9}"
],
"childRequirements": [
"{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}"
],
"implemented": [
"{018EF6B5-AB94-40C6-AA53-46943E824ACF}",
"{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}"
],
"prefix": "ENELIXEASEE",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/EaseeGateway"
}
+791
View File
@@ -0,0 +1,791 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/EaseeGatewayProtokoll.php';
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
class EaseeGateway extends IPSModule
{
private const WEBSOCKET_MODULE_ID = '{D68FD31F-0E90-7019-F16C-1949BD3079EF}';
private const SIMPLE_RX_DATA_ID = '{018EF6B5-AB94-40C6-AA53-46943E824ACF}';
private const SIMPLE_TX_DATA_ID = '{79827379-F36E-4ADA-8A95-5F8D1DC92FA9}';
private const CHILD_REQUEST_DATA_ID = '{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}';
private const CHILD_EVENT_DATA_ID = '{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}';
private const SIGNALR_BASE_URL = 'https://streams.easee.com/hubs/chargers';
private const API_BASE_URL = 'https://api.easee.com';
private const RECORD_SEPARATOR = "\x1e";
public function Create(): void
{
parent::Create();
$this->RegisterPropertyBoolean('Active', true);
$this->RegisterPropertyString('Username', '');
$this->RegisterPropertyString('Password', '');
$this->RegisterPropertyBoolean('VerifyCertificate', true);
// Nur fuer den automatisierten IP-Symcon-Funktionstest.
$this->RegisterPropertyBoolean('Testmodus', false);
$this->RegisterAttributeString('ObservationCache', '{}');
$this->RegisterVariableBoolean('Connected', 'Easee verbunden', '~Switch', 10);
$this->RegisterVariableInteger(
'SubscriptionCount',
'Angemeldete Ladestationen',
'',
20
);
$this->RegisterVariableString('LastError', 'Letzter Fehler', '', 30);
$this->RegisterTimer(
'MaintainConnectionTimer',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'MaintainConnection', false);"
);
$this->RegisterTimer(
'TokenRefreshTimer',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'RefreshToken', false);"
);
$this->RequireParent(self::WEBSOCKET_MODULE_ID);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->SetTimerInterval('MaintainConnectionTimer', 0);
$this->SetTimerInterval('TokenRefreshTimer', 0);
$this->SetBuffer('SignalRReady', '0');
$this->SetBuffer('ReceiveBuffer', '');
$this->SetBuffer('Subscriptions', '{}');
$this->SetBuffer('SubscribedThisConnection', '{}');
$this->SetBuffer('PendingInvocations', '{}');
$this->SetValue('SubscriptionCount', 0);
$this->setzeVerbunden(false);
if (!$this->ReadPropertyBoolean('Active')) {
$this->SetStatus(104);
return;
}
if ($this->ReadPropertyBoolean('Testmodus')) {
$this->SetStatus(102);
$this->setzeLetztenFehler('');
$this->setzeVerbunden(true);
return;
}
if (
trim($this->ReadPropertyString('Username')) === ''
|| $this->ReadPropertyString('Password') === ''
) {
$this->SetStatus(201);
$this->setzeLetztenFehler('Easee-Benutzername oder Passwort fehlt.');
return;
}
if (!$this->stelleWebSocketParentSicher()) {
$this->SetStatus(203);
return;
}
$this->SetTimerInterval('MaintainConnectionTimer', 10000);
$this->SetTimerInterval('TokenRefreshTimer', 1800000);
if (!$this->erneuereZugangsdaten(false)) {
$this->SetStatus(202);
return;
}
$this->verbindeSignalR();
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'MaintainConnection':
$this->pflegeVerbindung();
return;
case 'RefreshToken':
if ($this->erneuereZugangsdaten(false)) {
$this->verbindeSignalR();
}
return;
case 'Reconnect':
$this->SetBuffer('AccessToken', '');
$this->SetBuffer('RefreshToken', '');
if (
$this->ReadPropertyBoolean('Testmodus')
|| $this->erneuereZugangsdaten(false)
) {
$this->verbindeSignalR();
}
return;
case 'TestObservation':
if (!$this->ReadPropertyBoolean('Testmodus') || !is_string($wert)) {
throw new InvalidArgumentException('TestObservation ist nur im Testmodus zulaessig.');
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException('TestObservation erwartet ein JSON-Objekt.');
}
$this->veroeffentlicheBeobachtung(
EaseeGatewayProtokoll::seriennummer((string) ($daten['serialNumber'] ?? '')),
(int) ($daten['id'] ?? 0),
$daten['value'] ?? null
);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
public function GetConfigurationForParent(): string
{
return json_encode([
'Active' => $this->ReadPropertyBoolean('Active')
&& !$this->ReadPropertyBoolean('Testmodus'),
'URL' => $this->GetBuffer('WebSocketURL'),
'VerifyCertificate' => $this->ReadPropertyBoolean('VerifyCertificate'),
'Headers' => '[]',
], JSON_THROW_ON_ERROR);
}
public function ReceiveData($jsonString): void
{
$paket = json_decode((string) $jsonString, true);
if (!is_array($paket) || !isset($paket['Buffer'])) {
return;
}
$this->SetBuffer('LastReceive', (string) time());
$puffer = $this->GetBuffer('ReceiveBuffer') . (string) $paket['Buffer'];
$rahmen = explode(self::RECORD_SEPARATOR, $puffer);
$this->SetBuffer('ReceiveBuffer', (string) array_pop($rahmen));
foreach ($rahmen as $eintrag) {
if ($eintrag !== '') {
$this->verarbeiteSignalRRahmen($eintrag);
}
}
}
public function ForwardData($jsonString): string
{
$paket = json_decode((string) $jsonString, true);
if (!is_array($paket) || !isset($paket['Buffer'])) {
return json_encode([
'success' => false,
'error' => 'Ungueltiges Datenpaket.',
], JSON_THROW_ON_ERROR);
}
return $this->ProcessStationRequest((string) $paket['Buffer']);
}
public function ProcessStationRequest($jsonString): string
{
$anfrage = json_decode((string) $jsonString, true);
if (!is_array($anfrage) || !isset($anfrage['action'])) {
return json_encode([
'success' => false,
'error' => 'Ungueltige Gateway-Anfrage.',
], JSON_THROW_ON_ERROR);
}
$seriennummer = EaseeGatewayProtokoll::seriennummer(
(string) ($anfrage['serialNumber'] ?? '')
);
switch ($anfrage['action']) {
case 'Subscribe':
case 'GetState':
if ($seriennummer === '') {
return json_encode([
'success' => false,
'error' => 'Seriennummer fehlt.',
], JSON_THROW_ON_ERROR);
}
$this->registriereAbonnement($seriennummer);
return $this->erstelleStatusantwort($seriennummer);
case 'SetDynamicChargerCurrent':
return json_encode($this->setzeDynamischenLadestrom(
$seriennummer,
(float) ($anfrage['amps'] ?? -1)
), JSON_THROW_ON_ERROR);
}
return json_encode([
'success' => false,
'error' => 'Unbekannte Gateway-Aktion.',
], JSON_THROW_ON_ERROR);
}
private function stelleWebSocketParentSicher(): bool
{
$instanz = IPS_GetInstance($this->InstanceID);
$parentID = (int) $instanz['ConnectionID'];
if ($parentID <= 0 && !$this->RequireParent(self::WEBSOCKET_MODULE_ID)) {
$this->setzeLetztenFehler('WebSocket-Client konnte nicht erstellt werden.');
return false;
}
$instanz = IPS_GetInstance($this->InstanceID);
$parentID = (int) $instanz['ConnectionID'];
if ($parentID <= 0 || !IPS_InstanceExists($parentID)) {
$this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.');
return false;
}
$parent = IPS_GetInstance($parentID);
if ($parent['ModuleInfo']['ModuleID'] !== self::WEBSOCKET_MODULE_ID) {
$this->setzeLetztenFehler('Ungueltige WebSocket-Schnittstelle.');
return false;
}
return true;
}
private function pflegeVerbindung(): void
{
if (!$this->ReadPropertyBoolean('Active') || $this->ReadPropertyBoolean('Testmodus')) {
return;
}
$jetzt = time();
$letzteAushandlung = (int) $this->GetBuffer('LastNegotiation');
$letzterEmpfang = (int) $this->GetBuffer('LastReceive');
$bereit = $this->GetBuffer('SignalRReady') === '1';
if ($bereit) {
$this->sendeSignalRRahmen(['type' => 6]);
if ($letzterEmpfang > 0 && ($jetzt - $letzterEmpfang) <= 90) {
return;
}
$this->SetBuffer('SignalRReady', '0');
$this->setzeVerbunden(false);
}
if (($jetzt - $letzteAushandlung) >= 45) {
$this->verbindeSignalR();
return;
}
$this->sendeHandshake();
}
private function verbindeSignalR(): bool
{
if ($this->ReadPropertyBoolean('Testmodus')) {
$this->SetStatus(102);
$this->setzeVerbunden(true);
return true;
}
if (!$this->stelleAccessTokenSicher()) {
$this->SetStatus(202);
return false;
}
$this->SetBuffer('LastNegotiation', (string) time());
$token = $this->GetBuffer('AccessToken');
$antwort = $this->httpAnfrage(
'POST',
self::SIGNALR_BASE_URL . '/negotiate?negotiateVersion=1',
'',
$token
);
if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) {
$token = $this->GetBuffer('AccessToken');
$antwort = $this->httpAnfrage(
'POST',
self::SIGNALR_BASE_URL . '/negotiate?negotiateVersion=1',
'',
$token
);
}
if (!$antwort['success']) {
$this->SetStatus(203);
$this->setzeLetztenFehler(
'Easee-Ereignisverbindung fehlgeschlagen: HTTP '
. $antwort['httpCode']
. ($antwort['error'] !== '' ? ' / ' . $antwort['error'] : '')
);
return false;
}
$daten = json_decode($antwort['body'], true);
$verbindungstoken = is_array($daten)
? (string) ($daten['connectionToken'] ?? $daten['connectionId'] ?? '')
: '';
if ($verbindungstoken === '') {
$this->SetStatus(203);
$this->setzeLetztenFehler('Easee liefert kein Verbindungstoken.');
return false;
}
$url = 'wss://streams.easee.com/hubs/chargers?id='
. rawurlencode($verbindungstoken)
. '&access_token=' . rawurlencode($token);
$this->SetBuffer('WebSocketURL', $url);
$this->SetBuffer('SignalRReady', '0');
$this->SetBuffer('ReceiveBuffer', '');
$this->SetBuffer('SubscribedThisConnection', '{}');
$this->SetBuffer('PendingInvocations', '{}');
$this->WriteAttributeString('ObservationCache', '{}');
$this->setzeVerbunden(false);
if (!$this->konfiguriereWebSocketParent($url)) {
$this->SetStatus(203);
return false;
}
$this->sendeHandshake();
return true;
}
private function konfiguriereWebSocketParent(string $url): bool
{
$parentID = (int) IPS_GetInstance($this->InstanceID)['ConnectionID'];
if ($parentID <= 0) {
$this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.');
return false;
}
IPS_SetProperty($parentID, 'Active', true);
IPS_SetProperty($parentID, 'URL', $url);
IPS_SetProperty(
$parentID,
'VerifyCertificate',
$this->ReadPropertyBoolean('VerifyCertificate')
);
IPS_SetProperty($parentID, 'Headers', '[]');
IPS_ApplyChanges($parentID);
return true;
}
private function sendeHandshake(): void
{
$this->sendeRohdaten(
json_encode(['protocol' => 'json', 'version' => 1], JSON_THROW_ON_ERROR)
. self::RECORD_SEPARATOR
);
}
/** @param array<string, mixed> $rahmen */
private function sendeSignalRRahmen(array $rahmen): void
{
$this->sendeRohdaten(
json_encode($rahmen, JSON_THROW_ON_ERROR) . self::RECORD_SEPARATOR
);
}
private function sendeRohdaten(string $nutzdaten): void
{
if ($this->ReadPropertyBoolean('Testmodus')) {
return;
}
$this->SendDataToParent(json_encode([
'DataID' => self::SIMPLE_TX_DATA_ID,
'Buffer' => $nutzdaten,
], JSON_THROW_ON_ERROR));
}
private function verarbeiteSignalRRahmen(string $rahmen): void
{
if ($rahmen === '{}') {
$this->SetBuffer('SignalRReady', '1');
$this->SetStatus(102);
$this->setzeLetztenFehler('');
$this->setzeVerbunden(true);
$this->sendeAlleAbonnements();
return;
}
$nachricht = json_decode($rahmen, true);
if (!is_array($nachricht)) {
$this->setzeLetztenFehler('Ungueltige Easee-Ereignisnachricht.');
return;
}
if (isset($nachricht['error'])) {
$this->SetStatus(203);
$this->setzeLetztenFehler('Easee-Ereignisfehler: ' . $nachricht['error']);
return;
}
$typ = (int) ($nachricht['type'] ?? 0);
if ($typ === 6) {
return;
}
if ($typ === 7) {
$this->SetBuffer('SignalRReady', '0');
$this->setzeVerbunden(false);
$this->setzeLetztenFehler('Easee-Ereignisverbindung wurde beendet.');
return;
}
$seriennummer = '';
if ($typ === 3 && isset($nachricht['invocationId'])) {
$offen = $this->lesePufferArray('PendingInvocations');
$aufrufID = (string) $nachricht['invocationId'];
$seriennummer = (string) ($offen[$aufrufID] ?? '');
unset($offen[$aufrufID]);
$this->SetBuffer(
'PendingInvocations',
json_encode($offen, JSON_THROW_ON_ERROR)
);
}
foreach (['arguments', 'result'] as $feld) {
if (!isset($nachricht[$feld])) {
continue;
}
$abonnements = array_keys($this->lesePufferArray('Subscriptions'));
foreach (EaseeGatewayProtokoll::extrahiereBeobachtungen(
$nachricht[$feld],
$abonnements,
$seriennummer
) as $beobachtung) {
$this->veroeffentlicheBeobachtung(
$beobachtung['Seriennummer'],
$beobachtung['ID'],
$beobachtung['Wert']
);
}
}
}
private function registriereAbonnement(string $seriennummer): void
{
$abonnements = $this->lesePufferArray('Subscriptions');
if (!isset($abonnements[$seriennummer])) {
$abonnements[$seriennummer] = true;
$this->SetBuffer(
'Subscriptions',
json_encode($abonnements, JSON_THROW_ON_ERROR)
);
$this->SetValue('SubscriptionCount', count($abonnements));
}
if ($this->GetBuffer('SignalRReady') === '1') {
$this->sendeAbonnement($seriennummer);
}
}
private function sendeAlleAbonnements(): void
{
foreach (array_keys($this->lesePufferArray('Subscriptions')) as $seriennummer) {
$this->sendeAbonnement((string) $seriennummer);
}
}
private function sendeAbonnement(string $seriennummer): void
{
$gesendet = $this->lesePufferArray('SubscribedThisConnection');
if (isset($gesendet[$seriennummer])) {
return;
}
$aufrufID = (string) (((int) $this->GetBuffer('InvocationID')) + 1);
$this->SetBuffer('InvocationID', $aufrufID);
$offen = $this->lesePufferArray('PendingInvocations');
$offen[$aufrufID] = $seriennummer;
$this->SetBuffer(
'PendingInvocations',
json_encode($offen, JSON_THROW_ON_ERROR)
);
$this->sendeSignalRRahmen([
'type' => 1,
'invocationId' => $aufrufID,
'target' => 'SubscribeWithCurrentState',
'arguments' => [$seriennummer, true],
]);
$gesendet[$seriennummer] = true;
$this->SetBuffer(
'SubscribedThisConnection',
json_encode($gesendet, JSON_THROW_ON_ERROR)
);
}
private function veroeffentlicheBeobachtung(
string $seriennummer,
int $id,
$wert
): void {
if (
$seriennummer === ''
|| !in_array($id, EaseeGatewayProtokoll::BEOBACHTUNGEN, true)
) {
return;
}
$cache = $this->leseAttributArray('ObservationCache');
if (!isset($cache[$seriennummer]) || !is_array($cache[$seriennummer])) {
$cache[$seriennummer] = [];
}
if ($id === 109 && (int) $wert === 1) {
foreach ([110, 120, 182, 183, 184, 185] as $sessionID) {
$cache[$seriennummer][(string) $sessionID] = 0;
}
}
$cache[$seriennummer][(string) $id] = $wert;
$cache[$seriennummer]['updated'] = time();
$this->WriteAttributeString(
'ObservationCache',
json_encode($cache, JSON_THROW_ON_ERROR)
);
$this->SendDataToChildren(json_encode([
'DataID' => self::CHILD_EVENT_DATA_ID,
'Buffer' => json_encode([
'type' => 'Observation',
'serialNumber' => $seriennummer,
'id' => $id,
'value' => $wert,
'timestamp' => time(),
], JSON_THROW_ON_ERROR),
], JSON_THROW_ON_ERROR));
}
private function erstelleStatusantwort(string $seriennummer): string
{
$cache = $this->leseAttributArray('ObservationCache');
return json_encode([
'success' => true,
'connected' => $this->GetBuffer('SignalRReady') === '1'
|| $this->ReadPropertyBoolean('Testmodus'),
'state' => $cache[$seriennummer] ?? [],
], JSON_THROW_ON_ERROR);
}
/** @return array<string, mixed> */
private function setzeDynamischenLadestrom(string $seriennummer, float $ampere): array
{
if ($seriennummer === '') {
return ['success' => false, 'error' => 'Seriennummer fehlt.'];
}
if (
!is_finite($ampere)
|| floor($ampere) !== $ampere
|| $ampere < 0
|| $ampere > 32
|| ($ampere > 0 && $ampere < 6)
) {
return [
'success' => false,
'error' => 'Strom muss 0 A oder eine ganze Zahl zwischen 6 und 32 A sein.',
];
}
if ($this->ReadPropertyBoolean('Testmodus')) {
$this->SetBuffer('LetzterTestbefehl', json_encode([
'serialNumber' => $seriennummer,
'amps' => $ampere,
], JSON_THROW_ON_ERROR));
return ['success' => true, 'httpCode' => 200, 'body' => '{}'];
}
return $this->autorisierteApiAnfrage(
'POST',
'/api/chargers/' . rawurlencode($seriennummer)
. '/commands/set_dynamic_charger_current',
json_encode([
'amps' => (int) round($ampere),
'minutes' => 0,
], JSON_THROW_ON_ERROR)
);
}
/** @return array<string, mixed> */
private function autorisierteApiAnfrage(
string $methode,
string $pfad,
string $inhalt
): array {
if (!$this->stelleAccessTokenSicher()) {
return ['success' => false, 'error' => 'Kein Easee-Access-Token.'];
}
$antwort = $this->httpAnfrage(
$methode,
self::API_BASE_URL . $pfad,
$inhalt,
$this->GetBuffer('AccessToken')
);
if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) {
$antwort = $this->httpAnfrage(
$methode,
self::API_BASE_URL . $pfad,
$inhalt,
$this->GetBuffer('AccessToken')
);
}
if (!$antwort['success']) {
return [
'success' => false,
'error' => $antwort['error'] !== ''
? $antwort['error']
: 'Easee-HTTP-Fehler ' . $antwort['httpCode'] . '.',
'httpCode' => $antwort['httpCode'],
];
}
return [
'success' => true,
'httpCode' => $antwort['httpCode'],
'body' => $antwort['body'],
];
}
private function erneuereZugangsdaten(bool $neuVerbinden = true): bool
{
$tokenpaar = null;
$refreshToken = $this->GetBuffer('RefreshToken');
if ($refreshToken !== '') {
$tokenpaar = $this->fordereTokenpaarAn(
self::API_BASE_URL . '/api/accounts/refresh_token',
['refreshToken' => $refreshToken]
);
}
if ($tokenpaar === null) {
$tokenpaar = $this->fordereTokenpaarAn(
self::API_BASE_URL . '/api/accounts/login',
[
'userName' => $this->ReadPropertyString('Username'),
'password' => $this->ReadPropertyString('Password'),
]
);
}
if ($tokenpaar === null || !isset($tokenpaar['accessToken'])) {
$this->setzeLetztenFehler('Easee-Anmeldung oder Token-Erneuerung fehlgeschlagen.');
$this->SetStatus(202);
return false;
}
$this->SetBuffer('AccessToken', (string) $tokenpaar['accessToken']);
if (isset($tokenpaar['refreshToken'])) {
$this->SetBuffer('RefreshToken', (string) $tokenpaar['refreshToken']);
}
if ($neuVerbinden) {
return $this->verbindeSignalR();
}
return true;
}
private function stelleAccessTokenSicher(): bool
{
return $this->GetBuffer('AccessToken') !== ''
|| $this->erneuereZugangsdaten(false);
}
/** @param array<string, string> $nutzdaten
* @return array<string, mixed>|null
*/
private function fordereTokenpaarAn(string $url, array $nutzdaten): ?array
{
$antwort = $this->httpAnfrage(
'POST',
$url,
json_encode($nutzdaten, JSON_THROW_ON_ERROR)
);
if (!$antwort['success']) {
return null;
}
$daten = json_decode($antwort['body'], true);
return is_array($daten) ? $daten : null;
}
/**
* @return array{success: bool, body: string, error: string, httpCode: int}
*/
private function httpAnfrage(
string $methode,
string $url,
string $inhalt = '',
string $bearerToken = ''
): array {
$kopf = ['Accept: application/json', 'Content-Type: application/json'];
if ($bearerToken !== '') {
$kopf[] = 'Authorization: Bearer ' . $bearerToken;
}
$curl = curl_init($url);
if ($curl === false) {
return [
'success' => false,
'body' => '',
'error' => 'HTTP-Anfrage konnte nicht initialisiert werden.',
'httpCode' => 0,
];
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $methode,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 30,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HTTPHEADER => $kopf,
CURLOPT_POSTFIELDS => $inhalt,
CURLOPT_SSL_VERIFYPEER => $this->ReadPropertyBoolean('VerifyCertificate'),
CURLOPT_SSL_VERIFYHOST => $this->ReadPropertyBoolean('VerifyCertificate') ? 2 : 0,
]);
$antwort = curl_exec($curl);
$fehler = curl_error($curl);
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
return [
'success' => $antwort !== false
&& $fehler === ''
&& $httpStatus >= 200
&& $httpStatus < 300,
'body' => $antwort === false ? '' : (string) $antwort,
'error' => $fehler,
'httpCode' => $httpStatus,
];
}
private function setzeVerbunden(bool $verbunden): void
{
$this->SetValue('Connected', $verbunden);
$this->SendDataToChildren(json_encode([
'DataID' => self::CHILD_EVENT_DATA_ID,
'Buffer' => json_encode([
'type' => 'GatewayStatus',
'connected' => $verbunden,
], JSON_THROW_ON_ERROR),
], JSON_THROW_ON_ERROR));
}
private function setzeLetztenFehler(string $nachricht): void
{
$this->SetValue('LastError', $nachricht);
}
/** @return array<string, mixed> */
private function leseAttributArray(string $name): array
{
$wert = json_decode($this->ReadAttributeString($name), true);
return is_array($wert) ? $wert : [];
}
/** @return array<string, mixed> */
private function lesePufferArray(string $name): array
{
$wert = json_decode($this->GetBuffer($name), true);
return is_array($wert) ? $wert : [];
}
}
+11
View File
@@ -0,0 +1,11 @@
# Ladestation Gateway
Eventbasiertes Enelix-EMS-Verbrauchermodul fuer eine Easee-Ladestation. Das
Modul liest Fahrzeugstatus und aktive 1-/3-Phasenladung direkt aus Easee
Observations und sendet Stromvorgaben ueber das verbundene Easee Gateway.
Konfiguration, Betriebsarten und Tests:
[Moduldokumentation](../docs/module/Ladestation-Gateway/README.md)
Gateway-Vertrag:
[Schnittstellenbeschreibung](../docs/Schnittstelle-Easee-Gateway.md)
+116
View File
@@ -0,0 +1,116 @@
{
"elements": [
{
"type": "Select",
"name": "Betriebsmodus",
"caption": "Variante",
"options": [
{
"caption": "Easee",
"value": 0
},
{
"caption": "Easee - Nur Solarladen",
"value": 1
}
]
},
{
"type": "ValidationTextBox",
"name": "Ladestationskennung",
"caption": "Easee Seriennummer"
},
{
"type": "NumberSpinner",
"name": "MaximalerLadestrom",
"caption": "Maximaler Ladestrom",
"minimum": 6,
"maximum": 32,
"suffix": " A"
},
{
"type": "CheckBox",
"name": "Ladefreigabe",
"caption": "Ladefreigabe beim Start"
},
{
"type": "CheckBox",
"name": "Solarladen",
"caption": "Solarladen beim Start"
},
{
"type": "NumberSpinner",
"name": "PrioritaetPV",
"caption": "Prioritaet PV",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "PrioritaetPeak",
"caption": "Prioritaet Peak",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Meldeintervall",
"caption": "Meldeintervall",
"minimum": 1,
"suffix": " s"
},
{
"type": "NumberSpinner",
"name": "VorgabeTimeout",
"caption": "Vorgabe-Timeout",
"minimum": 1,
"suffix": " s"
},
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Einstellungen in Visualisierung"
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Diagnoseprotokoll aktivieren"
},
{
"type": "Label",
"caption": "Easee-Zugangsdaten werden ausschliesslich im verbundenen Easee Gateway gespeichert."
}
],
"actions": [
{
"type": "Button",
"caption": "Status neu anfordern",
"onClick": "IPS_RequestAction($id, \"StatusAnfordern\", false);"
}
],
"status": [
{
"code": 201,
"icon": "error",
"caption": "Konfiguration ungueltig"
},
{
"code": 202,
"icon": "error",
"caption": "Easee Gateway nicht verbunden"
},
{
"code": 203,
"icon": "inactive",
"caption": "Warte auf Easee-Status"
},
{
"code": 204,
"icon": "error",
"caption": "Easee Ladestation meldet Fehler"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"id": "{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}",
"name": "LadestationGateway",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Ladestation Gateway"
],
"parentRequirements": [
"{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}"
],
"childRequirements": [],
"implemented": [
"{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}"
],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/LadestationGateway"
}
+985
View File
@@ -0,0 +1,985 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/LadestationRegler.php';
require_once __DIR__ . '/../libs/EaseeLadestatus.php';
require_once __DIR__ . '/../libs/EaseeGatewayProtokoll.php';
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
use Belevo\EnelixEMS\EaseeLadestatus;
use Belevo\EnelixEMS\LadestationRegler;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
{
use VerbraucherBasisTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const EASEE_GATEWAY_MODULE_ID = '{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}';
private const GATEWAY_REQUEST_DATA_ID = '{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}';
private const STATUS_AKTIV = 102;
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
private const STATUS_GATEWAY_GETRENNT = 202;
private const STATUS_WARTET_AUF_DATEN = 203;
private const STATUS_LADEFEHLER = 204;
/** @var list<string> */
private const DIAGNOSE_VARIABLEN = [
'Istleistung',
'Leistungsquelle',
'Sollleistung',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
'Stoertext',
'GatewayVerbunden',
'ApiMaximalstrom',
'LetzterGeraetebefehl',
'LeistungsangebotDiagnose',
];
public function Create(): void
{
parent::Create();
$this->registriereVerbraucherBasis();
$this->RegisterPropertyInteger('Betriebsmodus', EaseeLadestatus::BETRIEBSMODUS_EASEE);
$this->RegisterPropertyString('Ladestationskennung', '');
$this->RegisterPropertyInteger('MaximalerLadestrom', 16);
$this->RegisterPropertyBoolean('Ladefreigabe', false);
$this->RegisterPropertyBoolean('Solarladen', true);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
$this->RegisterVariableBoolean(
'FahrzeugVerbunden',
'Fahrzeug verbunden',
'~Switch',
100
);
$this->RegisterVariableBoolean(
'FahrzeugGeladen',
'Fahrzeug geladen',
'~Switch',
110
);
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 120);
$this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 130);
$this->RegisterVariableInteger('Phasenzahl', 'Phasenzahl', '', 140);
$this->RegisterAttributeBoolean('InitialwerteGesetzt', false);
$this->RegisterAttributeBoolean('KonfigurationLadefreigabe', false);
$this->RegisterAttributeBoolean('KonfigurationSolarladen', true);
$this->RegisterAttributeBoolean('ZustandLadefreigabe', false);
$this->RegisterAttributeBoolean('ZustandSolarladen', true);
$this->RegisterAttributeBoolean('GatewayVerbunden', false);
$this->RegisterAttributeString('EaseeBeobachtungen', '{}');
$this->RegisterAttributeInteger('LetzteBeobachtung', 0);
$this->RegisterAttributeInteger('ApiMaximalstrom', 16);
$this->RegisterAttributeBoolean('ZustandFahrzeugVerbunden', false);
$this->RegisterAttributeBoolean('ZustandFahrzeugGeladen', false);
$this->RegisterAttributeInteger('ZustandFahrzeugstatus', 0);
$this->RegisterAttributeFloat('ZustandLadestrom', 0.0);
$this->RegisterAttributeInteger('ZustandPhasenzahl', 0);
$this->RegisterAttributeFloat('ZustandIstleistung', 0.0);
$this->RegisterAttributeInteger('ZustandSollleistung', 0);
$this->RegisterAttributeBoolean('ZustandSollwertGueltig', false);
$this->RegisterAttributeBoolean('ZustandVerfuegbar', false);
$this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false);
$this->RegisterAttributeBoolean('ZustandStoerung', false);
$this->RegisterAttributeString('ZustandStoertext', '');
$this->RegisterAttributeString('Leistungsangebot', '[0]');
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
$this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzterGesetzterStrom', -1);
$this->RegisterAttributeString('LetzterGeraetebefehl', '');
$this->RegisterTimer(
'Meldezyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
);
$this->RegisterTimer(
'RueckmeldungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
);
$this->RegisterTimer(
'VorgabeTimeout',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', false);"
);
$this->ConnectParent(self::EASEE_GATEWAY_MODULE_ID);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->initialisiereLokaleEinstellungen();
$this->aktualisiereVariablen();
$this->WriteAttributeBoolean('GatewayVerbunden', false);
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->deaktiviereTimer();
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->SetTimerInterval(
'Meldezyklus',
$this->ReadPropertyInteger('Meldeintervall') * 1000
);
$this->statusAnfordern();
$this->aktualisiere(true);
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false);
}
$this->aktualisiere(true);
return;
case 'Ladefreigabe':
$this->WriteAttributeBoolean('ZustandLadefreigabe', (bool) $wert);
$this->setzeSichtbareVariable('Ladefreigabe', (bool) $wert);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'Solarladen':
$solarladen = EaseeLadestatus::solarladenErzwungen(
$this->ReadPropertyInteger('Betriebsmodus')
) ? true : (bool) $wert;
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
$this->setzeSichtbareVariable('Solarladen', $solarladen);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'StatusAnfordern':
$this->statusAnfordern();
return;
case 'VorgabeTimeout':
$this->SetTimerInterval('VorgabeTimeout', 0);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'Melden':
if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
}
$this->sendeVerbraucherdaten();
return;
case 'ManagerdatenEmpfangen':
if (!is_string($wert)) {
throw new InvalidArgumentException(
'Managerdaten muessen als JSON uebergeben werden.'
);
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException(
'Managerdaten muessen ein JSON-Objekt sein.'
);
}
$this->ManagerdatenEmpfangen($daten);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
public function ReceiveData($jsonString): void
{
$paket = json_decode((string) $jsonString, true);
if (!is_array($paket) || !isset($paket['Buffer'])) {
return;
}
$ereignis = json_decode((string) $paket['Buffer'], true);
if (!is_array($ereignis) || !isset($ereignis['type'])) {
return;
}
if ($ereignis['type'] === 'GatewayStatus') {
$verbunden = (bool) ($ereignis['connected'] ?? false);
$this->WriteAttributeBoolean('GatewayVerbunden', $verbunden);
$this->setzeSichtbareVariable('GatewayVerbunden', $verbunden);
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
if ($verbunden) {
$this->statusAnfordern();
} else {
$this->verwerfeBeobachtungen();
$this->aktualisiere(true);
}
return;
}
$seriennummer = EaseeGatewayProtokoll::seriennummer(
(string) ($ereignis['serialNumber'] ?? '')
);
if (
$ereignis['type'] !== 'Observation'
|| $seriennummer !== $this->seriennummer()
) {
return;
}
$this->speichereBeobachtung(
(int) ($ereignis['id'] ?? 0),
$ereignis['value'] ?? null,
(int) ($ereignis['timestamp'] ?? time())
);
$this->aktualisiere(true);
}
/** @param array<string, mixed> $daten */
public function ManagerdatenEmpfangen(array $daten): void
{
Nachrichtenvertrag::pruefeManagerdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
throw new InvalidArgumentException(
'Managerdaten sind an eine andere Instanz adressiert.'
);
}
$managerID = $daten['Kopf']['AbsenderID'];
if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) {
throw new InvalidArgumentException(
'Der Manager hat diese Ladestation nicht aktiv zugeordnet.'
);
}
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->aktualisiere(false);
$sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) {
if (
(bool) $this->leseZustand('SollwertGueltig')
&& !in_array(
(int) $this->leseZustand('Sollleistung'),
$this->leseLeistungsangebot(),
true
)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
return;
}
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) {
throw new InvalidArgumentException(
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
);
}
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
$this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true);
$this->SetTimerInterval(
'VorgabeTimeout',
$this->ReadPropertyInteger('VorgabeTimeout') * 1000
);
$this->aktualisiere(false);
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
private function statusAnfordern(): void
{
try {
$antwort = $this->gatewayAnfrage([
'action' => 'Subscribe',
'serialNumber' => $this->seriennummer(),
]);
if (!($antwort['success'] ?? false)) {
throw new RuntimeException(
(string) ($antwort['error'] ?? 'Easee Gateway nicht erreichbar.')
);
}
$this->WriteAttributeBoolean(
'GatewayVerbunden',
(bool) ($antwort['connected'] ?? false)
);
$this->setzeSichtbareVariable(
'GatewayVerbunden',
$this->ReadAttributeBoolean('GatewayVerbunden')
);
$this->verwerfeBeobachtungen();
if (is_array($antwort['state'] ?? null)) {
$zustand = $antwort['state'];
$zeitpunkt = (int) ($zustand['updated'] ?? time());
foreach (EaseeGatewayProtokoll::BEOBACHTUNGEN as $id) {
if (array_key_exists((string) $id, $zustand)) {
$this->speichereBeobachtung(
$id,
$zustand[(string) $id],
$zeitpunkt
);
}
}
}
$this->aktualisiere(true);
} catch (Throwable $fehler) {
$this->WriteAttributeBoolean('GatewayVerbunden', false);
$this->aktualisiere(true);
$this->protokolliere('Gateway', $fehler->getMessage());
}
}
/** @param array<string, mixed> $anfrage
* @return array<string, mixed>
*/
private function gatewayAnfrage(array $anfrage): array
{
$antwort = $this->SendDataToParent(json_encode([
'DataID' => self::GATEWAY_REQUEST_DATA_ID,
'Buffer' => json_encode($anfrage, JSON_THROW_ON_ERROR),
], JSON_THROW_ON_ERROR));
if (!is_string($antwort) || $antwort === '') {
throw new RuntimeException('Easee Gateway liefert keine Antwort.');
}
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new RuntimeException('Easee Gateway liefert ungueltige Daten.');
}
return $daten;
}
private function speichereBeobachtung(int $id, $wert, int $zeitpunkt): void
{
if (!in_array($id, EaseeGatewayProtokoll::BEOBACHTUNGEN, true)) {
return;
}
$beobachtungen = $this->leseBeobachtungen();
if ($id === 109 && (int) $wert === 1) {
foreach ([110, 120, 182, 183, 184, 185] as $sessionID) {
$beobachtungen[(string) $sessionID] = 0;
}
}
$beobachtungen[(string) $id] = $wert;
$this->WriteAttributeString(
'EaseeBeobachtungen',
json_encode($beobachtungen, JSON_THROW_ON_ERROR)
);
$this->WriteAttributeInteger('LetzteBeobachtung', $zeitpunkt);
}
private function aktualisiere(bool $meldungPlanen): void
{
try {
$status = EaseeLadestatus::ausBeobachtungen(
$this->leseBeobachtungen(),
$this->ReadPropertyInteger('MaximalerLadestrom')
);
foreach ([
'FahrzeugVerbunden',
'FahrzeugGeladen',
'Fahrzeugstatus',
'Phasenzahl',
'Istleistung_W',
'Ladestrom_A',
] as $feld) {
$this->setzeZustand($feld, $status[$feld]);
}
$this->WriteAttributeInteger(
'ApiMaximalstrom',
$status['ApiMaximalstrom_A']
);
$this->setzeSichtbareVariable(
'ApiMaximalstrom',
$status['ApiMaximalstrom_A']
);
$gatewayVerbunden = $this->ReadAttributeBoolean('GatewayVerbunden');
$phaseGueltig = in_array($status['Phasenzahl'], [1, 3], true);
$steuerbar = $gatewayVerbunden
&& $status['StatusGueltig']
&& !$status['Stoerung']
&& (!$status['FahrzeugVerbunden'] || $phaseGueltig);
$solarladen = $this->ReadAttributeBoolean('ZustandSolarladen');
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
=== Nachrichtenvertrag::BETRIEBSART_PEAK;
$angebot = LadestationRegler::leistungsangebot(
(bool) $this->GetValue('Aktiv'),
$this->ReadAttributeBoolean('ZustandLadefreigabe'),
$steuerbar && $status['FahrzeugVerbunden'],
$status['FahrzeugGeladen'],
$phaseGueltig ? $status['Phasenzahl'] : 1,
$status['ApiMaximalstrom_A'],
$solarladen,
$peakbetrieb
);
$verfuegbar = $steuerbar
&& $status['FahrzeugVerbunden']
&& !$status['FahrzeugGeladen']
&& $angebot !== [0];
$angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR);
$this->WriteAttributeString('Leistungsangebot', $angebotJson);
$this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson);
$this->setzeZustand('Verfuegbar', $verfuegbar);
$this->setzeZustand(
'AenderungMoeglich',
$verfuegbar && count($angebot) > 1
);
$stoertext = '';
if (!$gatewayVerbunden) {
$stoertext = 'Easee Gateway ist nicht verbunden.';
$this->SetStatus(self::STATUS_GATEWAY_GETRENNT);
} elseif (!$status['StatusGueltig']) {
$stoertext = 'Warte auf den aktuellen Easee-Ladestatus.';
$this->SetStatus(self::STATUS_WARTET_AUF_DATEN);
} elseif ($status['Stoerung']) {
$stoertext = $status['Stoertext'];
$this->SetStatus(self::STATUS_LADEFEHLER);
} elseif ($status['FahrzeugVerbunden'] && !$phaseGueltig) {
$stoertext = 'Easee meldet keine unterstuetzte aktive Ausgangsphase.';
$this->SetStatus(self::STATUS_WARTET_AUF_DATEN);
} else {
$this->SetStatus(self::STATUS_AKTIV);
}
$this->setzeStoerung($stoertext);
if ($gatewayVerbunden && $status['StatusGueltig']) {
$this->setzeLadestrom(
$this->bestimmeWirksameSollleistung($angebot),
$status['Phasenzahl']
);
} else {
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
}
} catch (Throwable $fehler) {
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage());
$this->SetStatus(self::STATUS_GATEWAY_GETRENNT);
$this->protokolliere('Easee', $fehler->getMessage());
}
if ($meldungPlanen) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
}
/** @param list<int> $angebot */
private function bestimmeWirksameSollleistung(array $angebot): int
{
if ((bool) $this->leseZustand('SollwertGueltig')) {
$sollleistung = (int) $this->leseZustand('Sollleistung');
if (in_array($sollleistung, $angebot, true)) {
return $sollleistung;
}
$this->setzeZustand('SollwertGueltig', false);
}
if (
!$this->ReadAttributeBoolean('ZustandSolarladen')
&& $angebot !== [0]
) {
return $angebot[count($angebot) - 1];
}
return 0;
}
private function setzeLadestrom(int $leistung, int $phasenzahl): void
{
$strom = $leistung === 0
? 0
: LadestationRegler::stromFuerLeistung($leistung, $phasenzahl);
if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) {
return;
}
$antwort = $this->gatewayAnfrage([
'action' => 'SetDynamicChargerCurrent',
'serialNumber' => $this->seriennummer(),
'amps' => $strom,
]);
if (!($antwort['success'] ?? false)) {
throw new RuntimeException(
(string) ($antwort['error'] ?? 'Easee-Stromvorgabe fehlgeschlagen.')
);
}
$befehl = json_encode([
'Aktion' => 'SetDynamicChargerCurrent',
'Seriennummer' => $this->seriennummer(),
'Ampere' => $strom,
], JSON_THROW_ON_ERROR);
$this->WriteAttributeString('LetzterGeraetebefehl', $befehl);
$this->setzeSichtbareVariable('LetzterGeraetebefehl', $befehl);
$this->WriteAttributeInteger('LetzterGesetzterStrom', $strom);
}
private function seriennummer(): string
{
return EaseeGatewayProtokoll::seriennummer(
$this->ReadPropertyString('Ladestationskennung')
);
}
private function verwerfeBeobachtungen(): void
{
$this->WriteAttributeString('EaseeBeobachtungen', '{}');
$this->WriteAttributeInteger('LetzteBeobachtung', 0);
}
/** @return array<string, mixed> */
private function leseBeobachtungen(): array
{
$daten = json_decode($this->ReadAttributeString('EaseeBeobachtungen'), true);
return is_array($daten) ? $daten : [];
}
private function sendeVerbraucherdaten(): void
{
foreach ($this->zugeordneteManagerIDs() as $managerID) {
$daten = $this->baueVerbraucherdaten($managerID);
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
try {
IPS_RequestAction(
$managerID,
'VerbraucherdatenEmpfangen',
json_encode($daten, JSON_THROW_ON_ERROR)
);
} catch (Throwable $fehler) {
$this->protokolliere('Managerkommunikation', $fehler->getMessage());
}
}
}
/** @return array<string, mixed> */
private function baueVerbraucherdaten(int $managerID): array
{
$stoerung = (bool) $this->leseZustand('Stoerung');
return [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $this->InstanceID,
'EmpfaengerID' => $managerID,
'Zeitpunkt' => time(),
],
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN,
'Zustand' => [
[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung')
: null,
'Einheit' => 'W',
],
[
'Kennung' => 'FahrzeugVerbunden',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('FahrzeugVerbunden'),
'Einheit' => '',
],
[
'Kennung' => 'FahrzeugGeladen',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('FahrzeugGeladen'),
'Einheit' => '',
],
[
'Kennung' => 'Fahrzeugstatus',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('Fahrzeugstatus'),
'Einheit' => '',
],
[
'Kennung' => 'Phasenzahl',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('Phasenzahl'),
'Einheit' => '',
],
[
'Kennung' => 'Ladestrom_A',
'Art' => 'Istwert',
'Wert' => (float) $this->leseZustand('Ladestrom'),
'Einheit' => 'A',
],
[
'Kennung' => 'Ladefreigabe',
'Art' => 'Status',
'Wert' => $this->ReadAttributeBoolean('ZustandLadefreigabe'),
'Einheit' => '',
],
[
'Kennung' => 'Solarladen',
'Art' => 'Status',
'Wert' => $this->ReadAttributeBoolean('ZustandSolarladen'),
'Einheit' => '',
],
[
'Kennung' => 'Ladefehler',
'Art' => 'Stoerung',
'Wert' => $stoerung,
'Einheit' => '',
'Text' => (string) $this->leseZustand('Stoertext'),
],
],
];
}
/** @return list<int> */
private function zugeordneteManagerIDs(): array
{
$ergebnis = [];
foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) {
try {
$automatisch = (bool) IPS_GetProperty(
$managerID,
'AutomatischeSuche'
);
$property = $automatisch
? 'AutomatischeVerbraucherZuordnung'
: 'VerbraucherZuordnung';
$zuordnung = $this->leseManagerZuordnung($managerID, $property);
if ($automatisch && $zuordnung === []) {
$zuordnung = $this->leseManagerZuordnung(
$managerID,
'VerbraucherZuordnung'
);
}
} catch (Throwable $fehler) {
continue;
}
foreach ($zuordnung as $eintrag) {
if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) {
continue;
}
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
if ($instanzID === $this->InstanceID) {
$ergebnis[] = (int) $managerID;
break;
}
}
}
return array_values(array_unique($ergebnis));
}
/** @return array<mixed> */
private function leseManagerZuordnung(int $managerID, string $property): array
{
$zuordnung = json_decode(
(string) IPS_GetProperty($managerID, $property),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($zuordnung) ? $zuordnung : [];
}
private function pruefeKonfiguration(): void
{
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException(
$property . ' muss mindestens 0 sein.'
);
}
}
foreach (['Meldeintervall', 'VorgabeTimeout'] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException(
$property . ' muss groesser als 0 sein.'
);
}
}
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
if ($maximalstrom < 6 || $maximalstrom > 32) {
throw new InvalidArgumentException(
'MaximalerLadestrom muss zwischen 6 und 32 A liegen.'
);
}
EaseeLadestatus::solarladenErzwungen(
$this->ReadPropertyInteger('Betriebsmodus')
);
if ($this->seriennummer() === '') {
throw new InvalidArgumentException('Easee Seriennummer fehlt.');
}
}
private function initialisiereLokaleEinstellungen(): void
{
$initialisiert = $this->ReadAttributeBoolean('InitialwerteGesetzt');
$ladefreigabe = $this->ReadPropertyBoolean('Ladefreigabe');
$solarladen = EaseeLadestatus::solarladenErzwungen(
$this->ReadPropertyInteger('Betriebsmodus')
) ? true : $this->ReadPropertyBoolean('Solarladen');
if (
!$initialisiert
|| $ladefreigabe
!== $this->ReadAttributeBoolean('KonfigurationLadefreigabe')
) {
$this->WriteAttributeBoolean('ZustandLadefreigabe', $ladefreigabe);
}
if (
!$initialisiert
|| $solarladen
!== $this->ReadAttributeBoolean('KonfigurationSolarladen')
) {
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
}
$this->WriteAttributeBoolean('KonfigurationLadefreigabe', $ladefreigabe);
$this->WriteAttributeBoolean('KonfigurationSolarladen', $solarladen);
$this->WriteAttributeBoolean('InitialwerteGesetzt', true);
}
private function aktualisiereVariablen(): void
{
if ($this->ReadPropertyBoolean('EinstellungenInVisu')) {
$this->RegisterVariableBoolean(
'Ladefreigabe',
'Ladefreigabe',
'~Switch',
150
);
$this->RegisterVariableBoolean(
'Solarladen',
'Solarladen',
'~Switch',
160
);
$this->EnableAction('Ladefreigabe');
$this->EnableAction('Solarladen');
$this->SetValue(
'Ladefreigabe',
$this->ReadAttributeBoolean('ZustandLadefreigabe')
);
$this->SetValue(
'Solarladen',
$this->ReadAttributeBoolean('ZustandSolarladen')
);
} else {
$this->entferneVariable('Ladefreigabe');
$this->entferneVariable('Solarladen');
}
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->registriereVerbraucherDiagnose();
$this->RegisterVariableBoolean(
'GatewayVerbunden',
'Easee Gateway verbunden',
'~Switch',
200
);
$this->RegisterVariableInteger(
'ApiMaximalstrom',
'API-Maximalstrom',
'',
210
);
$this->RegisterVariableString(
'LetzterGeraetebefehl',
'Letzter Geraetebefehl',
'',
220
);
$this->RegisterVariableString(
'LeistungsangebotDiagnose',
'Leistungsangebot',
'',
230
);
$this->SetValue(
'LeistungsangebotDiagnose',
$this->ReadAttributeString('Leistungsangebot')
);
$this->SetValue(
'LetzterGeraetebefehl',
$this->ReadAttributeString('LetzterGeraetebefehl')
);
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if (!in_array($ident, [
'GatewayVerbunden',
'ApiMaximalstrom',
'LetzterGeraetebefehl',
'LeistungsangebotDiagnose',
], true)) {
$this->setzeSichtbareVariable($ident, $this->leseZustand($ident));
}
}
$this->setzeSichtbareVariable(
'GatewayVerbunden',
$this->ReadAttributeBoolean('GatewayVerbunden')
);
$this->setzeSichtbareVariable(
'ApiMaximalstrom',
$this->ReadAttributeInteger('ApiMaximalstrom')
);
} else {
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$this->entferneVariable($ident);
}
}
}
private function entferneVariable(string $ident): void
{
$id = @$this->GetIDForIdent($ident);
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
$this->UnregisterVariable($ident);
}
}
/** @param mixed $wert */
private function setzeZustand(string $ident, $wert): void
{
switch ($ident) {
case 'FahrzeugVerbunden':
case 'FahrzeugGeladen':
case 'SollwertGueltig':
case 'Verfuegbar':
case 'AenderungMoeglich':
case 'Stoerung':
$this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert);
break;
case 'Fahrzeugstatus':
case 'Phasenzahl':
case 'Sollleistung':
$this->WriteAttributeInteger('Zustand' . $ident, (int) $wert);
break;
case 'Ladestrom_A':
$ident = 'Ladestrom';
$this->WriteAttributeFloat('ZustandLadestrom', (float) $wert);
break;
case 'Istleistung_W':
$ident = 'Istleistung';
$this->WriteAttributeFloat('ZustandIstleistung', (float) $wert);
break;
case 'Stoertext':
$this->WriteAttributeString('ZustandStoertext', (string) $wert);
break;
default:
throw new LogicException('Unbekannter Zustand: ' . $ident);
}
$this->setzeSichtbareVariable($ident, $wert);
if ($ident === 'Istleistung') {
$this->setzeSichtbareVariable(
'Leistungsquelle',
Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN
);
}
}
/** @return mixed */
private function leseZustand(string $ident)
{
if ($ident === 'Ladefreigabe' || $ident === 'Solarladen') {
return $this->ReadAttributeBoolean('Zustand' . $ident);
}
if (in_array($ident, [
'FahrzeugVerbunden',
'FahrzeugGeladen',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
], true)) {
return $this->ReadAttributeBoolean('Zustand' . $ident);
}
if (in_array($ident, [
'Fahrzeugstatus',
'Phasenzahl',
'Sollleistung',
], true)) {
return $this->ReadAttributeInteger('Zustand' . $ident);
}
if ($ident === 'Ladestrom' || $ident === 'Istleistung') {
return $this->ReadAttributeFloat('Zustand' . $ident);
}
if ($ident === 'Stoertext') {
return $this->ReadAttributeString('ZustandStoertext');
}
if ($ident === 'Leistungsquelle') {
return Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN;
}
throw new LogicException('Unbekannter Zustand: ' . $ident);
}
/** @param mixed $wert */
private function setzeSichtbareVariable(string $ident, $wert): void
{
$id = @$this->GetIDForIdent($ident);
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
$this->SetValue($ident, $wert);
}
}
/** @return list<int> */
private function leseLeistungsangebot(): array
{
$angebot = json_decode(
$this->ReadAttributeString('Leistungsangebot'),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($angebot) ? array_map('intval', $angebot) : [0];
}
private function setzeStoerung(string $text): void
{
$this->setzeZustand('Stoerung', $text !== '');
$this->setzeZustand('Stoertext', $text);
}
private function deaktiviereTimer(): void
{
foreach (['Meldezyklus', 'RueckmeldungVerzoegert', 'VorgabeTimeout'] as $timer) {
$this->SetTimerInterval($timer, 0);
}
}
private function protokolliere(string $bereich, string $nachricht): void
{
if ($this->ReadPropertyBoolean('LoggingEin')) {
$this->SendDebug($bereich, $nachricht, 0);
}
}
}
+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"
}
+763
View File
@@ -0,0 +1,763 @@
<?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', false);
$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();
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')
);
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);
}
}
+301
View File
@@ -0,0 +1,301 @@
{
"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": "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": "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": "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": "Prognose / Forecast",
"items": [
{
"type": "ValidationTextBox",
"name": "PrognoseAnschluss",
"caption": "Konfiguration als JSON"
}
]
},
{
"type": "ExpansionPanel",
"caption": "SDL / VGT",
"items": [
{
"type": "ValidationTextBox",
"name": "SDLAnschluss",
"caption": "Konfiguration als JSON"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Stoerueberwachung",
"items": [
{
"type": "ValidationTextBox",
"name": "StoermeldeAnschluss",
"caption": "Konfiguration als JSON"
}
]
}
],
"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 fehlt oder ist abgelaufen"}
]
}
+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"
}
+1081
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
);
}
}
+40 -9
View File
@@ -4,20 +4,27 @@ 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. Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die
Verbraucherbasis, der Manager, der mehrstufige Warmwassererwaermer, der
1-stufige Verbraucher und die Ladestation Stand-Alone sind implementiert. Die
weiteren Module sind als Diskussionsentwürfe dokumentiert.
## Geplante Module ## Geplante Module
- Manager - Manager (implementiert)
- Batterie - Batterie (implementiert)
- Wassererwaermer - Wassererwaermer (implementiert)
- Pufferspeicher - Pufferspeicher
- Verbraucher 1-Stufig - Verbraucher 1-Stufig (implementiert)
- Waermepumpe - Waermepumpe (implementiert)
- Ladestation Stand-Alone - Ladestation Stand-Alone (implementiert)
- Ladestation Gateway - Ladestation Gateway
- Easee Gateway - Easee Gateway
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. 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.
## Voraussetzungen ## Voraussetzungen
@@ -32,7 +39,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 +58,18 @@ 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
- [Zentrale Liste offener Teamentscheidungen](docs/Offene-Punkte.md)
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
- [Batterieschnittstelle](docs/Schnittstelle-Batterie.md)
- [Manager-Modul inklusive Lizenzierung](docs/module/Manager/README.md)
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md)
- [Batteriemodul](docs/module/Batterie/README.md)
- [Verbraucher-1-Stufig-Modul](docs/module/Verbraucher-1-Stufig/README.md)
- [Ladestation-Stand-Alone-Modul](docs/module/Ladestation-Stand-Alone/README.md)
- [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md)
- [Migration Verbraucher 1-Stufig](docs/migration/Verbraucher-1-Stufig.md)
- [Migration Batterie](docs/migration/Batterie.md)
- [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md)
- [Modulübersicht](docs/module/README.md)
+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
+9
View File
@@ -20,10 +20,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": {
+34
View File
@@ -0,0 +1,34 @@
# Obere Anschlüsse des Managers
> Status: Lizenzierung implementiert; die übrigen Anbieterformate sind noch
> nicht festgelegt.
Prognose, SDL/VGT und Stoerueberwachung sind optional. Die Lizenzierung ist fuer
den Betrieb des Managers erforderlich. Sie werden im Manager konfiguriert und erzeugen
keine Abhängigkeit zwischen Enelix EMS und Enelix Utils.
| Anschluss | Aufgabe | Gegenstelle |
| --- | --- | --- |
| SDL/VGT | Zeitlich gültige Leistungsaufträge und Rückmeldungen | Optional das unabhängige Utils-Modul VGT-Schnittstelle |
| Prognose / Forecast | Vorhersage für Produktion und bei Bedarf Verbrauch | Externer Anbieter; kein Repo-Modul |
| Lizenzierung | Pruefung freigeschalteter Manager-Funktionen | `POST https://license.enelix.ch/api/v1/licenses/activate` |
| Störüberwachung | Weitergabe von Zuständen und Störungen | Externer Empfänger; kein Repo-Modul |
## Gemeinsame Grundsätze
- Die Auswahl der Gegenstelle erfolgt ausschliesslich im Manager.
- Anbieterformate werden an diesem Anschluss übersetzt und gelangen nicht in
die Verbraucher-Schnittstelle.
- Jeder Anschluss meldet im Manager `NichtVerwendet`, `Verbunden` oder `Fehler`.
- Fehlende optionale Anschlüsse dürfen die lokale EMS-Grundfunktion nicht
blockieren.
- Eine fehlende oder abgelaufene Lizenz blockiert die Managerregelung. Eine
bestaetigte Entwicklungs-Lease bleibt bei Verbindungsunterbruch bis
`offlineUntil` gueltig.
## Noch festzulegen
- Anbieter und Datenformat für Prognose und Störüberwachung
- Aufbau, Gültigkeitszeitraum und Rückmeldung eines SDL-Auftrags
- Authentisierung der noch optionalen Anschlüsse
- Ablösung der Entwicklungs-Lease durch den signierten produktiven Lizenzvertrag
+125
View File
@@ -0,0 +1,125 @@
# 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 | Offen | Grundeinrichtung | Generator und kostenpflichtige Ersteinrichtung |
| OP-003 | Offen | Lizenzportal | VGT-Integration in license.enelix.ch |
| OP-004 | Offen | Lizenzierung | Lizenzmodell des Verbrauchskostenreports |
| OP-005 | Offen | Teststrategie | Standort fuer Feldtests |
## Offene Punkte
### OP-002: Generator und kostenpflichtige Ersteinrichtung
- **Entscheidungsfrage:** Soll der automatische Generator fuer die
Grundeinrichtung zugleich die kostenpflichtige Ersteinrichtung abbilden?
- **Kontext:** Der vorgesehene automatische Generator fuer die Grundeinrichtung
muss gegenueber der kostenpflichtigen Ersteinrichtung abgegrenzt werden.
- **Auswirkung:** Die Entscheidung legt fest, ob beide als derselbe Vorgang oder
als getrennte Leistungen behandelt werden.
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
### 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-004: Lizenzmodell des Verbrauchskostenreports
- **Entscheidungsfrage:** Soll der Verbrauchskostenreport nach dem Lizenzmodell
des Managers oder mit einer einzigen, von der Anzahl angeschlossener Zaehler
unabhaengigen Modullizenz angeboten werden?
- **Kontext:** Fuer den Verbrauchskostenreport muss zwischen dem Lizenzmodell des
Managers und einer zaehlerunabhaengigen Modullizenz entschieden werden.
- **Auswirkung:** Die Entscheidung legt fest, ob die Anzahl angeschlossener
Zaehler den Lizenzumfang des Verbrauchskostenreports beeinflusst.
- **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)
## Vorlage fuer neue Punkte
```markdown
### OP-NNN: Kurztitel
- **Entscheidungsfrage:** Welche konkrete Entscheidung muss das Team treffen?
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
```
+110
View File
@@ -0,0 +1,110 @@
# Schnittstelle Batterie
Diese Beschreibung ergaenzt den allgemeinen
[Manager-Verbraucher-Vertrag](Schnittstelle.md) fuer das Batteriemodul.
Vertragsversion ist 4.0.
## Manager an Batterie
Die Batterie empfaengt den unveraenderten gemeinsamen Datensatz:
~~~json
{
"Kopf": {
"Version": "4.0",
"AbsenderID": 10001,
"EmpfaengerID": 20001,
"Zeitpunkt": 1788825600
},
"Betriebsart": "PV",
"Sollleistung_W": -1500
}
~~~
Sollleistung_W verwendet folgende Semantik:
- positiver Wert: Batterie laden
- negativer Wert: Batterie entladen
- 0: Leistungsregister auf 0 setzen
- null: nur Betriebsart synchronisieren und neues Angebot anfordern
Ein konkreter Wert muss im zuletzt fuer dieselbe Betriebsart gemeldeten
Leistungsangebot enthalten sein. Waehren der Aenderungssperre wird nur die
Wiederholung des aktuellen Sollwerts akzeptiert.
## Batterie an Manager
Die Batterie verwendet alle Pflichtfelder des gemeinsamen Vertrags und
ergaenzt folgende Zustandseintraege:
| Kennung | Art | Typ | Einheit | Bedeutung |
| --- | --- | --- | --- | --- |
| Sollleistung_W | Sollwert | Integer oder null | W | Aktuell gueltige Manager-Vorgabe |
| Ladezustand_Prozent | Istwert | Float oder null | % | Physischer SoC |
| HystereseAktiv | Status | Boolean | - | Reserve-Hysterese ist aktiv |
| Batteriesteuerung | Status | Integer | - | 1 Wechselrichter, 2 Enelix |
| Messwertfehler | Stoerung | Boolean | - | Pflichtmesswert fehlt oder ist ungueltig |
| Registerfehler | Stoerung | Boolean | - | Mindestens ein Schreibbefehl ist fehlgeschlagen |
Beispiel:
~~~json
{
"Kopf": {
"Version": "4.0",
"AbsenderID": 20001,
"EmpfaengerID": 10001,
"Zeitpunkt": 1788825602
},
"Betriebsart": "Peak",
"PrioritaetPV": 0,
"PrioritaetPeak": 0,
"Leistungswerte_W": [-1500],
"AenderungMoeglich": true,
"Verfuegbar": true,
"Istleistung_W": -250.0,
"Leistungsquelle": 2,
"Zustand": [
{
"Kennung": "Sollleistung_W",
"Art": "Sollwert",
"Wert": -1500,
"Einheit": "W"
},
{
"Kennung": "Ladezustand_Prozent",
"Art": "Istwert",
"Wert": 54.2,
"Einheit": "%"
}
]
}
~~~
Ist die Leistungsmessung ungueltig, wird Istleistung_W als null und
Leistungsquelle als 0 gemeldet. Bei einem gueltigen Messwert ist
Leistungsquelle 2.
## Ereignisse
Das Modul registriert VM_UPDATE fuer:
- maximale Ladeleistung
- maximale Entladeleistung
- Ladezustand
- Netzleistung
- aktuelle Batterieleistung
Jede Aktualisierung berechnet Zustand und Angebot neu und plant eine
gebuendelte Rueckmeldung. Registerwerte werden nur geschrieben, wenn sich der
resultierende Befehl geaendert hat.
## Registerausgang
Der technische Ausgang besteht aus ausgewaehlten numerischen
IP-Symcon-Variablen. Das Modul verwendet RequestAction und setzt diese
Variablen nicht mit SetValue. Damit bleibt der jeweilige Modbus-, Skript- oder
Geraeteadapter fuer die konkrete Registerkommunikation verantwortlich.
Die genaue Herstellerabbildung steht in der
[Moduldokumentation](module/Batterie/README.md).
+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.
+99 -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,135 @@ 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. Erst wenn alle aktiven Verbraucher synchronisiert sind, verteilt der Manager
Sollleistungen.
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.
+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
- 250-W-Grundraster und 50-W-Feinwerte um 0
- Reserve-, Mindestladezustands- und Hystereselogik
- getrennte Angebote fuer PV und Peak
- Wechselrichter- oder Enelix-Steuerung
- Registercodes fuer generisch, GoodWe, SolarEdge und Sigenergy
- Aufsummierung der bezogenen Energie
## Angepasst
| Enelix 1 | Enelix 2 |
| --- | --- |
| zyklischer Timer Do_UserCalc | Messwert- und Managerereignisse |
| IdleCounter in Zyklen | Aenderungssperre in Sekunden mit Einmaltimer |
| PowerSteps als Modulvariable | Leistungswerte_W direkt im Vertrag |
| interne Stellwertvariablen | ausgewaehlte Registervariablen mit RequestAction |
| fest verdrahtete Herstellerhilfsvariablen | dynamisch sichtbare Registerauswahl |
| fest codierte 2-%-Hysterese | Property LadezustandHysterese |
| berechnete Istleistung | gemessene Istleistung, Leistungsquelle 2 |
| Interval-basierte Energie | Zeitintegration zwischen Ereignissen und Vollmeldungen |
| Is_Peak_Shaving als Variable | Betriebsart PV oder Peak im Vertrag 4.0 |
## Neu implementiert
- Validierung aller Mess- und Registervariablen
- Pflichtaktion auf jeder Zielregistervariable
- Messwertalter und sicherer Zustand bei ungueltigen Messwerten
- Sollwert-Timeout
- Diagnosevariablen, Registerfehlerstatus und Debug-Logging
- standardisierte PHPUnit- und Symcon-8.0-Laufzeittests
- explizite Schnittstellenbeschreibung
## Verworfen
- direkter SetValue-Aufruf auf internen Leistungs- und Modusvariablen
- zyklische Regelung mit frei konfigurierbarem Interval
- doppelte Peak-/PV-Codepfade mit identischer Registerabbildung
- ungenutzte Batteriespannungsproperty
- CheckIdle mit globalem GetValue ohne Objekt-ID
## Umstellung einer Anlage
1. Bestehende Batterieinstanz und alle bisherigen Variablen-IDs dokumentieren.
2. Sicherung der IP-Symcon-Konfiguration erstellen.
3. Neue Enelix-2-Batterieinstanz anlegen.
4. Fuenf Messwertvariablen zuordnen.
5. Batterietyp waehlen und die eingeblendeten Registervariablen zuordnen.
6. Reserve, Mindestladezustand und Aenderungssperre uebertragen.
7. Neue Batterie im Enelix-2-Manager zuordnen.
8. Diagnose und Logging aktivieren.
9. Laden, Entladen, 0 W und Wechselrichtermodus unter Aufsicht testen.
10. Alte Instanz erst nach erfolgreicher Feldpruefung deaktivieren.
Ein automatisches Loeschen oder Ueberschreiben der alten Instanz findet nicht
statt.
+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.
+121
View File
@@ -0,0 +1,121 @@
# 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
Das Raster besteht wie in Enelix 1 aus 250-W-Grundschritten. Um 0 werden
zusaetzlich -200, -150, -100, -50, 0, 50, 100, 150 und 200 W angeboten,
soweit diese Werte innerhalb der dynamischen Leistungsgrenzen liegen.
PV und Peak verwenden die bisherige SoC-Logik. Die Hysterese wird als
persistenter Modulzustand gefuehrt. Bei Wechselrichtersteuerung, lokaler
Deaktivierung oder ungueltigen Messwerten lautet das Angebot [0].
## Variablen
Immer sichtbar sind Aktiv und Ladestatus. Ladestatus verwendet:
| Wert | Bedeutung |
| ---: | --- |
| 0 | Messwert ungueltig oder unbekannt |
| 1 | Ruhezustand |
| 2 | Laden |
| 3 | Entladen |
Die Diagnoseoption ergaenzt die gemeinsamen Verbraucherdiagnosen sowie
Ladezustand, Netzleistung, Leistungsgrenzen, Hysterese, Steuerungsmodus,
BezogeneEnergie, LeistungsangebotDiagnose und LetzterRegisterbefehl.
## Fehlerbehandlung
- Status 201: Konfiguration oder Variablentyp ungueltig
- Status 202: Messwert fehlt, ist veraltet oder fachlich ungueltig
- Status 203: RequestAction auf mindestens ein Register ist fehlgeschlagen
Ein ungueltiger oder abgelaufener Sollwert wird verworfen und durch 0 ersetzt.
Die konkrete Stoerung wird im Vertrag und bei aktivierter Diagnose in
Stoerung und Stoertext gemeldet.
## Managerkommunikation
Die fachliche Schnittstelle ist in
[Schnittstelle Batterie](../../Schnittstelle-Batterie.md) beschrieben.
Transport und gemeinsame Felder folgen
[EMS-Schnittstelle](../../Schnittstelle.md).
## Tests
- BatterieReglerTest: Raster, Hysterese, PV/Peak und Herstellerabbildung
- BatterieModulstrukturTest: Metadaten, Formular, Ereignismodell und Manager-ID
- Symcon/modules/Batterie.php: reale Modulinstanz und Registeraktionen unter
IP-Symcon 8.0
+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,63 @@
# Ladestation Stand-Alone
> Status: Implementiert. Eigenstaendiges Verbrauchermodul mit direkter
> Geraeteanbindung; keine Beziehung zur Ladestation Gateway.
## Unterstuetzte Geraete
| Geraetetyp | Status | Steuerung |
| --- | --- | --- |
| go-e Charger, alte API | `GET /mqtt?payload=` | `alw` und `amp` |
| go-e Charger Gemini / Gemini flex | `GET /api/status` | `frc` und `amp` |
| smart-me Pico | Pico-Charging-API | Load-Management-Current-API mit Basic Auth |
Die Routen, Leistungsfaktoren und Erkennung wurden gezielt aus Enelix 1
uebernommen. Zugangsdaten werden weder als Variable noch im Diagnoseprotokoll
ausgegeben.
## Verhalten
- `FahrzeugVerbunden` entspricht bei go-e `car != 1` und bei Pico `State != 1`.
- Die Phasenerkennung verwendet wie Enelix 1 die gemessene Ladeleistung:
ueber 7500 W sind dreiphasig, sonst einphasig.
- Ein bereits verbundenes Fahrzeug gilt bei einem ermittelten Maximalstrom
unter 6 A als geladen.
- PV mit Solarladen bietet `0 W` und alle Ladestufen von 6 A bis zum
konfigurierten Maximum an.
- PV ohne Solarladen bietet ausschliesslich die Maximalleistung an.
- Peak mit Solarladen bietet nur `[0]` an und stoppt die Ladestation.
- Peak ohne Solarladen bietet `[0, ...Ladestufen]` an, damit der Manager die
Ladeleistung wie in Enelix 1 stufenweise reduzieren kann.
- Ohne gueltige Manager-Vorgabe wird bei Solarladen mit 0 A und ohne
Solarladen mit maximaler Leistung gefahren.
## Variablen
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `FahrzeugVerbunden` | Boolean / Anzeige | Geraet meldet ein verbundenes Fahrzeug. |
| `FahrzeugGeladen` | Boolean / Anzeige | Enelix-1-Erkennung unter 6 A. |
| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` getrennt, `2` bereit, `3` laedt, `4` geladen. |
| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Strom in A. |
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. |
| `Ladefreigabe` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; lokale Ladeerlaubnis. |
| `Solarladen` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; variable Ladestufen ein/aus. |
## Properties
Neben den gemeinsamen Verbraucher-Properties werden Geraetetyp, Adresse,
Pico-ID, Seriennummer, Zugangsdaten, maximaler Ladestrom, Abfrageintervall
sowie die Startwerte fuer Ladefreigabe und Solarladen konfiguriert.
Die gemeinsamen Verbraucher- und Diagnosevariablen entsprechen der
EMS-Schnittstelle Version 4.0. `LoggingEin` steuert ausschliesslich das
laufende Debug-Protokoll.
## Tests
Die Adaptertests verwenden einen injizierten Fake-HTTP-Transport. Dadurch
werden fuer alle drei Geraetevarianten Statusantworten, URL, HTTP-Methode,
Authentisierung und Steueraufrufe geprueft, ohne ein reales Geraet anzusprechen.
Der Symcon-Funktionstest nutzt den internen, nicht im Konfigurationsformular
sichtbaren `Testmodus` und prueft zusaetzlich Fahrzeug-/Phasenerkennung,
die vier PV-/Peak-Angebote sowie die Umschaltung ueber den `Solarladen`-Button.
+202
View File
@@ -0,0 +1,202 @@
# 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
Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netzleistung`.
| 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. |
| `NetzleistungGueltig` | Boolean / Logging | Messquelle vorhanden und aktuell. |
| `WirksameLastspitzengrenze` | Float / Logging | Aktuelle feste oder monatliche Grenze in W. |
| `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. |
| `PrognoseAnschluss` | String/JSON | Optionaler Forecast-Anschluss. |
| `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. |
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.
## 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 Lastspitzenmodus. |
| `manager_peak` | Manager-Grundregelung einschliesslich konstantem oder monatlichem Lastspitzenmodus. |
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. Bei
einer Migration muss deshalb 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.
### 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.
+27
View File
@@ -0,0 +1,27 @@
# EMS-Module und Modulentwürfe
> Manager, Warmwassererwaermer, 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: `EaseeGateway`, `LadestationGateway`,
`LadestationStandAlone`, `Manager`, `Pufferspeicher`, `VerbraucherEinStufig`
und `Warmwassererwaermer`.
Der Manager-Test enthält Manager ohne Verbraucher, jeden Verbrauchertyp einzeln und alle aktuell implementierten Verbrauchertypen gemeinsam.
## 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. Auf `develop` werden zusätzlich
die von den geänderten Pfaden betroffenen Symcon-Modultests ausgeführt. Bei
gemeinsamen Framework- oder Vertragsänderungen läuft die vollständige Suite.
Für `beta` ist immer `all` vorgeschrieben.
Die Symcon-Jobs benötigen einen geschützten Runner mit dem Label `symcon-8`,
lokalem Zugriff auf eine isolierte IP-Symcon-8.x-Instanz und den installierten
Repository-Stand. Ohne diesen Runner ist die Richtlinie dokumentiert, aber
nicht technisch durchsetzbar.
## 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.
+64
View File
@@ -0,0 +1,64 @@
# Enelix-2-Demoanlage
Die Demo erzeugt idempotent eine vollständige, spielbare EMS-Anlage in IP-Symcon 8.x:
- Enelix Manager mit manueller Zuordnung,
- zwei mehrstufige Boiler (1,2/2,4 kW und 1,5/3,0 kW),
- einen einstufigen Entfeuchter (0,9 kW),
- simulierte PV-Erzeugung, Grundlast, Netzleistung und Boilertemperaturen,
- native Symcon-Instanz `Energy Distribution` für den Energiefluss,
- eigene Kachelansichten mit allen Laufzeitparametern je Boiler,
- kombinierten Verlauf für PV-Leistung, Hausverbrauch 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.
## Bedienung
In der Kachelvisualisierung öffnet die Startkategorie `Enelix 2 Demoanlage`.
Unter `Simulation` lassen sich Tageslauf, Tageszeit, Geschwindigkeit,
PV-Spitzenleistung, Bewölkung und Haus-Grundlast verändern. Der Manager verteilt
den verfügbaren PV-Überschuss nach Priorität auf beide Boiler und den Entfeuchter.
`Übersicht` zeigt die native Symcon-Energieverteilung. Die Kacheln `Boiler 1`
und `Boiler 2` öffnen direkt sämtliche Bedien-, Status- und Diagnosevariablen,
ohne zuerst das Instanz-Konfigurationsformular zu zeigen. Unter `Leistung und
Energie` liegen die beiden kombinierten Zeitdiagramme und die Energiezähler.
Die Energiesummen verwenden die beschleunigte Simulationszeit.
## Validierung
- Alle vier Enelix-Instanzen müssen Status 102 besitzen.
- Der Manager muss drei Verbraucher melden.
- Bei ausreichendem PV-Überschuss müssen Verbraucher schalten.
- Netzleistung, PV-Leistung, Hausverbrauch, Verbraucherleistungen, Energiesummen und Temperaturen werden archiviert.
- Die native Energieverteilung muss sechs 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.
+913
View File
@@ -0,0 +1,913 @@
<?php
declare(strict_types=1);
const ENELIX_DEMO_ROOT_IDENT = 'ENELIX_DEMO_2';
const ENELIX_DEMO_MANAGER_MODULE = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
const ENELIX_DEMO_BOILER_MODULE = '{B7C54AF4-AD7D-4FE4-B75D-203693906251}';
const ENELIX_DEMO_SINGLE_MODULE = '{15879A4E-D0C2-4495-83DE-46E1E462591E}';
const ENELIX_DEMO_DUMMY_MODULE = '{485D0419-BE97-4548-AA9C-C083EB82E61E}';
const ENELIX_DEMO_TILE_MODULE = '{B5B875BB-9B76-45FD-4E67-2607E45B3AC4}';
const ENELIX_DEMO_ARCHIVE_MODULE = '{43192F0B-135B-4CE7-A0A7-1475603F3060}';
const ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE = '{BA90FDB3-C203-8F26-B4D6-F3DB99905724}';
function enelixDemoInstall(): array
{
enelixDemoValidateRuntime();
$root = enelixDemoEnsureCategory(0, ENELIX_DEMO_ROOT_IDENT, 'Enelix 2 Demoanlage', 100);
IPS_SetInfo($root, 'Idempotent erzeugte Enelix-2-Demo. Entfernen nur mit enelixDemoRemove(true).');
$dashboard = enelixDemoEnsureCategory($root, 'Dashboard', 'Übersicht', 10);
$simulation = enelixDemoEnsureCategory($root, 'Simulation', 'Simulation', 20);
$boiler1View = enelixDemoEnsureCategory($root, 'Boiler1Ansicht', 'Boiler 1', 30);
$boiler2View = enelixDemoEnsureCategory($root, 'Boiler2Ansicht', 'Boiler 2', 40);
$history = enelixDemoEnsureCategory($root, 'Historie', 'Leistung und Energie', 50);
$ems = enelixDemoEnsureCategory($root, 'EMS', 'Manager und Verbraucher', 60);
$technical = enelixDemoEnsureCategory($root, 'Technik', 'Simulationstechnik', 90, true);
enelixDemoEnsureProfiles();
$legacyFlow = enelixDemoEnsureVariable($dashboard, 'Energiefluss', 'Energiefluss (Legacy)', 3, '~HTMLBox', '', 90);
IPS_SetHidden($legacyFlow, true);
$pvDisplay = enelixDemoEnsureVariable($dashboard, 'PVLeistung', 'PV-Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 20);
$houseDisplay = enelixDemoEnsureVariable($dashboard, 'Hausverbrauch', 'Hausverbrauch gesamt', 2, 'ENELIX.Demo.PowerPositive', 0.0, 30);
$loadDisplay = enelixDemoEnsureVariable($dashboard, 'Verbraucherleistung', 'Geregelte Verbraucher', 2, 'ENELIX.Demo.PowerPositive', 0.0, 40);
$grid = enelixDemoEnsureVariable($dashboard, 'Netzleistung', 'Netzleistung', 2, 'ENELIX.Demo.Power', 0.0, 50);
$boiler1Power = enelixDemoEnsureVariable($dashboard, 'Boiler1Leistung', 'Boiler 1 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 60);
$boiler2Power = enelixDemoEnsureVariable($dashboard, 'Boiler2Leistung', 'Boiler 2 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 70);
$dryerPower = enelixDemoEnsureVariable($dashboard, 'EntfeuchterLeistung', 'Entfeuchter Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80);
$powerChart = enelixDemoEnsureVariable($history, 'Leistungsdiagramm', 'Leistungsverlauf (Legacy)', 3, '~HTMLBox', '', 90);
$energyChart = enelixDemoEnsureVariable($history, 'Energiediagramm', 'Kumulierte Energien (Legacy)', 3, '~HTMLBox', '', 100);
IPS_SetHidden($powerChart, true);
IPS_SetHidden($energyChart, true);
$pvEnergy = enelixDemoEnsureVariable($history, 'PVEnergie', 'PV-Energie', 2, '~Electricity', 0.0, 30);
$houseEnergy = enelixDemoEnsureVariable($history, 'HausEnergie', 'Hausverbrauch Energie', 2, '~Electricity', 0.0, 40);
$gridImportEnergy = enelixDemoEnsureVariable($history, 'NetzbezugEnergie', 'Netzbezug Energie', 2, '~Electricity', 0.0, 50);
$gridExportEnergy = enelixDemoEnsureVariable($history, 'EinspeisungEnergie', 'Einspeisung Energie', 2, '~Electricity', 0.0, 60);
$auto = enelixDemoEnsureVariable($simulation, 'Automatik', 'Tageslauf automatisch', 0, '~Switch', true, 10);
$hour = enelixDemoEnsureVariable($simulation, 'Tageszeit', 'Simulierte Tageszeit', 2, 'ENELIX.Demo.Hour', 8.0, 20);
$speed = enelixDemoEnsureVariable($simulation, 'Geschwindigkeit', 'Minuten je Schritt', 1, 'ENELIX.Demo.Speed', 10, 30);
$peak = enelixDemoEnsureVariable($simulation, 'PVSpitze', 'PV-Spitzenleistung', 2, 'ENELIX.Demo.PowerPositive', 7000.0, 40);
$cloud = enelixDemoEnsureVariable($simulation, 'Bewoelkung', 'Bewölkung', 1, 'ENELIX.Demo.Percent', 20, 50);
$base = enelixDemoEnsureVariable($simulation, 'Grundlast', 'Haus-Grundlast', 2, 'ENELIX.Demo.PowerPositive', 900.0, 60);
$actorScript = enelixDemoEnsureScript(
$technical,
'AktorAktion',
'Simulierte Aktoren',
"<?php\nSetValue((int) \$_IPS['VARIABLE'], (bool) \$_IPS['VALUE']);\n",
10
);
$tickScript = enelixDemoEnsureScript(
$technical,
'SimulationTick',
'Simulation aktualisieren',
"<?php\nrequire_once IPS_GetKernelDir() . 'modules/Enelix-EMS/examples/Demoanlage/bootstrap.php';\nenelixDemoTick('Timer');\n",
20
);
$controlScript = enelixDemoEnsureScript(
$technical,
'BedienAktion',
'Bedienwerte übernehmen',
"<?php\nSetValue((int) \$_IPS['VARIABLE'], \$_IPS['VALUE']);\nIPS_RunScript(" . $tickScript . ");\n",
30
);
foreach ([$auto, $hour, $speed, $peak, $cloud, $base] as $variableId) {
IPS_SetVariableCustomAction($variableId, $controlScript);
}
enelixDemoEnsureCyclicEvent($tickScript, 'Simulationszyklus', 2);
$temp1 = enelixDemoEnsureVariable($technical, 'Boiler1Temperatur', 'Boiler 1 Temperatur', 2, '~Temperature', 49.0, 100);
$temp2 = enelixDemoEnsureVariable($technical, 'Boiler2Temperatur', 'Boiler 2 Temperatur', 2, '~Temperature', 47.0, 110);
$boiler11 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe1', 'Boiler 1 Stufe 1', 0, '~Switch', false, 120);
$boiler12 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe2', 'Boiler 1 Stufe 2', 0, '~Switch', false, 130);
$boiler21 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe1', 'Boiler 2 Stufe 1', 0, '~Switch', false, 140);
$boiler22 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe2', 'Boiler 2 Stufe 2', 0, '~Switch', false, 150);
$dryContact = enelixDemoEnsureVariable($technical, 'EntfeuchterKontakt', 'Entfeuchter Kontakt', 0, '~Switch', false, 160);
$chartUpdated = enelixDemoEnsureVariable($technical, 'LetztesDiagrammUpdate', 'Letztes Diagramm-Update', 1, '', 0, 170);
IPS_SetHidden($chartUpdated, true);
$flowPv = enelixDemoEnsureVariable($technical, 'EnergieflussPVkW', 'PV-Leistung', 2, '~Power', 0.0, 300);
$flowGrid = enelixDemoEnsureVariable($technical, 'EnergieflussNetzkW', 'Netzleistung', 2, '~Power', 0.0, 310);
$flowBase = enelixDemoEnsureVariable($technical, 'EnergieflussGrundlastkW', 'Haus-Grundlast', 2, '~Power', 0.0, 320);
$flowBoiler1 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler1kW', 'Boiler 1', 2, '~Power', 0.0, 330);
$flowBoiler2 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler2kW', 'Boiler 2', 2, '~Power', 0.0, 340);
$flowDryer = enelixDemoEnsureVariable($technical, 'EnergieflussEntfeuchterkW', 'Entfeuchter', 2, '~Power', 0.0, 350);
$flowHouse = enelixDemoEnsureVariable($technical, 'DiagrammHausverbrauchkW', 'Hausverbrauch gesamt', 2, '~Power', 0.0, 360);
$flowLoads = enelixDemoEnsureVariable($technical, 'DiagrammVerbraucherkW', 'Geregelte Verbraucher', 2, '~Power', 0.0, 370);
foreach ([
$flowPv, $flowGrid, $flowBase, $flowBoiler1,
$flowBoiler2, $flowDryer, $flowHouse, $flowLoads,
] as $flowVariableId) {
IPS_SetHidden($flowVariableId, true);
}
foreach ([$boiler11, $boiler12, $boiler21, $boiler22, $dryContact] as $actorId) {
IPS_SetVariableCustomAction($actorId, $actorScript);
}
$boiler1 = enelixDemoEnsureInstance($ems, 'Boiler1', 'Boiler 1 · 300 Liter', ENELIX_DEMO_BOILER_MODULE, 20);
$boiler2 = enelixDemoEnsureInstance($ems, 'Boiler2', 'Boiler 2 · 500 Liter', ENELIX_DEMO_BOILER_MODULE, 30);
$dryer = enelixDemoEnsureInstance($ems, 'Entfeuchter', 'Entfeuchter · 1-stufig', ENELIX_DEMO_SINGLE_MODULE, 40);
enelixDemoConfigure($boiler1, [
'Boilerfuehler_PT1' => $temp1,
'LeistungsStufen' => json_encode([
['Stufe' => 1, 'Leistung' => 1200, 'Schaltkontakt_Stufe' => $boiler11],
['Stufe' => 2, 'Leistung' => 2400, 'Schaltkontakt_Stufe' => $boiler12],
], JSON_THROW_ON_ERROR),
'Boilervolumen' => 300,
'LastwechselSperrzeit' => 2,
'TemperaturMaxAlter' => 30,
'LegionellenfunktionAktiv' => false,
'PrioritaetPV' => 0,
'PrioritaetPeak' => 2,
'Meldeintervall' => 5,
'VorgabeTimeout' => 30,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
]);
enelixDemoConfigure($boiler2, [
'Boilerfuehler_PT1' => $temp2,
'LeistungsStufen' => json_encode([
['Stufe' => 1, 'Leistung' => 1500, 'Schaltkontakt_Stufe' => $boiler21],
['Stufe' => 2, 'Leistung' => 3000, 'Schaltkontakt_Stufe' => $boiler22],
], JSON_THROW_ON_ERROR),
'Boilervolumen' => 500,
'LastwechselSperrzeit' => 2,
'TemperaturMaxAlter' => 30,
'LegionellenfunktionAktiv' => false,
'PrioritaetPV' => 1,
'PrioritaetPeak' => 1,
'Meldeintervall' => 5,
'VorgabeTimeout' => 30,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
]);
enelixDemoConfigure($dryer, [
'Nennleistung' => 900,
'SchaltkontaktVariableID' => $dryContact,
'RueckmeldungVariableID' => $dryContact,
'Mindesteinschaltdauer' => 2,
'Mindestausschaltdauer' => 2,
'Mindestlaufzeit' => 0,
'PrioritaetPV' => 2,
'PrioritaetPeak' => 0,
'Meldeintervall' => 5,
'VorgabeTimeout' => 30,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
]);
$energyDistribution = enelixDemoEnsureInstance(
$dashboard,
'EnergieverteilungNativ',
'Energiefluss',
ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE,
10
);
enelixDemoConfigureEnergyDistribution($energyDistribution, [
enelixDemoEnergyNode($flowPv, 'PV-Anlage', 'solar'),
enelixDemoEnergyNode($flowGrid, 'Stromnetz', 'grid'),
enelixDemoEnergyNode($flowBase, 'Haus-Grundlast', 'consumer'),
enelixDemoEnergyNode($flowBoiler1, 'Boiler 1', 'consumer', $temp1),
enelixDemoEnergyNode($flowBoiler2, 'Boiler 2', 'consumer', $temp2),
enelixDemoEnergyNode($flowDryer, 'Entfeuchter', 'consumer'),
]);
IPS_SetHidden($energyDistribution, false);
$oldEnergyDistribution = enelixDemoFind($dashboard, 'Energieverteilung');
if ($oldEnergyDistribution > 0 && $oldEnergyDistribution !== $energyDistribution) {
IPS_SetHidden($oldEnergyDistribution, true);
}
foreach ([$boiler1, $boiler2] as $boilerId) {
IPS_RequestAction($boilerId, 'Mindesttemperatur', 42.0);
IPS_RequestAction($boilerId, 'Maximaltemperatur', 62.0);
IPS_RequestAction($boilerId, 'Aktiv', true);
}
IPS_RequestAction($dryer, 'Aktiv', true);
$manager = enelixDemoEnsureInstance($ems, 'Manager', 'Enelix Manager', ENELIX_DEMO_MANAGER_MODULE, 10);
$assignment = array_map(
static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true],
[$boiler1, $boiler2, $dryer]
);
enelixDemoConfigure($manager, [
'NetzleistungVariableID' => $grid,
'Netzleistungsfaktor' => 1.0,
'MesswertMaxAlter' => 10,
'AutomatischeSuche' => false,
'SuchbereichID' => $root,
'VerbraucherZuordnung' => json_encode($assignment, JSON_THROW_ON_ERROR),
'KeepAlive' => 5,
'VerbraucherTimeout' => 15,
'SollwertSolarladen' => 0.0,
'Umschaltdifferenz' => 0.0,
'DiagnosevariablenAnzeigen' => true,
'LoggingEin' => false,
]);
foreach ([$boiler1, $boiler2, $dryer] as $consumerId) {
IPS_RequestAction($consumerId, 'Melden', true);
}
IPS_RequestAction($manager, 'Aktiv', true);
$boiler1List = enelixDemoEnsureInstance(
$dashboard,
'Boiler1Liste',
'Boiler 1',
ENELIX_DEMO_DUMMY_MODULE,
110
);
$boiler2List = enelixDemoEnsureInstance(
$dashboard,
'Boiler2Liste',
'Boiler 2',
ENELIX_DEMO_DUMMY_MODULE,
120
);
enelixDemoConfigure($boiler1List, []);
enelixDemoConfigure($boiler2List, []);
enelixDemoLinkInstanceVariables($boiler1List, $boiler1, $temp1);
enelixDemoLinkInstanceVariables($boiler2List, $boiler2, $temp2);
IPS_SetHidden($boiler1View, true);
IPS_SetHidden($boiler2View, true);
IPS_SetHidden($boiler1, true);
IPS_SetHidden($boiler2, true);
foreach (['Boiler1Link', 'Boiler2Link'] as $oldLinkIdent) {
$oldLink = enelixDemoFind($dashboard, $oldLinkIdent);
if ($oldLink > 0) {
IPS_SetHidden($oldLink, true);
}
}
enelixDemoEnsureLink($dashboard, 'ManagerLink', 'Manager', $manager, 100);
enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 130);
enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 70);
enelixDemoEnsureLink($simulation, 'Boiler2TemperaturLink', 'Boiler 2 Temperatur', $temp2, 80);
enelixDemoConfigureArchive([
$pvDisplay, $houseDisplay, $loadDisplay, $grid,
$boiler1Power, $boiler2Power, $dryerPower,
$pvEnergy, $houseEnergy, $gridImportEnergy, $gridExportEnergy,
$temp1, $temp2,
$flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2, $flowDryer,
$flowHouse, $flowLoads,
]);
enelixDemoEnsureChart($history, 'LeistungsdiagrammNativ', 'Leistungsverlauf', [
enelixDemoChartDataset($flowPv, 'PV', '#f4c542', '~Power'),
enelixDemoChartDataset($flowHouse, 'Hausverbrauch', '#2bb673', '~Power'),
enelixDemoChartDataset($flowLoads, 'Geregelte Verbraucher', '#2786ac', '~Power'),
enelixDemoChartDataset($flowGrid, 'Netzleistung', '#e95d5d', '~Power'),
], 10);
enelixDemoEnsureChart($history, 'EnergiediagrammNativ', 'Kumulierte Energien', [
enelixDemoChartDataset($pvEnergy, 'PV', '#f4c542', '~Electricity'),
enelixDemoChartDataset($houseEnergy, 'Hausverbrauch', '#2bb673', '~Electricity'),
enelixDemoChartDataset($gridImportEnergy, 'Netzbezug', '#e95d5d', '~Electricity'),
enelixDemoChartDataset($gridExportEnergy, 'Einspeisung', '#38a3db', '~Electricity'),
], 20);
enelixDemoConfigureVisualization($root, $energyDistribution);
enelixDemoTick('Install');
return [
'rootId' => $root,
'visualizationId' => enelixDemoFirstInstance(ENELIX_DEMO_TILE_MODULE),
'managerId' => $manager,
'consumerIds' => [$boiler1, $boiler2, $dryer],
'tickScriptId' => $tickScript,
];
}
function enelixDemoTick(string $source = 'Manual'): void
{
$root = enelixDemoRequiredId(0, ENELIX_DEMO_ROOT_IDENT);
$dashboard = enelixDemoRequiredId($root, 'Dashboard');
$simulation = enelixDemoRequiredId($root, 'Simulation');
$ems = enelixDemoRequiredId($root, 'EMS');
$technical = enelixDemoRequiredId($root, 'Technik');
$autoId = enelixDemoRequiredId($simulation, 'Automatik');
$hourId = enelixDemoRequiredId($simulation, 'Tageszeit');
$minutes = max(1, (int) GetValue(enelixDemoRequiredId($simulation, 'Geschwindigkeit')));
$hour = (float) GetValue($hourId);
if ($source === 'Timer' && (bool) GetValue($autoId)) {
$hour = fmod($hour + ($minutes / 60.0), 24.0);
SetValue($hourId, $hour);
}
$peak = (float) GetValue(enelixDemoRequiredId($simulation, 'PVSpitze'));
$cloud = (int) GetValue(enelixDemoRequiredId($simulation, 'Bewoelkung'));
$base = (float) GetValue(enelixDemoRequiredId($simulation, 'Grundlast'));
$sun = ($hour >= 6.0 && $hour <= 18.0)
? max(0.0, sin(M_PI * ($hour - 6.0) / 12.0))
: 0.0;
$pv = $peak * $sun * (1.0 - (min(100, max(0, $cloud)) * 0.0075));
$contacts = [
'boiler11' => enelixDemoRequiredId($technical, 'Boiler1Stufe1'),
'boiler12' => enelixDemoRequiredId($technical, 'Boiler1Stufe2'),
'boiler21' => enelixDemoRequiredId($technical, 'Boiler2Stufe1'),
'boiler22' => enelixDemoRequiredId($technical, 'Boiler2Stufe2'),
'dryer' => enelixDemoRequiredId($technical, 'EntfeuchterKontakt'),
];
$powerBefore = enelixDemoControlledPower($contacts);
$temp1 = enelixDemoRequiredId($technical, 'Boiler1Temperatur');
$temp2 = enelixDemoRequiredId($technical, 'Boiler2Temperatur');
if ($source === 'Timer') {
enelixDemoUpdateTemperature($temp1, (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0), $minutes);
enelixDemoUpdateTemperature($temp2, (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0), $minutes);
}
$gridId = enelixDemoRequiredId($dashboard, 'Netzleistung');
SetValue($gridId, round($base + $powerBefore - $pv, 1));
$consumers = [
enelixDemoRequiredId($ems, 'Boiler1'),
enelixDemoRequiredId($ems, 'Boiler2'),
enelixDemoRequiredId($ems, 'Entfeuchter'),
];
foreach ($consumers as $consumerId) {
IPS_RequestAction($consumerId, 'Melden', true);
}
$manager = enelixDemoRequiredId($ems, 'Manager');
IPS_RequestAction($manager, 'Regeln', true);
$boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0);
$boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0);
$dryer = (bool) GetValue($contacts['dryer']) ? 900 : 0;
$power = $boiler1 + $boiler2 + $dryer;
$house = $base + $power;
$grid = $house - $pv;
$pvId = enelixDemoRequiredId($dashboard, 'PVLeistung');
$houseId = enelixDemoRequiredId($dashboard, 'Hausverbrauch');
$loadId = enelixDemoRequiredId($dashboard, 'Verbraucherleistung');
SetValue($gridId, round($grid, 1));
SetValue($pvId, round($pv, 1));
SetValue($houseId, round($house, 1));
SetValue($loadId, (float) $power);
SetValue(enelixDemoRequiredId($dashboard, 'Boiler1Leistung'), (float) $boiler1);
SetValue(enelixDemoRequiredId($dashboard, 'Boiler2Leistung'), (float) $boiler2);
SetValue(enelixDemoRequiredId($dashboard, 'EntfeuchterLeistung'), (float) $dryer);
SetValue(enelixDemoRequiredId($technical, 'EnergieflussPVkW'), round($pv / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussNetzkW'), round($grid / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussGrundlastkW'), round($base / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler1kW'), round($boiler1 / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler2kW'), round($boiler2 / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussEntfeuchterkW'), round($dryer / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'DiagrammHausverbrauchkW'), round($house / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($power / 1000.0, 3));
$history = enelixDemoRequiredId($root, 'Historie');
$energyIds = [
'pv' => enelixDemoRequiredId($history, 'PVEnergie'),
'house' => enelixDemoRequiredId($history, 'HausEnergie'),
'import' => enelixDemoRequiredId($history, 'NetzbezugEnergie'),
'export' => enelixDemoRequiredId($history, 'EinspeisungEnergie'),
];
if ($source === 'Timer') {
$hours = $minutes / 60.0;
enelixDemoAddEnergy($energyIds['pv'], $pv, $hours);
enelixDemoAddEnergy($energyIds['house'], $house, $hours);
enelixDemoAddEnergy($energyIds['import'], max(0.0, $grid), $hours);
enelixDemoAddEnergy($energyIds['export'], max(0.0, -$grid), $hours);
}
}
function enelixDemoRenderFlow(array $state): string
{
$format = static fn (float $value): string => number_format($value, 0, ',', "'");
$hour = (float) $state['hour'];
$time = sprintf('%02d:%02d', (int) floor($hour), (int) round(($hour - floor($hour)) * 60) % 60);
$grid = (float) $state['grid'];
$gridLabel = $grid < -25 ? 'Einspeisung' : ($grid > 25 ? 'Netzbezug' : 'Ausgeglichen');
$gridArrow = $grid < -25 ? '&rarr;' : ($grid > 25 ? '&larr;' : '&harr;');
$gridClass = abs($grid) <= 250 ? 'good' : ($grid < 0 ? 'export' : 'import');
$b1Class = (float) $state['boiler1'] > 0 ? 'on' : '';
$b2Class = (float) $state['boiler2'] > 0 ? 'on' : '';
$dryClass = (float) $state['dryer'] > 0 ? 'on' : '';
return '<style>
.enelix-flow{box-sizing:border-box;font-family:Inter,Segoe UI,Arial,sans-serif;background:#10151c;color:#f4f7fa;padding:18px;min-height:420px}
.enelix-flow *{box-sizing:border-box}.enelix-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:18px}
.enelix-title{font-size:24px;font-weight:750;letter-spacing:0}.enelix-sub{color:#9dacbd;font-size:13px}
.enelix-line{display:grid;grid-template-columns:minmax(120px,1fr) 48px minmax(140px,1.2fr) 48px minmax(120px,1fr);align-items:center;gap:8px}
.enelix-node{border:1px solid #34404d;background:#17202a;padding:16px;text-align:center;border-radius:8px;min-height:92px}
.enelix-node small{display:block;color:#9dacbd;margin-bottom:8px}.enelix-node b{font-size:22px}
.enelix-node.solar{border-color:#b99628}.enelix-node.bus{border-color:#2786ac}.enelix-node.good{border-color:#2bb673}
.enelix-node.export{border-color:#38a3db}.enelix-node.import{border-color:#e95d5d}.enelix-arrow{text-align:center;font-size:30px;color:#9dacbd}
.enelix-loads{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:18px}
.enelix-load{border-left:4px solid #566371;background:#17202a;padding:13px 14px;border-radius:6px}
.enelix-load.on{border-left-color:#2bb673}.enelix-load b{display:block;font-size:17px;margin:4px 0}.enelix-load span{color:#9dacbd;font-size:12px}
.enelix-foot{display:flex;justify-content:space-between;gap:12px;margin-top:16px;padding-top:12px;border-top:1px solid #2a3541;color:#9dacbd;font-size:12px}
@media(max-width:640px){.enelix-line{grid-template-columns:1fr}.enelix-arrow{transform:rotate(90deg)}.enelix-loads{grid-template-columns:1fr}.enelix-head{align-items:start;flex-direction:column;gap:4px}}
</style><div class="enelix-flow"><div class="enelix-head"><div><div class="enelix-title">Enelix 2 Energiefluss</div><div class="enelix-sub">Manager regelt drei flexible Verbraucher</div></div><div class="enelix-sub">Simulation ' . $time . ' Uhr</div></div>
<div class="enelix-line"><div class="enelix-node solar"><small>PV-Erzeugung</small><b>' . $format((float) $state['pv']) . ' W</b></div><div class="enelix-arrow">&rarr;</div><div class="enelix-node bus"><small>Haus + flexible Lasten</small><b>' . $format((float) $state['base'] + (float) $state['boiler1'] + (float) $state['boiler2'] + (float) $state['dryer']) . ' W</b></div><div class="enelix-arrow">' . $gridArrow . '</div><div class="enelix-node ' . $gridClass . '"><small>' . $gridLabel . '</small><b>' . $format(abs($grid)) . ' W</b></div></div>
<div class="enelix-loads"><div class="enelix-load ' . $b1Class . '"><span>Priorität 1</span><b>Boiler 1 · ' . $format((float) $state['boiler1']) . ' W</b><span>' . number_format((float) $state['temp1'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $b2Class . '"><span>Priorität 2</span><b>Boiler 2 · ' . $format((float) $state['boiler2']) . ' W</b><span>' . number_format((float) $state['temp2'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $dryClass . '"><span>Priorität 3</span><b>Entfeuchter · ' . $format((float) $state['dryer']) . ' W</b><span>einstufiger Verbraucher</span></div></div>
<div class="enelix-foot"><span>Grundlast ' . $format((float) $state['base']) . ' W</span><span>Manager: ' . htmlspecialchars((string) $state['manager'], ENT_QUOTES, 'UTF-8') . '</span></div></div>';
}
function enelixDemoControlledPower(array $contacts): int
{
$boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0);
$boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0);
return $boiler1 + $boiler2 + ((bool) GetValue($contacts['dryer']) ? 900 : 0);
}
function enelixDemoUpdateTemperature(int $variableId, int $power, int $minutes): void
{
$current = (float) GetValue($variableId);
$delta = (($power / 1000.0) * 0.03 * $minutes) - (0.01 * $minutes);
SetValue($variableId, round(min(70.0, max(38.0, $current + $delta)), 2));
}
function enelixDemoValidateRuntime(): void
{
$version = IPS_GetKernelVersion();
if (version_compare($version, '8.0', '<') || version_compare($version, '9.0', '>=')) {
throw new RuntimeException('Die Demo benötigt IP-Symcon 8.x; aktiv ist ' . $version . '.');
}
$available = IPS_GetModuleList();
foreach ([
ENELIX_DEMO_MANAGER_MODULE,
ENELIX_DEMO_BOILER_MODULE,
ENELIX_DEMO_SINGLE_MODULE,
ENELIX_DEMO_DUMMY_MODULE,
ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE,
] as $moduleId) {
if (!in_array($moduleId, $available, true)) {
throw new RuntimeException('Benötigtes Enelix-Modul fehlt: ' . $moduleId);
}
}
}
function enelixDemoEnsureProfiles(): void
{
enelixDemoEnsureProfile('ENELIX.Demo.Power', 2, -12000, 12000, 100, 0, ' W');
enelixDemoEnsureProfile('ENELIX.Demo.PowerPositive', 2, 0, 12000, 100, 0, ' W');
enelixDemoEnsureProfile('ENELIX.Demo.Hour', 2, 0, 23.75, 0.25, 2, ' h');
enelixDemoEnsureProfile('ENELIX.Demo.Percent', 1, 0, 100, 5, 0, ' %');
enelixDemoEnsureProfile('ENELIX.Demo.Speed', 1, 1, 60, 1, 0, ' min');
enelixDemoEnsureProfile('ENELIX.Demo.Energy', 2, 0, 1000000, 0.01, 2, ' kWh');
}
function enelixDemoEnsureProfile(string $name, int $type, float $min, float $max, float $step, int $digits, string $suffix): void
{
if (!IPS_VariableProfileExists($name)) {
IPS_CreateVariableProfile($name, $type);
}
$profile = IPS_GetVariableProfile($name);
if ((int) $profile['ProfileType'] !== $type) {
throw new RuntimeException('Variablenprofil hat einen unpassenden Typ: ' . $name);
}
IPS_SetVariableProfileValues($name, $min, $max, $step);
IPS_SetVariableProfileDigits($name, $digits);
IPS_SetVariableProfileText($name, '', $suffix);
}
function enelixDemoEnsureCategory(int $parent, string $ident, string $name, int $position, bool $hidden = false): int
{
$id = enelixDemoFind($parent, $ident);
if ($id === 0) {
$id = IPS_CreateCategory();
IPS_SetParent($id, $parent);
IPS_SetIdent($id, $ident);
}
enelixDemoRequireType($id, 0);
IPS_SetName($id, $name);
IPS_SetPosition($id, $position);
IPS_SetHidden($id, $hidden);
return $id;
}
function enelixDemoEnsureVariable(int $parent, string $ident, string $name, int $type, string $profile, $default, int $position): int
{
$id = enelixDemoFind($parent, $ident);
$created = false;
if ($id === 0) {
$id = IPS_CreateVariable($type);
IPS_SetParent($id, $parent);
IPS_SetIdent($id, $ident);
$created = true;
}
enelixDemoRequireType($id, 2);
$variable = IPS_GetVariable($id);
if ((int) $variable['VariableType'] !== $type) {
throw new RuntimeException('Variablentyp-Konflikt bei ' . $ident);
}
IPS_SetName($id, $name);
IPS_SetPosition($id, $position);
if ($profile !== '') {
IPS_SetVariableCustomProfile($id, $profile);
}
if ($created) {
SetValue($id, $default);
}
return $id;
}
function enelixDemoEnsureChart(
int $parent,
string $ident,
string $name,
array $datasets,
int $position
): int {
$id = enelixDemoFind($parent, $ident);
if ($id === 0) {
$id = IPS_CreateMedia(4);
IPS_SetParent($id, $parent);
IPS_SetIdent($id, $ident);
}
enelixDemoRequireType($id, 5);
if ((int) IPS_GetMedia($id)['MediaType'] !== 4) {
throw new RuntimeException('Medientyp-Konflikt bei ' . $ident);
}
IPS_SetName($id, $name);
IPS_SetPosition($id, $position);
IPS_SetHidden($id, false);
IPS_SetMediaFile($id, 'media/' . $id . '.chart', false);
IPS_SetMediaContent(
$id,
base64_encode(json_encode(['datasets' => $datasets], JSON_THROW_ON_ERROR))
);
return $id;
}
function enelixDemoChartDataset(
int $variableId,
string $title,
string $strokeColor,
string $profile
): array {
return [
'variableID' => $variableId,
'fillColor' => 'clear',
'strokeColor' => $strokeColor,
'timeOffset' => 0,
'visible' => true,
'title' => $title,
'type' => 'line',
'side' => 'left',
'profile' => $profile,
];
}
function enelixDemoEnsureScript(int $parent, string $ident, string $name, string $content, int $position): int
{
$id = enelixDemoFind($parent, $ident);
if ($id === 0) {
$id = IPS_CreateScript(0);
IPS_SetParent($id, $parent);
IPS_SetIdent($id, $ident);
}
enelixDemoRequireType($id, 3);
IPS_SetName($id, $name);
IPS_SetPosition($id, $position);
IPS_SetScriptContent($id, $content);
return $id;
}
function enelixDemoEnsureInstance(int $parent, string $ident, string $name, string $moduleId, int $position): int
{
$id = enelixDemoFind($parent, $ident);
if ($id === 0) {
$id = IPS_CreateInstance($moduleId);
IPS_SetParent($id, $parent);
IPS_SetIdent($id, $ident);
}
enelixDemoRequireType($id, 1);
$actualModule = IPS_GetInstance($id)['ModuleInfo']['ModuleID'];
if ($actualModule !== $moduleId) {
throw new RuntimeException('Modulkonflikt bei Instanz ' . $ident);
}
IPS_SetName($id, $name);
IPS_SetPosition($id, $position);
return $id;
}
function enelixDemoEnsureLink(int $parent, string $ident, string $name, int $target, int $position): int
{
$id = enelixDemoFind($parent, $ident);
if ($id === 0) {
$id = IPS_CreateLink();
IPS_SetParent($id, $parent);
IPS_SetIdent($id, $ident);
}
enelixDemoRequireType($id, 6);
IPS_SetName($id, $name);
IPS_SetPosition($id, $position);
IPS_SetLinkTargetID($id, $target);
return $id;
}
function enelixDemoEnsureCyclicEvent(int $scriptId, string $ident, int $seconds): int
{
$id = enelixDemoFind($scriptId, $ident);
if ($id === 0) {
$id = IPS_CreateEvent(1);
IPS_SetParent($id, $scriptId);
IPS_SetIdent($id, $ident);
}
enelixDemoRequireType($id, 4);
IPS_SetName($id, 'Alle ' . $seconds . ' Sekunden');
IPS_SetEventCyclic($id, 0, 0, 0, 0, 1, $seconds);
IPS_SetEventAction(
$id,
'{346AA8C1-30E0-1663-78EF-93EFADFAC650}',
['SCRIPT' => 'IPS_RunScript(' . $scriptId . ');']
);
IPS_SetEventActive($id, true);
return $id;
}
function enelixDemoConfigure(int $instanceId, array $properties): void
{
foreach ($properties as $name => $value) {
IPS_SetProperty($instanceId, (string) $name, $value);
}
IPS_ApplyChanges($instanceId);
if ((int) IPS_GetInstance($instanceId)['InstanceStatus'] !== 102) {
throw new RuntimeException('Instanz ist nach der Konfiguration nicht aktiv: ' . IPS_GetName($instanceId));
}
}
function enelixDemoConfigureArchive(array $variableIds): void
{
$archive = enelixDemoFirstInstance(ENELIX_DEMO_ARCHIVE_MODULE);
if ($archive === 0) {
return;
}
foreach ($variableIds as $variableId) {
AC_SetLoggingStatus($archive, $variableId, true);
AC_SetAggregationType($archive, $variableId, 0);
AC_SetGraphStatus($archive, $variableId, true);
}
IPS_ApplyChanges($archive);
}
function enelixDemoConfigureVisualization(int $root, int $flow): void
{
$visualization = enelixDemoFirstInstance(ENELIX_DEMO_TILE_MODULE);
if ($visualization === 0) {
return;
}
IPS_SetProperty($visualization, 'BaseID', $root);
IPS_SetProperty($visualization, 'CategoryDisplay', 'Cards');
IPS_SetProperty($visualization, 'ShowGreeting', 3);
IPS_SetProperty($visualization, 'GreetingName', 'Enelix 2 Demo');
IPS_SetProperty($visualization, 'ShowDateTime', true);
IPS_SetProperty($visualization, 'Favorites', json_encode([['ObjectID' => $flow]], JSON_THROW_ON_ERROR));
IPS_ApplyChanges($visualization);
VISU_Reload($visualization);
}
function enelixDemoFirstInstance(string $moduleId): int
{
$ids = IPS_GetInstanceListByModuleID($moduleId);
return $ids === [] ? 0 : (int) $ids[0];
}
function enelixDemoFind(int $parent, string $ident): int
{
$id = @IPS_GetObjectIDByIdent($ident, $parent);
return $id === false ? 0 : (int) $id;
}
function enelixDemoRequiredId(int $parent, string $ident): int
{
$id = enelixDemoFind($parent, $ident);
if ($id === 0) {
throw new RuntimeException('Demo-Objekt fehlt: ' . $ident);
}
return $id;
}
function enelixDemoRequireType(int $objectId, int $type): void
{
$actual = (int) IPS_GetObject($objectId)['ObjectType'];
if ($actual !== $type) {
throw new RuntimeException('Objekttyp-Konflikt bei ID ' . $objectId);
}
}
function enelixDemoRemove(bool $confirmed = false): void
{
if (!$confirmed) {
throw new RuntimeException('Rollback nur mit enelixDemoRemove(true) ausführen.');
}
$root = enelixDemoFind(0, ENELIX_DEMO_ROOT_IDENT);
if ($root === 0) {
return;
}
$visualization = enelixDemoFirstInstance(ENELIX_DEMO_TILE_MODULE);
if ($visualization > 0 && (int) json_decode(IPS_GetConfiguration($visualization), true)['BaseID'] === $root) {
IPS_SetProperty($visualization, 'BaseID', 0);
IPS_ApplyChanges($visualization);
}
enelixDemoDeleteTree($root);
foreach (['ENELIX.Demo.Power', 'ENELIX.Demo.PowerPositive', 'ENELIX.Demo.Hour', 'ENELIX.Demo.Percent', 'ENELIX.Demo.Speed', 'ENELIX.Demo.Energy'] as $profile) {
if (IPS_VariableProfileExists($profile)) {
IPS_DeleteVariableProfile($profile);
}
}
}
function enelixDemoDeleteTree(int $objectId): void
{
foreach (array_reverse(IPS_GetChildrenIDs($objectId)) as $childId) {
enelixDemoDeleteTree((int) $childId);
}
$type = (int) IPS_GetObject($objectId)['ObjectType'];
if ($type === 1 && (int) IPS_GetInstance($objectId)['ConnectionID'] > 0) {
IPS_DisconnectInstance($objectId);
}
switch ($type) {
case 0: IPS_DeleteCategory($objectId); break;
case 1: IPS_DeleteInstance($objectId); break;
case 2: IPS_DeleteVariable($objectId); break;
case 3: IPS_DeleteScript($objectId, true); break;
case 4: IPS_DeleteEvent($objectId); break;
case 5: IPS_DeleteMedia($objectId, true); break;
case 6: IPS_DeleteLink($objectId); break;
default: throw new RuntimeException('Unbekannter Objekttyp: ' . $type);
}
}
function enelixDemoEnergyNode(
int $variableId,
string $name,
string $type,
int $infoVariableId = 0
): array {
return [
'VariableConfigType' => 0,
'VariableID' => $variableId,
'SecondaryVariableID' => 1,
'Name' => $name,
'Type' => $type,
'Grouped' => false,
'CalculationType' => 0,
'InfoVariableID' => $infoVariableId > 0 ? $infoVariableId : 1,
'Invert' => false,
];
}
function enelixDemoConfigureEnergyDistribution(int $instanceId, array $nodes): void
{
enelixDemoConfigure($instanceId, [
'Calculation' => true,
'Config' => json_encode($nodes, JSON_THROW_ON_ERROR),
'EnableLimits' => false,
'Restrictions' => '[]',
]);
}
function enelixDemoLinkInstanceVariables(int $categoryId, int $instanceId, int $sensorId): void
{
enelixDemoEnsureLink($categoryId, 'SensorTemperatur', 'Gemessene Temperatur', $sensorId, 5);
$position = 10;
foreach (IPS_GetChildrenIDs($instanceId) as $childId) {
if ((int) IPS_GetObject($childId)['ObjectType'] !== 2) {
continue;
}
$object = IPS_GetObject($childId);
$ident = $object['ObjectIdent'] !== '' ? $object['ObjectIdent'] : (string) $childId;
enelixDemoEnsureLink(
$categoryId,
'Wert_' . preg_replace('/[^A-Za-z0-9_]/', '_', $ident),
(string) $object['ObjectName'],
(int) $childId,
$position
);
$position += 10;
}
}
function enelixDemoAddEnergy(int $variableId, float $powerW, float $hours): void
{
$energy = (float) GetValue($variableId) + (($powerW * $hours) / 1000.0);
SetValue($variableId, round($energy, 4));
}
function enelixDemoUpdateCharts(int $historyId, array $powerIds, array $energyIds): void
{
$archive = enelixDemoFirstInstance(ENELIX_DEMO_ARCHIVE_MODULE);
if ($archive === 0) {
return;
}
$end = time();
$start = $end - 1200;
$powerSeries = [
['name' => 'PV', 'color' => '#f4c542', 'values' => enelixDemoLoggedSeries($archive, $powerIds[0], $start, $end)],
['name' => 'Hausverbrauch', 'color' => '#2bb673', 'values' => enelixDemoLoggedSeries($archive, $powerIds[1], $start, $end)],
['name' => 'Netzleistung', 'color' => '#e95d5d', 'values' => enelixDemoLoggedSeries($archive, $powerIds[2], $start, $end)],
];
$energySeries = [
['name' => 'PV', 'color' => '#f4c542', 'values' => enelixDemoLoggedSeries($archive, $energyIds['pv'], $start, $end)],
['name' => 'Haus', 'color' => '#2bb673', 'values' => enelixDemoLoggedSeries($archive, $energyIds['house'], $start, $end)],
['name' => 'Netzbezug', 'color' => '#e95d5d', 'values' => enelixDemoLoggedSeries($archive, $energyIds['import'], $start, $end)],
['name' => 'Einspeisung', 'color' => '#38a3db', 'values' => enelixDemoLoggedSeries($archive, $energyIds['export'], $start, $end)],
];
SetValue(
enelixDemoRequiredId($historyId, 'Leistungsdiagramm'),
enelixDemoRenderChart('Leistungsverlauf · letzte 20 Minuten', $powerSeries, 'W', $start, $end)
);
SetValue(
enelixDemoRequiredId($historyId, 'Energiediagramm'),
enelixDemoRenderChart('Kumulierte Energie · Simulationszeit', $energySeries, 'kWh', $start, $end)
);
}
function enelixDemoLoggedSeries(int $archiveId, int $variableId, int $start, int $end): array
{
$values = array_reverse(AC_GetLoggedValues($archiveId, $variableId, $start, $end, 0));
if ($values === []) {
return [['time' => $end, 'value' => (float) GetValue($variableId)]];
}
$step = max(1, (int) ceil(count($values) / 90));
$result = [];
foreach ($values as $index => $value) {
if ($index % $step !== 0 && $index !== count($values) - 1) {
continue;
}
$result[] = [
'time' => (int) $value['TimeStamp'],
'value' => (float) $value['Value'],
];
}
return $result;
}
function enelixDemoRenderChart(
string $title,
array $series,
string $unit,
int $start,
int $end
): string {
$allValues = [0.0];
foreach ($series as $line) {
foreach ($line['values'] as $point) {
$allValues[] = (float) $point['value'];
}
}
$min = min($allValues);
$max = max($allValues);
if (abs($max - $min) < 0.0001) {
$max = $min + 1.0;
}
$left = 72.0;
$top = 34.0;
$width = 650.0;
$height = 210.0;
$timeRange = max(1, $end - $start);
$valueRange = $max - $min;
$grid = '';
for ($index = 0; $index <= 4; $index++) {
$y = $top + (($height / 4) * $index);
$value = $max - (($valueRange / 4) * $index);
$grid .= '<line x1="' . $left . '" y1="' . $y . '" x2="' . ($left + $width) . '" y2="' . $y . '" />';
$grid .= '<text x="' . ($left - 10) . '" y="' . ($y + 4) . '" text-anchor="end">'
. number_format($value, $unit === 'W' ? 0 : 2, ',', "'") . '</text>';
}
$lines = '';
$legend = '';
foreach ($series as $line) {
$points = [];
foreach ($line['values'] as $point) {
$x = $left + ((((int) $point['time'] - $start) / $timeRange) * $width);
$y = $top + (($max - (float) $point['value']) / $valueRange * $height);
$points[] = round($x, 1) . ',' . round($y, 1);
}
$color = htmlspecialchars((string) $line['color'], ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars((string) $line['name'], ENT_QUOTES, 'UTF-8');
$lines .= '<polyline points="' . implode(' ', $points) . '" stroke="' . $color . '" />';
$legend .= '<span><i style="background:' . $color . '"></i>' . $name . '</span>';
}
return '<style>
.enelix-chart{font-family:Inter,Segoe UI,Arial,sans-serif;background:#10151c;color:#f4f7fa;padding:14px;border-radius:6px}
.enelix-chart h3{font-size:17px;margin:0 0 8px}.enelix-chart svg{display:block;width:100%;height:auto;min-height:230px}
.enelix-chart .grid line{stroke:#33404d;stroke-width:1}.enelix-chart text{fill:#9dacbd;font-size:11px}
.enelix-chart polyline{fill:none;stroke-width:3;stroke-linejoin:round;stroke-linecap:round}
.enelix-legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:4px;color:#c5ced8;font-size:12px}
.enelix-legend span{display:flex;align-items:center;gap:6px}.enelix-legend i{display:inline-block;width:18px;height:3px}
</style><div class="enelix-chart"><h3>' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '</h3>
<svg viewBox="0 0 760 280" role="img" aria-label="' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '">
<g class="grid">' . $grid . '</g>' . $lines . '
<text x="' . $left . '" y="266">' . date('H:i', $start) . '</text>
<text x="' . ($left + $width) . '" y="266" text-anchor="end">' . date('H:i', $end) . '</text>
<text x="18" y="20">' . htmlspecialchars($unit, ENT_QUOTES, 'UTF-8') . '</text>
</svg><div class="enelix-legend">' . $legend . '</div></div>';
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"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"
+279
View File
@@ -0,0 +1,279 @@
<?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;
/**
* Erzeugt das bisherige Raster: 250-W-Grundschritte und
* 50-W-Feinschritte im Bereich von -200 W bis +200 W.
*
* @return list<int>
*/
public static function leistungsraster(float $maxLadenW, float $maxEntladenW): array
{
self::pruefeLeistungsgrenzen($maxLadenW, $maxEntladenW);
$maxLaden = (int) floor($maxLadenW / 50.0) * 50;
$maxEntladen = (int) floor($maxEntladenW / 50.0) * 50;
$werte = [0];
for ($leistung = -$maxEntladen; $leistung <= 0; $leistung += 250) {
$werte[] = $leistung;
}
for ($leistung = 0; $leistung <= $maxLaden; $leistung += 250) {
$werte[] = $leistung;
}
for ($leistung = -200; $leistung <= 200; $leistung += 50) {
if ($leistung >= -$maxEntladen && $leistung <= $maxLaden) {
$werte[] = $leistung;
}
}
$werte = array_values(array_unique($werte));
sort($werte, SORT_NUMERIC);
return $werte;
}
public static function hystereseAktiv(
float $ladezustand,
float $reserve,
float $hysterese,
bool $bisherAktiv
): bool {
self::pruefeLadezustandsgrenzen($reserve, $hysterese);
if ($ladezustand > $reserve + $hysterese) {
return false;
}
if ($ladezustand <= $reserve) {
return true;
}
return $bisherAktiv;
}
/**
* Bildet das zustands- und betriebsartabhaengige Angebot aus Enelix 1 ab.
*
* @return list<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];
}
$raster = self::leistungsraster($maxLadenW, $maxEntladenW);
if ($peakbetrieb) {
if ($ladezustand > $reserve && !$hystereseAktiv) {
$ziel = $netzleistungW > 0.0
? -min($netzleistungW, $maxEntladenW) + $istleistungW
: $netzleistungW;
return [self::begrenzeLeistung($ziel, $maxLadenW, $maxEntladenW)];
}
if ($ladezustand > $reserve && $hystereseAktiv) {
return self::filtere($raster, static fn (int $wert): bool => $wert <= 0);
}
if ($ladezustand > $minimum) {
return $raster;
}
return self::filtere($raster, static fn (int $wert): bool => $wert >= 0);
}
if ($ladezustand > 99.0) {
return self::filtere($raster, static fn (int $wert): bool => $wert <= 0);
}
if ($ladezustand > $reserve && !$hystereseAktiv) {
return $raster;
}
if ($ladezustand >= $reserve && $hystereseAktiv) {
return self::filtere($raster, static fn (int $wert): bool => $wert >= 0);
}
return [(int) round($maxLadenW)];
}
/**
* Liefert nur die Registerwerte, die der gewaehlte Batterietyp benoetigt.
*
* @return array<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;
}
/** @param list<int> $werte
* @return list<int>
*/
private static function filtere(array $werte, callable $filter): array
{
return array_values(array_filter($werte, $filter));
}
private static function begrenzeLeistung(
float $leistungW,
float $maxLadenW,
float $maxEntladenW
): int {
return (int) round(min($maxLadenW, max(-$maxEntladenW, $leistungW)));
}
private static function pruefeLeistungsgrenzen(float $maxLadenW, float $maxEntladenW): void
{
if ($maxLadenW < 0.0 || $maxEntladenW < 0.0) {
throw new InvalidArgumentException('Leistungsgrenzen duerfen nicht negativ sein.');
}
}
private static function pruefeLadezustandsgrenzen(float $reserve, float $hysterese): void
{
if ($reserve < 0.0 || $reserve > 100.0) {
throw new InvalidArgumentException('Reserve muss zwischen 0 und 100 Prozent liegen.');
}
if ($hysterese < 0.0 || $reserve + $hysterese > 100.0) {
throw new InvalidArgumentException(
'Hysterese muss positiv sein und mit der Reserve hoechstens 100 Prozent ergeben.'
);
}
}
private static function pruefeBatterietyp(int $batterietyp): void
{
if (!in_array($batterietyp, [
self::TYP_HERSTELLERUNABHAENGIG,
self::TYP_GOODWE,
self::TYP_SOLAREDGE,
self::TYP_SIGENERGY,
], true)) {
throw new InvalidArgumentException('Batterietyp ist nicht konfiguriert.');
}
}
private static function pruefeSteuerung(int $steuerung): void
{
if (!in_array($steuerung, [
self::STEUERUNG_WECHSELRICHTER,
self::STEUERUNG_ENELIX,
], true)) {
throw new InvalidArgumentException('Batteriesteuerung ist ungueltig.');
}
}
}
+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);
}
}
+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);
}
}
}
+146
View File
@@ -0,0 +1,146 @@
<?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): 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 = $warVerbunden && $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.');
}
}
+147
View File
@@ -0,0 +1,147 @@
<?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 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.');
}
}
/**
* @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);
}
}
}
+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;
} }
+22 -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']);
@@ -93,6 +99,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);
}
}
+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>
+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}')
);
}
}
+191
View File
@@ -0,0 +1,191 @@
<?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 testLeistungsrasterBehaeltGrobeUndFeineSchritte(): void
{
self::assertSame(
[
-1000, -750, -500, -250, -200, -150, -100, -50,
0, 50, 100, 150, 200, 250, 500, 750, 1000,
],
BatterieRegler::leistungsraster(1000.0, 1000.0)
);
}
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 testPvAngebotEntsprichtDemBisherigenSocVerhalten(): void
{
$voll = BatterieRegler::leistungsraster(1000.0, 1000.0);
self::assertSame(
$voll,
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(
[-1000, -750, -500, -250, -200, -150, -100, -50, 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 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::leistungsraster(-1.0, 1000.0);
}
}
+145
View File
@@ -0,0 +1,145 @@
<?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,
'temp1' => 51.2,
'temp2' => 48.7,
'manager' => 'PV',
]);
self::assertStringContainsString('Enelix 2 Energiefluss', $html);
self::assertStringContainsString('Boiler 1', $html);
self::assertStringContainsString('Boiler 2', $html);
self::assertStringContainsString('Entfeuchter', $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("'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(
"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 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);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
use PHPUnit\Framework\TestCase;
final class EaseeGatewayProtokollTest extends TestCase
{
public function testVerschachtelteSignalRBeobachtungenWerdenExtrahiert(): void
{
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
'arguments' => [
'EH123456',
[
['id' => 109, 'value' => 3],
['ObservationId' => 110, 'Value' => 30],
],
],
], ['EH123456']);
self::assertSame([
['Seriennummer' => 'EH123456', 'ID' => 109, 'Wert' => 3],
['Seriennummer' => 'EH123456', 'ID' => 110, 'Wert' => 30],
], $beobachtungen);
}
public function testNichtAbonnierteStationenUndUnbekannteIdsWerdenIgnoriert(): void
{
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
[
'serialNumber' => 'EH999999',
'id' => 109,
'value' => 3,
],
[
'serialNumber' => 'EH123456',
'id' => 999,
'value' => 1,
],
], ['EH123456']);
self::assertSame([], $beobachtungen);
}
public function testSeriennummerWirdNormalisiert(): void
{
self::assertSame(
'EH123456',
EaseeGatewayProtokoll::seriennummer(' eh123456 ')
);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\EaseeLadestatus;
use PHPUnit\Framework\TestCase;
final class EaseeLadestatusTest extends TestCase
{
public function testFahrzeugUndEinphasigesLadenKommenDirektAusApiWerten(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 3,
'110' => 10,
'120' => 2.3,
'182' => 10.1,
], 16);
self::assertTrue($status['FahrzeugVerbunden']);
self::assertFalse($status['FahrzeugGeladen']);
self::assertSame(3, $status['Fahrzeugstatus']);
self::assertSame(1, $status['Phasenzahl']);
self::assertEqualsWithDelta(2300.0, $status['Istleistung_W'], 0.01);
self::assertEqualsWithDelta(10.1, $status['Ladestrom_A'], 0.01);
}
public function testDreiphasigesLadenWirdNichtAusLeistungGeschaetzt(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 3,
'110' => 30,
'120' => 1.0,
'182' => 2.0,
'183' => 2.2,
'184' => 2.1,
], 32);
self::assertSame(3, $status['Phasenzahl']);
self::assertEqualsWithDelta(2.2, $status['Ladestrom_A'], 0.01);
}
public function testZweiphasigerApiWertWirdNichtAlsEinOderDreiGeschaetzt(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 3,
'110' => 20,
'120' => 4.6,
], 16);
self::assertSame(0, $status['Phasenzahl']);
}
public function testGetrenntUndGeladenFolgenDemEaseeBetriebszustand(): void
{
$getrennt = EaseeLadestatus::ausBeobachtungen(['109' => 1, '110' => 30], 16);
$geladen = EaseeLadestatus::ausBeobachtungen(['109' => 4, '110' => 30], 16);
self::assertFalse($getrennt['FahrzeugVerbunden']);
self::assertSame(0, $getrennt['Phasenzahl']);
self::assertTrue($geladen['FahrzeugVerbunden']);
self::assertTrue($geladen['FahrzeugGeladen']);
self::assertSame(4, $geladen['Fahrzeugstatus']);
}
public function testUnbekannterApiBetriebszustandGibtRegelungNichtFrei(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 99,
'110' => 30,
], 16);
self::assertFalse($status['StatusGueltig']);
self::assertFalse($status['FahrzeugVerbunden']);
self::assertSame(0, $status['Fahrzeugstatus']);
self::assertSame(0, $status['Phasenzahl']);
}
public function testApiUndKabelgrenzenBegrenzenDenKonfiguriertenStrom(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 2,
'110' => 10,
'47' => 25.0,
'104' => 13.7,
], 32);
self::assertSame(13, $status['ApiMaximalstrom_A']);
}
public function testNurSolarladenIstFestErzwungen(): void
{
self::assertFalse(EaseeLadestatus::solarladenErzwungen(0));
self::assertTrue(EaseeLadestatus::solarladenErzwungen(1));
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class EaseeModuleStrukturTest extends TestCase
{
public function testGatewayUndLadestationSindVollstaendigeModule(): void
{
foreach (['EaseeGateway', 'LadestationGateway'] as $modul) {
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
self::assertFileExists(__DIR__ . '/../' . $modul . '/' . $datei);
}
}
}
public function testDatenSchnittstellenPassenZusammen(): void
{
$gateway = $this->module('EaseeGateway');
$ladestation = $this->module('LadestationGateway');
self::assertContains(
'{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}',
$gateway['implemented']
);
self::assertContains(
'{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}',
$gateway['childRequirements']
);
self::assertContains(
'{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}',
$ladestation['parentRequirements']
);
self::assertContains(
'{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}',
$ladestation['implemented']
);
}
public function testZugangsdatenLiegenNurImGateway(): void
{
$gatewayForm = (string) file_get_contents(
__DIR__ . '/../EaseeGateway/form.json'
);
$ladestationForm = (string) file_get_contents(
__DIR__ . '/../LadestationGateway/form.json'
);
self::assertStringContainsString('PasswordTextBox', $gatewayForm);
self::assertStringNotContainsString('PasswordTextBox', $ladestationForm);
self::assertStringNotContainsString('Username', $ladestationForm);
}
public function testLadestationVerwendetApiStatusUndGemeinsamenVertrag(): void
{
$modul = (string) file_get_contents(
__DIR__ . '/../LadestationGateway/module.php'
);
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
self::assertStringContainsString('EaseeLadestatus::ausBeobachtungen', $modul);
self::assertStringContainsString("'FahrzeugVerbunden'", $modul);
self::assertStringContainsString("'Phasenzahl'", $modul);
self::assertStringNotContainsString('7500', $modul);
self::assertStringNotContainsString('Abfrageintervall', $modul);
}
/** @return array<string, mixed> */
private function module(string $name): array
{
return json_decode(
(string) file_get_contents(__DIR__ . '/../' . $name . '/module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
}
}
+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);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?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 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,73 @@
<?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("EnableAction('Ladefreigabe')", $modul);
self::assertStringContainsString("EnableAction('Solarladen')", $modul);
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul);
self::assertStringContainsString("ReadPropertyBoolean('EinstellungenInVisu')", $modul);
self::assertStringContainsString('UnregisterVariable($ident)', $modul);
}
}
+1
View File
@@ -15,6 +15,7 @@ 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::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']); self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']);
} }
+143
View File
@@ -0,0 +1,143 @@
<?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 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();
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class ManagerModulstrukturTest extends TestCase
{
public function testManagerIstAlsSymconModulVollstaendig(): void
{
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
self::assertFileExists(__DIR__ . '/../Manager/' . $datei);
}
$metadaten = json_decode(
(string) file_get_contents(__DIR__ . '/../Manager/module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame('Manager', $metadaten['name']);
self::assertSame('Enelix', $metadaten['vendor']);
self::assertSame(['Manager'], $metadaten['aliases']);
self::assertSame(3, $metadaten['type']);
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $metadaten['id']);
}
public function testFormularEnthaeltDieZentralenManagerProperties(): void
{
$formular = json_decode(
(string) file_get_contents(__DIR__ . '/../Manager/form.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$json = json_encode($formular, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
foreach ([
'NetzleistungVariableID',
'VerbraucherZuordnung',
'AutomatischeVerbraucherZuordnung',
'Lastspitzenmodus',
'Monatsgrenzen',
'SollwertSolarladen',
'KeepAlive',
'VerbraucherTimeout',
'MesswertMaxAlter',
'Umschaltdifferenz',
'DiagnosevariablenAnzeigen',
'LoggingEin',
'Lizenzcode',
'LizenzPruefen',
'LizenzInstallationID',
'LizenzInformation',
'VerbraucherAktualisieren',
] as $property) {
self::assertStringContainsString($property, $json);
}
foreach (['Regelintervall', 'Vorgabeintervall', 'UmschaltschwelleUeberschuss',
'UmschaltschwelleLastspitze', 'Optionale obere Anschluesse', 'LizenzAnschluss'] as $entfernt
) {
self::assertStringNotContainsString($entfernt, $json);
}
foreach (['Grundeinstellungen', 'Erweiterte Einstellungen', 'Prognose / Forecast',
'SDL / VGT', 'Lizenzierung', 'Stoerueberwachung'] as $bereich
) {
self::assertStringContainsString($bereich, $json);
}
self::assertSame('Lizenzierung', $formular['elements'][0]['caption']);
self::assertStringContainsString(
"'SuchbereichID' => \$SuchbereichID",
$json
);
self::assertStringContainsString(
"'Zuordnung' => \$AutomatischeVerbraucherZuordnung",
$json
);
}
public function testManagerTrenntDiagnosevariablenUndDebugLogging(): void
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertStringContainsString(
"RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false)",
$inhalt
);
self::assertStringContainsString("ReadPropertyBoolean('DiagnosevariablenAnzeigen')", $inhalt);
self::assertStringContainsString('UnregisterVariable($ident)', $inhalt);
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $inhalt);
self::assertStringContainsString('SendDebug(', $inhalt);
}
public function testManagerBindetDieLizenzierungFailClosedAn(): void
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertFileExists(__DIR__ . '/../libs/Lizenzpruefung.php');
self::assertStringContainsString(
"RegisterPropertyString('Lizenzcode', '')",
$inhalt
);
self::assertStringContainsString(
"https://license.enelix.ch/api/v1/licenses/activate",
$inhalt
);
self::assertStringContainsString("RegisterAttributeString('LizenzInstallationID'", $inhalt);
self::assertStringContainsString("RegisterAttributeString('LizenzLease'", $inhalt);
self::assertStringContainsString("RegisterAttributeString('LizenzcodeHash'", $inhalt);
self::assertStringContainsString('STATUS_LIZENZ_UNGUELTIG', $inhalt);
self::assertStringContainsString('aktualisiereLizenzfreigabe(', $inhalt);
self::assertStringNotContainsString("'publicKey' =>", $inhalt);
}
public function testManagerVerwendetDieNeuenZeitdefaults(): void
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertStringContainsString("RegisterPropertyInteger('KeepAlive', 60)", $inhalt);
self::assertStringContainsString("RegisterPropertyInteger('VerbraucherTimeout', 60)", $inhalt);
self::assertStringContainsString("RegisterPropertyInteger('MesswertMaxAlter', 60)", $inhalt);
self::assertStringContainsString("RegisterPropertyFloat('Umschaltdifferenz', 5.0)", $inhalt);
self::assertStringNotContainsString("RegisterPropertyInteger('Regelintervall'", $inhalt);
self::assertStringNotContainsString("RegisterPropertyInteger('Vorgabeintervall'", $inhalt);
$verbraucherBasis = (string) file_get_contents(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
self::assertStringContainsString(
"RegisterPropertyInteger('VorgabeTimeout', 120)",
$verbraucherBasis
);
}
public function testManagerImplementiertDenGemeinsamenEmpfangsvertrag(): void
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertStringContainsString('implements ManagerSchnittstelle', $inhalt);
self::assertStringContainsString('VerbraucherdatenEmpfangen(array $daten)', $inhalt);
self::assertStringContainsString("'ManagerdatenEmpfangen'", $inhalt);
}
}
+188
View File
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\ManagerRegler;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class ManagerReglerTest extends TestCase
{
public function testOhnePeakBegrenzungBleibtSolarladenAktiv(): void
{
self::assertSame(
ManagerRegler::BETRIEBSART_PV,
ManagerRegler::bestimmeBetriebsart(
true,
true,
25000.0,
ManagerRegler::BETRIEBSART_INAKTIV,
false,
0.0,
15000.0,
0.0
)
);
}
public function testBetriebsartBleibtZwischenDenZielwertenErhalten(): void
{
self::assertSame(
ManagerRegler::BETRIEBSART_PEAK,
ManagerRegler::bestimmeBetriebsart(
true,
true,
5000.0,
ManagerRegler::BETRIEBSART_PEAK,
true,
0.0,
12000.0,
0.0
)
);
}
public function testUmschaltdifferenzVerhindertZuKleineAenderung(): void
{
self::assertSame(
ManagerRegler::BETRIEBSART_PV,
ManagerRegler::bestimmeBetriebsart(
true,
true,
11000.0,
ManagerRegler::BETRIEBSART_PV,
true,
10000.0,
10500.0,
60.0
)
);
}
public function testWechseltOberhalbDerPeakgrenzeInDenPeakbetrieb(): void
{
self::assertSame(
ManagerRegler::BETRIEBSART_PEAK,
ManagerRegler::bestimmeBetriebsart(
true,
true,
12500.0,
ManagerRegler::BETRIEBSART_PV,
true,
0.0,
12000.0,
5.0
)
);
}
public function testWechseltUnterhalbDesSolarsollwertsInDenPvBetrieb(): void
{
self::assertSame(
ManagerRegler::BETRIEBSART_PV,
ManagerRegler::bestimmeBetriebsart(
true,
true,
-500.0,
ManagerRegler::BETRIEBSART_PEAK,
true,
0.0,
12000.0,
5.0
)
);
}
public function testVerteilungBeruecksichtigtPrioritaetEinzelwerteUndBereiche(): void
{
$ergebnis = ManagerRegler::verteile(
0.0,
4500.0,
[
$this->verbraucher(20, 5, [0, 3000], 0),
$this->verbraucher(10, 0, [0, ['Von_W' => 1000, 'Bis_W' => 2000], 4000], 0),
],
'PrioritaetPV'
);
self::assertSame(4500, $ergebnis['Verteilbudget_W']);
self::assertSame([10 => 4000, 20 => 0], $ergebnis['Sollwerte']);
self::assertSame(500, $ergebnis['Restabweichung_W']);
}
public function testNichtAenderbarerVerbraucherReserviertSeineIstleistung(): void
{
$fest = $this->verbraucher(10, 0, [0, 3000], 3000, false);
$steuerbar = $this->verbraucher(20, 1, [0, ['Von_W' => 1, 'Bis_W' => 4000]], 0);
$ergebnis = ManagerRegler::verteile(3000.0, 5000.0, [$fest, $steuerbar], 'PrioritaetPV');
self::assertSame(3000, $ergebnis['NichtSteuerbar_W']);
self::assertSame([20 => 2000], $ergebnis['Sollwerte']);
self::assertSame(0, $ergebnis['Restabweichung_W']);
}
public function testFehlendeIstleistungFaelltAufAngenommenenSollwertZurueck(): void
{
$verbraucher = $this->verbraucher(10, 0, [0, 3000], null, false);
$verbraucher['Daten']['Leistungsquelle'] = Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN;
$verbraucher['Daten']['Istleistung_W'] = null;
$verbraucher['Daten']['Zustand'][0]['Wert'] = 3000;
$ergebnis = ManagerRegler::verteile(3000.0, 0.0, [$verbraucher], 'PrioritaetPV');
self::assertSame(3000, $ergebnis['AktuelleVerbraucherleistung_W']);
self::assertSame(-3000, $ergebnis['Restabweichung_W']);
}
public function testMonatlicheGrenzeMussVollstaendigSein(): void
{
$this->expectException(InvalidArgumentException::class);
ManagerRegler::wirksameLastspitzengrenze(2, 0.0, [1 => 12000.0], 9);
}
/**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
* @return array{InstanzID: int, Daten: array<string, mixed>}
*/
private function verbraucher(
int $instanzID,
int $prioritaet,
array $leistungswerte,
?int $istleistung,
bool $aenderungMoeglich = true
): array {
$quelle = $istleistung === null
? Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN
: Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN;
return [
'InstanzID' => $instanzID,
'Daten' => [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $instanzID,
'EmpfaengerID' => 1,
'Zeitpunkt' => 1788825600,
],
'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PV,
'PrioritaetPV' => $prioritaet,
'PrioritaetPeak' => $prioritaet,
'Leistungswerte_W' => $leistungswerte,
'AenderungMoeglich' => $aenderungMoeglich,
'Verfuegbar' => true,
'Istleistung_W' => $istleistung,
'Leistungsquelle' => $quelle,
'Zustand' => [[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => $istleistung,
'Einheit' => 'W',
]],
],
];
}
}
+17
View File
@@ -14,12 +14,28 @@ final class NachrichtenvertragTest extends TestCase
{ {
Nachrichtenvertrag::pruefeManagerdaten([ Nachrichtenvertrag::pruefeManagerdaten([
'Kopf' => $this->kopf(10001, 20001), 'Kopf' => $this->kopf(10001, 20001),
'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PV,
'Sollleistung_W' => 1501, 'Sollleistung_W' => 1501,
]); ]);
Nachrichtenvertrag::pruefeManagerdaten([
'Kopf' => $this->kopf(10001, 20001),
'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PEAK,
'Sollleistung_W' => null,
]);
self::assertTrue(true); self::assertTrue(true);
} }
public function testUngueltigeBetriebsartWirdAbgelehnt(): void
{
$daten = $this->verbraucherdaten();
$daten['Betriebsart'] = 'Unbekannt';
$this->expectException(InvalidArgumentException::class);
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
}
public function testGueltigeVerbraucherdatenMitEinzelwertenUndBereichenWerdenAkzeptiert(): void public function testGueltigeVerbraucherdatenMitEinzelwertenUndBereichenWerdenAkzeptiert(): void
{ {
Nachrichtenvertrag::pruefeVerbraucherdaten($this->verbraucherdaten()); Nachrichtenvertrag::pruefeVerbraucherdaten($this->verbraucherdaten());
@@ -92,6 +108,7 @@ final class NachrichtenvertragTest extends TestCase
{ {
return [ return [
'Kopf' => $this->kopf(20001, 10001), 'Kopf' => $this->kopf(20001, 10001),
'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PEAK,
'PrioritaetPV' => 0, 'PrioritaetPV' => 0,
'PrioritaetPeak' => 12, 'PrioritaetPeak' => 12,
'Leistungswerte_W' => [ 'Leistungswerte_W' => [
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class PufferspeicherModulstrukturTest extends TestCase
{
private const MODULPFAD = __DIR__ . '/../Pufferspeicher/';
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
{
foreach (['module.json', 'form.json', 'module.php'] 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('VerbraucherPufferspeicher', $metadaten['name']);
self::assertSame('Enelix', $metadaten['vendor']);
self::assertSame(['Pufferspeicher'], $metadaten['aliases']);
self::assertSame(3, $metadaten['type']);
}
public function testPropertiesSindRegistriertUndInGewuenschterReihenfolgeImFormular(): 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
);
$properties = array_values(array_unique($treffer[1]));
$formularText = (string) file_get_contents(self::MODULPFAD . 'form.json');
foreach ($properties as $property) {
self::assertStringContainsString('"name": "' . $property . '"', $formularText, $property);
}
$formular = json_decode($formularText, true, 512, JSON_THROW_ON_ERROR);
self::assertSame(
[
'Manager und Zeitverhalten',
'Pufferspeicher',
'Heizkurve',
'Erweiterte Einstellungen',
],
array_column($formular['elements'], 'caption')
);
}
public function testModulVerwendetVertrag40UndBetriebsartabhaengigesAngebot(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul);
self::assertStringContainsString("['Betriebsart']", $modul);
self::assertStringContainsString('BETRIEBSART_PEAK', $modul);
self::assertStringContainsString("'Betriebsart' =>", $modul);
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul);
self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $modul);
}
public function testEinAusLoggingVariablenUndWaermepumpenaktionSindEnthalten(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
self::assertStringContainsString("case 'Aktiv':", $modul);
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul);
foreach ([
'Puffertemperatur',
'Aussentemperatur',
'Solltemperatur',
'Heizbedarf',
] as $ident) {
self::assertStringContainsString("RegisterVariable", $modul);
self::assertStringContainsString("'" . $ident . "'", $modul);
}
self::assertStringContainsString('HeizkurveVonWaermepumpeUebernehmen', $modul);
self::assertStringContainsString('Wert von Waermepumpe uebernehmen', $formular);
}
public function testSchaltfolgeIstBreakBeforeMake(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
$aus = strpos($modul, "RequestAction(\$stufe['KontaktID'], false)");
$ein = strpos($modul, "RequestAction(\$stufe['KontaktID'], true)");
self::assertIsInt($aus);
self::assertIsInt($ein);
self::assertLessThan($ein, $aus);
}
public function testManagerKenntDieModulIDGenauEinmal(): void
{
$manager = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertSame(1, substr_count(
$manager,
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}'
));
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\PufferspeicherRegler;
use PHPUnit\Framework\TestCase;
final class PufferspeicherReglerTest extends TestCase
{
/** @return list<array{Stufe: int, Leistung_W: int, KontaktID: int}> */
private function stufen(): array
{
return [
['Stufe' => 1, 'Leistung_W' => 1000, 'KontaktID' => 101],
['Stufe' => 2, 'Leistung_W' => 2000, 'KontaktID' => 102],
];
}
public function testHeizkurveWirdBerechnetUndBegrenzt(): void
{
self::assertSame(
50.0,
PufferspeicherRegler::solltemperatur(5.0, 35.0, 1.0, 20.0, 80.0)
);
self::assertSame(
80.0,
PufferspeicherRegler::solltemperatur(-100.0, 35.0, 1.0, 20.0, 80.0)
);
self::assertSame(
20.0,
PufferspeicherRegler::solltemperatur(100.0, 35.0, 1.0, 20.0, 80.0)
);
}
public function testUnterErrechneterEinschaltschwelleWerdenAlleStufenAngeboten(): void
{
self::assertSame(
[0, 1000, 2000],
PufferspeicherRegler::leistungsangebot(
true,
false,
true,
44.9,
45.0,
$this->stufen()
)
);
}
public function testInHystereseUndDarueberWirdNurNullAngeboten(): void
{
self::assertSame(
[0],
PufferspeicherRegler::leistungsangebot(
true,
false,
true,
45.0,
45.0,
$this->stufen()
)
);
self::assertSame(
[0],
PufferspeicherRegler::leistungsangebot(
true,
false,
true,
51.0,
45.0,
$this->stufen()
)
);
}
public function testPeakbetriebBietetUnabhaengigVonDerTemperaturNurNull(): void
{
foreach ([-20.0, 44.9, 45.0, 100.0] as $temperatur) {
self::assertSame(
[0],
PufferspeicherRegler::leistungsangebot(
true,
true,
true,
$temperatur,
45.0,
$this->stufen()
)
);
}
}
public function testAusgeschaltetWirdImmerNurNullAngeboten(): void
{
self::assertSame(
[0],
PufferspeicherRegler::leistungsangebot(
false,
false,
true,
-20.0,
45.0,
$this->stufen()
)
);
}
public function testMindesttemperaturIstOptionalStatischOderDifferenzbasiert(): void
{
self::assertSame(
45.0,
PufferspeicherRegler::einschaltschwelle(
50.0,
5.0,
PufferspeicherRegler::MINIMUM_AUS,
30.0,
10.0
)
);
self::assertSame(
42.0,
PufferspeicherRegler::einschaltschwelle(
50.0,
5.0,
PufferspeicherRegler::MINIMUM_STATISCH,
42.0,
10.0
)
);
self::assertSame(
43.0,
PufferspeicherRegler::einschaltschwelle(
50.0,
5.0,
PufferspeicherRegler::MINIMUM_DIFFERENZ,
30.0,
7.0
)
);
}
public function testWaermepumpenwertKalibriertFusspunktAmAktuellenAussenwert(): void
{
$fusspunkt = PufferspeicherRegler::fusspunktAusWaermepumpenwert(50.0, 5.0, 1.0);
self::assertSame(35.0, $fusspunkt);
self::assertSame(
50.0,
PufferspeicherRegler::solltemperatur(5.0, $fusspunkt, 1.0, 0.0, 100.0)
);
}
}
+314
View File
@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\SymconTest;
use RuntimeException;
use Throwable;
final class TestContext
{
private string $repository;
private string $suite;
private string $runId;
private int $rootId;
/** @var list<array{name: string, status: string, duration_ms: int, message: string}> */
private array $cases = [];
/** @var list<string> */
private array $cleanupErrors = [];
/** @var list<string> */
private array $profilesToDelete = [];
public function __construct(string $repository, string $suite)
{
$this->repository = $repository;
$this->suite = $suite;
$this->runId = date('Ymd-His') . '-' . strtoupper(bin2hex(random_bytes(4)));
$this->rootId = IPS_CreateCategory();
IPS_SetParent($this->rootId, 0);
IPS_SetIdent($this->rootId, 'ENELIX_TEST_' . str_replace('-', '_', $this->runId));
IPS_SetName($this->rootId, 'Enelix Test ' . $suite . ' ' . $this->runId);
IPS_SetInfo($this->rootId, 'Temporär; wird durch den Enelix-Test-Runner entfernt.');
}
public function rootId(): int
{
return $this->rootId;
}
public function runId(): string
{
return $this->runId;
}
public function runCase(string $name, callable $test): void
{
$started = microtime(true);
try {
$test($this);
$this->cases[] = $this->caseResult($name, 'passed', $started, '');
} catch (Throwable $error) {
$this->cases[] = $this->caseResult($name, 'failed', $started, $error->getMessage());
}
}
public function fail(string $message): void
{
throw new RuntimeException($message);
}
public function assertTrue(bool $condition, string $message = 'Bedingung ist nicht erfüllt.'): void
{
if (!$condition) {
$this->fail($message);
}
}
public function assertSame($expected, $actual, string $message = ''): void
{
if ($expected === $actual) {
return;
}
$prefix = $message === '' ? '' : $message . ' ';
$this->fail(
$prefix . 'Erwartet: ' . $this->export($expected) . '; erhalten: ' . $this->export($actual)
);
}
public function assertEquals(float $expected, float $actual, float $delta, string $message = ''): void
{
if (abs($expected - $actual) <= $delta) {
return;
}
$prefix = $message === '' ? '' : $message . ' ';
$this->fail($prefix . "Erwartet: {$expected} ± {$delta}; erhalten: {$actual}");
}
public function assertInstanceStatus(int $instanceId, int $expected = 102): void
{
$this->assertTrue(IPS_InstanceExists($instanceId), 'Instanz existiert nicht.');
$status = (int) IPS_GetInstance($instanceId)['InstanceStatus'];
$this->assertSame($expected, $status, 'Unerwarteter Instanzstatus.');
}
public function objectByIdent(string $ident, int $parentId): int
{
$objectId = @IPS_GetObjectIDByIdent($ident, $parentId);
if ($objectId === false) {
$this->fail("Objekt {$ident} unter {$parentId} wurde nicht angelegt.");
}
return (int) $objectId;
}
public function createCategory(string $name, ?int $parentId = null): int
{
$id = IPS_CreateCategory();
IPS_SetParent($id, $parentId ?? $this->rootId);
IPS_SetName($id, $name);
return $id;
}
public function createVariable(
int $type,
string $name,
$value,
?int $parentId = null,
string $profile = ''
): int {
$id = IPS_CreateVariable($type);
IPS_SetParent($id, $parentId ?? $this->rootId);
IPS_SetName($id, $name);
if ($profile !== '' && IPS_VariableProfileExists($profile)) {
IPS_SetVariableCustomProfile($id, $profile);
}
SetValue($id, $value);
return $id;
}
public function createActionVariable(string $name, bool $value = false): int
{
$variableId = $this->createVariable(0, $name, $value);
$scriptId = IPS_CreateScript(0);
IPS_SetParent($scriptId, $this->rootId);
IPS_SetName($scriptId, $name . ' Aktion');
IPS_SetHidden($scriptId, true);
IPS_SetScriptContent($scriptId, <<<'PHP'
<?php
SetValue((int) $_IPS['VARIABLE'], (bool) $_IPS['VALUE']);
PHP
);
IPS_SetVariableCustomAction($variableId, $scriptId);
return $variableId;
}
/** @param array<string, bool|int|float|string> $properties */
public function createInstance(string $moduleId, string $name, array $properties = []): int
{
if (!in_array($moduleId, IPS_GetModuleList(), true)) {
$this->fail("Modul {$moduleId} ist in IP-Symcon nicht verfügbar.");
}
$id = IPS_CreateInstance($moduleId);
IPS_SetParent($id, $this->rootId);
IPS_SetName($id, $name);
foreach ($properties as $property => $value) {
IPS_SetProperty($id, $property, $value);
}
IPS_ApplyChanges($id);
return $id;
}
/** @param array<string, bool|int|float|string> $properties */
public function configureInstance(int $instanceId, array $properties): void
{
foreach ($properties as $property => $value) {
IPS_SetProperty($instanceId, $property, $value);
}
IPS_ApplyChanges($instanceId);
}
public function trackVariableProfile(string $name, bool $existedBefore): void
{
if (!$existedBefore && IPS_VariableProfileExists($name)) {
$this->profilesToDelete[] = $name;
}
}
public function waitUntil(callable $condition, int $timeoutMs = 2000, int $intervalMs = 50): void
{
$deadline = microtime(true) + ($timeoutMs / 1000);
do {
if ($condition()) {
return;
}
IPS_Sleep($intervalMs);
} while (microtime(true) < $deadline);
$this->fail("Bedingung wurde nach {$timeoutMs} ms nicht erfüllt.");
}
public function cleanup(): void
{
if (IPS_ObjectExists($this->rootId)) {
try {
$this->disconnectInstances($this->rootId);
$this->deleteObjectTree($this->rootId);
} catch (Throwable $error) {
$this->cleanupErrors[] = $error->getMessage();
}
}
foreach (array_unique($this->profilesToDelete) as $profile) {
try {
if (IPS_VariableProfileExists($profile)) {
IPS_DeleteVariableProfile($profile);
}
} catch (Throwable $error) {
$this->cleanupErrors[] = "Profil {$profile}: " . $error->getMessage();
}
}
if (IPS_ObjectExists($this->rootId)) {
$this->cleanupErrors[] = 'Temporäre Testkategorie konnte nicht vollständig entfernt werden.';
}
}
/** @return array<string, mixed> */
public function report(): array
{
$failed = count(array_filter(
$this->cases,
static fn (array $case): bool => $case['status'] === 'failed'
));
$failed += count($this->cleanupErrors);
return [
'repository' => $this->repository,
'suite' => $this->suite,
'run_id' => $this->runId,
'passed' => $failed === 0,
'tests' => count($this->cases) + count($this->cleanupErrors),
'failures' => $failed,
'cases' => $this->cases,
'cleanup_errors' => $this->cleanupErrors,
];
}
/** @return array{name: string, status: string, duration_ms: int, message: string} */
private function caseResult(string $name, string $status, float $started, string $message): array
{
return [
'name' => $name,
'status' => $status,
'duration_ms' => (int) round((microtime(true) - $started) * 1000),
'message' => $message,
];
}
private function export($value): string
{
return str_replace(["\r", "\n"], ['\\r', '\\n'], var_export($value, true));
}
private function disconnectInstances(int $objectId): void
{
if (!IPS_ObjectExists($objectId)) {
return;
}
foreach (IPS_GetChildrenIDs($objectId) as $childId) {
$this->disconnectInstances((int) $childId);
}
if ((int) IPS_GetObject($objectId)['ObjectType'] !== 1) {
return;
}
$connectionId = (int) IPS_GetInstance($objectId)['ConnectionID'];
if ($connectionId > 0) {
IPS_DisconnectInstance($objectId);
}
}
private function deleteObjectTree(int $objectId): void
{
if (!IPS_ObjectExists($objectId)) {
return;
}
foreach (array_reverse(IPS_GetChildrenIDs($objectId)) as $childId) {
$this->deleteObjectTree((int) $childId);
}
$type = (int) IPS_GetObject($objectId)['ObjectType'];
switch ($type) {
case 0:
IPS_DeleteCategory($objectId);
return;
case 1:
IPS_DeleteInstance($objectId);
return;
case 2:
IPS_DeleteVariable($objectId);
return;
case 3:
IPS_DeleteScript($objectId, true);
return;
case 4:
IPS_DeleteEvent($objectId);
return;
case 5:
IPS_DeleteMedia($objectId, true);
return;
case 6:
IPS_DeleteLink($objectId);
return;
}
throw new RuntimeException("Unbekannter Objekttyp {$type} bei Objekt {$objectId}.");
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\SymconTest;
use InvalidArgumentException;
use RuntimeException;
final class TestRunner
{
private string $repository;
private string $manifestPath;
public function __construct(string $repository, string $manifestPath)
{
$this->repository = $repository;
$this->manifestPath = $manifestPath;
}
/**
* @param list<string>|string $selection
* @return array{report: array<string, mixed>, console: string, junit: string}
*/
public function run(string $mode = 'all', $selection = []): array
{
$definitions = require $this->manifestPath;
if (!is_array($definitions) || $definitions === []) {
throw new RuntimeException('Das Symcon-Testmanifest ist leer oder ungültig.');
}
$selected = $this->selectDefinitions($definitions, $mode, $selection);
$started = microtime(true);
$moduleReports = [];
foreach ($selected as $name => $definition) {
$context = new TestContext($this->repository, (string) $name);
try {
$context->runCase('IP-Symcon 8.x ist aktiv', function (TestContext $test): void {
$version = IPS_GetKernelVersion();
$test->assertTrue(
version_compare($version, '8.0', '>=') && version_compare($version, '9.0', '<'),
'Erwartet wird IP-Symcon 8.x, aktiv ist ' . $version . '.'
);
});
$context->runCase('Modultest ausführen', function (TestContext $test) use ($definition): void {
$file = $definition['file'] ?? null;
if (!is_string($file) || !is_file($file)) {
$test->fail('Testskript wurde nicht gefunden: ' . (string) $file);
}
$moduleTest = require $file;
if (!is_callable($moduleTest)) {
$test->fail('Testskript muss eine aufrufbare Funktion zurückgeben.');
}
$moduleTest($test);
});
} finally {
$context->cleanup();
}
$moduleReports[] = $context->report();
}
$tests = array_sum(array_column($moduleReports, 'tests'));
$failures = array_sum(array_column($moduleReports, 'failures'));
$report = [
'schema_version' => 1,
'repository' => $this->repository,
'mode' => $mode,
'kernel_version' => IPS_GetKernelVersion(),
'started_at' => date(DATE_ATOM, (int) $started),
'duration_ms' => (int) round((microtime(true) - $started) * 1000),
'passed' => $failures === 0,
'modules' => count($moduleReports),
'tests' => $tests,
'failures' => $failures,
'results' => $moduleReports,
];
$console = self::toConsole($report);
IPS_LogMessage($this->repository . ' Tests', $console);
return [
'report' => $report,
'console' => $console,
'junit' => self::toJunit($report),
];
}
/** @param array<string, array<string, mixed>> $definitions */
private function selectDefinitions(array $definitions, string $mode, $selection): array
{
if (!in_array($mode, ['all', 'single', 'affected'], true)) {
throw new InvalidArgumentException('Modus muss all, single oder affected sein.');
}
if ($mode === 'all') {
return $definitions;
}
$names = is_string($selection) ? [$selection] : $selection;
if (!is_array($names) || $names === []) {
throw new InvalidArgumentException("Für {$mode} muss mindestens ein Modul angegeben werden.");
}
$selected = [];
foreach (array_values(array_unique($names)) as $name) {
if (!is_string($name) || !isset($definitions[$name])) {
throw new InvalidArgumentException('Unbekanntes Testmodul: ' . (string) $name);
}
$selected[$name] = $definitions[$name];
}
return $selected;
}
/** @param array<string, mixed> $report */
public static function toConsole(array $report): string
{
$state = $report['passed'] ? 'BESTANDEN' : 'FEHLGESCHLAGEN';
$lines = [
sprintf(
'%s %s: %s (%d Module, %d Tests, %d Fehler, %d ms)',
$report['repository'],
strtoupper((string) $report['mode']),
$state,
$report['modules'],
$report['tests'],
$report['failures'],
$report['duration_ms']
),
];
foreach ($report['results'] as $module) {
$lines[] = sprintf(
'%s %s (%d Tests, %d Fehler)',
$module['passed'] ? '[OK]' : '[FEHLER]',
$module['suite'],
$module['tests'],
$module['failures']
);
foreach ($module['cases'] as $case) {
if ($case['status'] === 'failed') {
$lines[] = ' - ' . $case['name'] . ': ' . $case['message'];
}
}
foreach ($module['cleanup_errors'] as $error) {
$lines[] = ' - Cleanup: ' . $error;
}
}
return implode("\n", $lines);
}
/** @param array<string, mixed> $report */
public static function toJunit(array $report): string
{
$attributes = sprintf(
'name="%s" tests="%d" failures="%d" time="%.3f"',
self::xml((string) $report['repository']),
$report['tests'],
$report['failures'],
$report['duration_ms'] / 1000
);
$xml = ['<?xml version="1.0" encoding="UTF-8"?>', '<testsuite ' . $attributes . '>'];
foreach ($report['results'] as $module) {
foreach ($module['cases'] as $case) {
$xml[] = sprintf(
' <testcase classname="%s.%s" name="%s" time="%.3f">',
self::xml((string) $report['repository']),
self::xml((string) $module['suite']),
self::xml((string) $case['name']),
$case['duration_ms'] / 1000
);
if ($case['status'] === 'failed') {
$xml[] = ' <failure message="' . self::xml((string) $case['message']) . '"/>';
}
$xml[] = ' </testcase>';
}
foreach ($module['cleanup_errors'] as $index => $error) {
$xml[] = sprintf(
' <testcase classname="%s.%s" name="Cleanup %d">',
self::xml((string) $report['repository']),
self::xml((string) $module['suite']),
$index + 1
);
$xml[] = ' <failure message="' . self::xml((string) $error) . '"/>';
$xml[] = ' </testcase>';
}
}
$xml[] = '</testsuite>';
return implode("\n", $xml);
}
private static function xml(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
$manifest = require dirname(__DIR__) . '/manifest.php';
$paths = array_slice($argv, 1);
$shared = [
'library.json',
'composer.json',
'tests/Symcon/TestContext.php',
'tests/Symcon/TestRunner.php',
'tests/Symcon/bootstrap.php',
'tests/Symcon/manifest.php',
];
$selected = [];
foreach ($paths as $path) {
$path = ltrim(str_replace('\\', '/', $path), './');
if (in_array($path, $shared, true)) {
$selected = array_keys($manifest);
break;
}
foreach ($manifest as $name => $definition) {
foreach ($definition['paths'] as $pattern) {
if (fnmatch($pattern, $path)) {
$selected[] = $name;
break;
}
}
}
}
echo json_encode(array_values(array_unique($selected)), JSON_THROW_ON_ERROR) . PHP_EOL;
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
mode="${1:-all}"
selection="${2:-}"
url="${ENELIX_SYMCON_URL:-http://127.0.0.1:3777/api/}"
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repository_root="$(cd -- "$script_dir/../../.." && pwd)"
output_dir="${ENELIX_TEST_OUTPUT_DIR:-$repository_root/build/symcon-tests}"
case "$mode" in
all|single|affected) ;;
*)
echo "Unbekannter Testmodus: $mode" >&2
exit 2
;;
esac
encode_base64() {
printf '%s' "$1" | base64 | tr -d '\r\n'
}
mode_base64="$(encode_base64 "$mode")"
selection_base64="$(encode_base64 "$selection")"
php_code="ob_start(); require_once IPS_GetKernelDir() . 'modules/Enelix-EMS/tests/Symcon/bootstrap.php'; "
php_code+="\$mode = base64_decode('$mode_base64'); "
php_code+="\$selection = array_values(array_filter(explode(',', base64_decode('$selection_base64')), "
php_code+="static fn (string \$value): bool => \$value !== '')); "
php_code+="\$result = enelixEmsRunSymconTests(\$mode, \$selection); "
php_code+="ob_end_clean(); "
php_code+="echo implode(':', ["
php_code+="base64_encode(json_encode(\$result, JSON_THROW_ON_ERROR)), "
php_code+="base64_encode(json_encode(\$result['report'], JSON_THROW_ON_ERROR)), "
php_code+="base64_encode(\$result['junit']), "
php_code+="base64_encode(\$result['console']), "
php_code+="\$result['report']['passed'] ? '1' : '0'"
php_code+="]);"
php_code_base64="$(encode_base64 "$php_code")"
rpc_code="eval(base64_decode('$php_code_base64'));"
payload="$(printf '{"jsonrpc":"2.0","id":1,"method":"IPS_RunScriptTextWaitEx","params":["%s",{}]}' "$rpc_code")"
mkdir -p "$output_dir"
response_file="$output_dir/rpc-response.json"
result_file="$output_dir/result.json"
curl --silent --show-error --fail \
--header 'Content-Type: application/json' \
--data "$payload" \
"$url" > "$response_file"
if grep -q '"error"' "$response_file"; then
sed -n '1,120p' "$response_file" >&2
exit 1
fi
wire="$(sed -n 's/^.*"result":"\([^"]*\)".*$/\1/p' "$response_file" | sed 's#\\/#/#g')"
if [[ -z "$wire" ]]; then
echo 'IP-Symcon lieferte keine auswertbaren Testdaten:' >&2
sed -n '1,120p' "$response_file" >&2
exit 1
fi
IFS=: read -r result_base64 report_base64 junit_base64 console_base64 passed extra <<< "$wire"
if [[ -n "${extra:-}" || -z "$result_base64" || -z "$report_base64" || -z "$junit_base64" ]]; then
echo 'IP-Symcon lieferte ein ungültiges Testergebnis.' >&2
exit 1
fi
printf '%s' "$result_base64" | base64 -d > "$result_file"
printf '%s' "$report_base64" | base64 -d > "$output_dir/report.json"
printf '%s' "$junit_base64" | base64 -d > "$output_dir/junit.xml"
printf '%s' "$console_base64" | base64 -d
[[ "$passed" == 1 ]]
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use Belevo\EnelixEMS\SymconTest\TestRunner;
require_once __DIR__ . '/TestContext.php';
require_once __DIR__ . '/TestRunner.php';
/**
* @param list<string>|string $selection
* @return array{report: array<string, mixed>, console: string, junit: string}
*/
function enelixEmsRunSymconTests(string $mode = 'all', $selection = []): array
{
$runner = new TestRunner('Enelix-EMS', __DIR__ . '/manifest.php');
return $runner->run($mode, $selection);
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
return [
'Batterie' => [
'file' => __DIR__ . '/modules/Batterie.php',
'paths' => [
'Batterie/*',
'libs/BatterieRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'EaseeGateway' => [
'file' => __DIR__ . '/modules/EaseeGateway.php',
'paths' => [
'EaseeGateway/*',
'libs/EaseeGatewayProtokoll.php',
],
],
'LadestationGateway' => [
'file' => __DIR__ . '/modules/LadestationGateway.php',
'paths' => [
'LadestationGateway/*',
'libs/EaseeGatewayProtokoll.php',
'libs/EaseeLadestatus.php',
'libs/LadestationRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'LadestationStandAlone' => [
'file' => __DIR__ . '/modules/LadestationStandAlone.php',
'paths' => [
'LadestationStandAlone/*',
'libs/LadestationAdapter.php',
'libs/LadestationRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'Manager' => [
'file' => __DIR__ . '/modules/Manager.php',
'paths' => [
'Manager/*',
'libs/Lizenzpruefung.php',
'libs/ManagerRegler.php',
'libs/ManagerSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
'libs/VerbraucherSchnittstelle.php',
],
],
'Pufferspeicher' => [
'file' => __DIR__ . '/modules/Pufferspeicher.php',
'paths' => [
'Pufferspeicher/*',
'libs/PufferspeicherRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'VerbraucherEinStufig' => [
'file' => __DIR__ . '/modules/VerbraucherEinStufig.php',
'paths' => [
'VerbraucherEinStufig/*',
'libs/EinStufigRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'Waermepumpe' => [
'file' => __DIR__ . '/modules/Waermepumpe.php',
'paths' => [
'Waermepumpe/*',
'libs/WaermepumpenRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'Warmwassererwaermer' => [
'file' => __DIR__ . '/modules/Warmwassererwaermer.php',
'paths' => [
'Warmwassererwaermer/*',
'libs/WarmwasserRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
];

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