Compare commits
84
Commits
main
...
995b704e36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
995b704e36 | ||
|
|
5ff0d405d4 | ||
|
|
ccecc437e9 | ||
|
|
156591a91e | ||
|
|
3cabf597ac | ||
|
|
d1fc62d43c | ||
|
|
aefe46352f | ||
|
|
6f76e6cd86 | ||
|
|
18c34b32e7 | ||
|
|
b111a5c501 | ||
|
|
ef708564d8 | ||
|
|
cf2ce3aff4 | ||
|
|
708a8ecea2 | ||
|
|
40f41a2fb8 | ||
|
|
2d24ce0900 | ||
|
|
f47c908798 | ||
|
|
72d96aeb81 | ||
|
|
dcf51bd8be | ||
|
|
efa8fe698c | ||
|
|
f250dea59a | ||
|
|
1e6481330a | ||
|
|
dbd728dd03 | ||
|
|
a67018e68f | ||
|
|
0f1e0c9fbb | ||
|
|
693beb2263 | ||
|
|
b1cb289922 | ||
|
|
98ee569178 | ||
|
|
2392a9c209 | ||
|
|
7845c66fd8 | ||
|
|
d7308663da | ||
|
|
d03a7fd8fb | ||
|
|
d804ba4bc1 | ||
|
|
e7c546ff2d | ||
|
|
e6516f5d7a | ||
|
|
8ae1f1ea8a | ||
|
|
3c71626b5f | ||
|
|
7b6d1b2f07 | ||
|
|
cf1ae5e348 | ||
|
|
3edf724df7 | ||
|
|
7cf67d4392 | ||
|
|
abd8c7edeb | ||
|
|
312b76aee5 | ||
|
|
4a01e9df0f | ||
|
|
66966875e7 | ||
|
|
51ce9b339e | ||
|
|
37822f0ed0 | ||
|
|
cda579ab86 | ||
|
|
d874ec0a3b | ||
|
|
5c5ef73fb8 | ||
|
|
49fe18c2eb | ||
|
|
052685401f | ||
|
|
ecbd0a3baf | ||
|
|
b404de4095 | ||
|
|
9598a287f8 | ||
|
|
6c2d6ddcde | ||
|
|
d4828f1ee5 | ||
|
|
29cfd8ceaa | ||
|
|
92e8ec59df | ||
|
|
355e7163fb | ||
|
|
1b4a788f22 | ||
|
|
9e6a675b25 | ||
|
|
4514ccb557 | ||
|
|
9869d99641 | ||
|
|
98fb926fbd | ||
|
|
7a3c436039 | ||
|
|
0a3ece3a81 | ||
|
|
21a6466ca5 | ||
|
|
37304691eb | ||
|
|
8af7776f33 | ||
|
|
0c9d4d920a | ||
|
|
dcd310498d | ||
|
|
c1fdd37b2b | ||
|
|
8439213234 | ||
|
|
efda77ccad | ||
|
|
69da93270e | ||
|
|
b2779148ee | ||
|
|
f10d454eb7 | ||
|
|
9426b0f041 | ||
|
|
1541482bf6 | ||
|
|
9acbff610d | ||
|
|
9aab4abdb3 | ||
|
|
0f9860356d | ||
|
|
bd47faa593 | ||
|
|
578285b0d6 |
@@ -21,6 +21,12 @@ jobs:
|
||||
- name: Abhaengigkeiten installieren
|
||||
run: composer install --no-interaction --no-progress --prefer-dist
|
||||
|
||||
- name: Pruefungen ausfuehren
|
||||
- name: Composer-Metadaten pruefen
|
||||
run: composer validate --strict
|
||||
|
||||
- name: Abhaengigkeiten auf Sicherheitsmeldungen pruefen
|
||||
run: composer audit --no-interaction
|
||||
|
||||
- name: Unit-Tests und Syntaxpruefung ausfuehren
|
||||
run: composer check
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/vendor/
|
||||
/.phpunit.cache/
|
||||
/.phpunit.result.cache
|
||||
/build/
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# Batterie
|
||||
|
||||
IP-Symcon-Modul fuer Batteriespeicher im Enelix EMS. Das Modul uebernimmt die
|
||||
Leistungsangebote und SoC-Logik der bisherigen Enelix-Batterie, arbeitet aber
|
||||
ereignisbasiert und schreibt Stellwerte direkt in ausgewaehlte
|
||||
IP-Symcon-Registervariablen.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- positive Leistung bedeutet Laden, negative Leistung bedeutet Entladen
|
||||
- stufenlose Leistungsbereiche in ganzen Watt innerhalb der dynamischen Grenzen
|
||||
- unterschiedliche Leistungsangebote fuer PV- und Peakbetrieb
|
||||
- Reserve- und Mindestladezustand mit konfigurierbarer Hysterese
|
||||
- direkte Registeransteuerung fuer herstellerunabhaengige Batterien, GoodWe,
|
||||
SolarEdge und Sigenergy
|
||||
- Umschaltung zwischen Wechselrichter- und Enelix-Steuerung
|
||||
- ereignisbasierte Reaktion auf alle ausgewaehlten Messwerte
|
||||
- Managerkommunikation ueber den Enelix-Vertrag 4.0
|
||||
- Sollwert-Timeout, zeitbasierte Aenderungssperre und periodische Vollmeldung
|
||||
- optionale Diagnosevariablen und Debug-Logging
|
||||
|
||||
Es gibt keinen zyklischen Regel- oder Berechnungstimer. Timer werden nur fuer
|
||||
Vollmeldungen, den Ablauf einer Manager-Vorgabe und das Ende der
|
||||
Aenderungssperre verwendet.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- IP-Symcon ab Version 8.0
|
||||
- ein eingerichteter Enelix Manager
|
||||
- numerische Variablen fuer Ladezustand, Netzleistung, Batterieleistung sowie
|
||||
maximale Lade- und Entladeleistung
|
||||
- numerische Registervariablen mit Standard- oder benutzerdefinierter Aktion
|
||||
|
||||
## Vorzeichen und Einheiten
|
||||
|
||||
| Wert | Positiv | Negativ | Einheit |
|
||||
| --- | --- | --- | --- |
|
||||
| Manager-Sollleistung | Laden | Entladen | W |
|
||||
| Batterieleistung | Laden | Entladen | W |
|
||||
| Netzleistung | Netzbezug | Einspeisung | W |
|
||||
| Ladezustand | - | - | % |
|
||||
|
||||
Sigenergy-Leistungsregister werden in kW beschrieben. Alle anderen
|
||||
Leistungsregister werden in W beschrieben.
|
||||
|
||||
## Batterietypen und Register
|
||||
|
||||
| Batterietyp | Management | Modus | Leistungsregister |
|
||||
| --- | --- | --- | --- |
|
||||
| Herstellerunabhaengig | WR 0, Enelix 1 | Laden 0, Entladen 1 | getrennt Laden/Entladen in W |
|
||||
| GoodWe | WR 1, Enelix 2 | Laden 11, Entladen 12 | gemeinsam, absoluter Wert in W |
|
||||
| SolarEdge | WR 1, Enelix 4 | Laden 3, Entladen 4 | getrennt Laden/Entladen in W |
|
||||
| Sigenergy | WR 0, Enelix 1 | Laden 3, Entladen 6 | getrennt Laden/Entladen in kW |
|
||||
|
||||
Bei Wechselrichtersteuerung bietet die Batterie dem Manager nur [0] an. Das
|
||||
Modul setzt die Leistungsregister auf 0 und schreibt den passenden
|
||||
Automatikcode in das Managementregister.
|
||||
|
||||
Die Konfigurationsmaske zeigt nach Wahl des Batterietyps nur die benoetigten
|
||||
Registerauswahlen:
|
||||
|
||||
- GoodWe: Management, Modus und gemeinsames Leistungsregister
|
||||
- alle anderen Typen: Management, Modus, Ladeleistung und Entladeleistung
|
||||
|
||||
## Messwerte
|
||||
|
||||
| Property | Beschreibung |
|
||||
| --- | --- |
|
||||
| MaxLadeleistungVariableID | Aktuell zulaessige maximale Ladeleistung in W |
|
||||
| MaxEntladeleistungVariableID | Aktuell zulaessige maximale Entladeleistung in W |
|
||||
| LadezustandVariableID | Ladezustand in % |
|
||||
| NetzleistungVariableID | Netzbezug beziehungsweise Einspeisung in W |
|
||||
| IstleistungVariableID | Aktuelle Batterieleistung in W |
|
||||
| MesswertMaxAlter | Maximales Alter jedes Messwertes in Sekunden |
|
||||
|
||||
Alle Messwerte muessen als Integer- oder Floatvariable vorliegen. Fehlt ein
|
||||
Messwert, ist er veraltet oder liegt der Ladezustand ausserhalb von 0 bis
|
||||
100 %, meldet sich die Batterie als nicht verfuegbar und bietet nur [0] an.
|
||||
|
||||
## Ladezustandslogik
|
||||
|
||||
ReserveLadezustand entspricht der bisherigen Peakshaving-Reserve.
|
||||
MindestLadezustand schuetzt vor weiterer Entladung.
|
||||
LadezustandHysterese ersetzt die bisher fest im Code hinterlegte
|
||||
2-Prozent-Hysterese.
|
||||
|
||||
Die bisherigen betriebsartabhaengigen Angebote bleiben erhalten:
|
||||
|
||||
- Im PV-Betrieb steht oberhalb der Reserve der durchgaengige Bereich von der
|
||||
maximalen Entlade- bis zur maximalen Ladeleistung zur Verfuegung.
|
||||
- Unterhalb der Reserve wird die maximale Ladeleistung als fester Wert angeboten.
|
||||
- Bei vollem Speicher wird der durchgaengige Bereich von der maximalen
|
||||
Entladeleistung bis 0 W angeboten.
|
||||
- Im Peakbetrieb wird oberhalb der Reserve der aus Netz- und aktueller
|
||||
Batterieleistung berechnete Entladewert angeboten.
|
||||
- Innerhalb der Reserve-Hysterese und unterhalb des Mindestladezustands werden
|
||||
Laden und Entladen wie bisher eingeschraenkt.
|
||||
|
||||
## Manager und Zeitverhalten
|
||||
|
||||
Das Modul verwendet den gemeinsamen Enelix-Nachrichtenvertrag 4.0.
|
||||
Betriebsart ist PV oder Peak. Ein Paket mit Sollleistung_W=null
|
||||
kuendigt nur die Betriebsart an und loest die Neuberechnung des Angebots aus.
|
||||
|
||||
Aenderungssperre ist die ereignisbasierte Entsprechung des frueheren
|
||||
Idle-Counters. Nach einer echten Sollwertaenderung meldet das Modul
|
||||
AenderungMoeglich=false. Ein einmaliger Timer hebt die Sperre nach Ablauf
|
||||
der konfigurierten Sekunden wieder auf.
|
||||
|
||||
VorgabeTimeout setzt die Register auf 0, wenn der Manager eine Vorgabe
|
||||
nicht rechtzeitig erneuert. Meldeintervall erzeugt nur eine periodische
|
||||
Vollmeldung und fuehrt keinen unabhaengigen Regelzyklus aus.
|
||||
|
||||
## Sichtbare Variablen
|
||||
|
||||
Immer vorhanden:
|
||||
|
||||
- Aktiv: lokale EMS-Freigabe
|
||||
- Ladestatus: 0 unbekannt, 1 ruhend, 2 laden, 3 entladen
|
||||
|
||||
Bei aktivierten Diagnosevariablen werden zusaetzlich unter anderem
|
||||
Istleistung, Sollleistung, Ladezustand, Messwertgueltigkeit, Hysterese,
|
||||
Leistungsgrenzen, Energie, Leistungsangebot und der letzte Registerbefehl
|
||||
angezeigt.
|
||||
|
||||
LoggingEin aktiviert zusaetzliche Meldungen im IP-Symcon-Debugprotokoll.
|
||||
Die Schaltflaeche **Messwerte und Angebot aktualisieren** loest eine sofortige
|
||||
ereignisartige Aktualisierung aus. **Register auf sicheren Zustand setzen**
|
||||
verwirft die aktuelle Vorgabe und schreibt eine Leistung von 0.
|
||||
|
||||
## Inbetriebnahme
|
||||
|
||||
1. Batterietyp und Steuerung auswaehlen.
|
||||
2. Alle fuenf Messwertvariablen zuordnen.
|
||||
3. Die automatisch eingeblendeten Registervariablen zuordnen.
|
||||
4. Reserve, Mindestladezustand und Hysterese pruefen.
|
||||
5. Diagnosevariablen und Logging fuer die Erstinbetriebnahme aktivieren.
|
||||
6. Die Batterie im Manager zuordnen.
|
||||
7. Aktiv einschalten.
|
||||
8. Unter Aufsicht je einen Lade-, Entlade- und Nullsollwert senden.
|
||||
9. Registercodes, Vorzeichen, Einheiten und reale Wechselrichterreaktion
|
||||
kontrollieren.
|
||||
10. Abschliessend die Wechselrichtersteuerung waehlen und pruefen, dass alle
|
||||
Leistungsregister auf 0 sowie das Managementregister auf Automatik gehen.
|
||||
|
||||
## Fehlerbilder
|
||||
|
||||
- **Konfiguration ungueltig:** Variablentypen, Aktionszuordnung und die fuer
|
||||
den Batterietyp erforderlichen Register pruefen.
|
||||
- **Messwerte fehlen oder sind veraltet:** Variablenaktualisierung und
|
||||
MesswertMaxAlter kontrollieren.
|
||||
- **Register konnten nicht geschrieben werden:** Standard- oder
|
||||
benutzerdefinierte Aktion der Zielvariablen sowie die Geraetekommunikation
|
||||
pruefen.
|
||||
- **Sollwert wird abgewiesen:** Der Wert muss im zuletzt gemeldeten Angebot
|
||||
enthalten und die Aenderungssperre muss abgelaufen sein.
|
||||
|
||||
## Tests
|
||||
|
||||
~~~bash
|
||||
composer test -- --filter 'Batterie(Regler|Modulstruktur)Test'
|
||||
composer symcon:single -- Batterie
|
||||
composer check
|
||||
~~~
|
||||
|
||||
Der Symcon-Test prueft die Modulinstanz, direkte GoodWe-Registerbefehle,
|
||||
Laden und Entladen, Messwertereignisse sowie die Rueckgabe an die
|
||||
Wechselrichtersteuerung.
|
||||
|
||||
## Weiterfuehrende Dokumentation
|
||||
|
||||
- [Ausfuehrliche Modulbeschreibung](../docs/module/Batterie/README.md)
|
||||
- [Manager-Verbraucher-Schnittstelle](../docs/Schnittstelle.md)
|
||||
- [Migration von Enelix 1](../docs/migration/Batterie.md)
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}",
|
||||
"name": "Batterie",
|
||||
"type": 3,
|
||||
"vendor": "Enelix",
|
||||
"aliases": [
|
||||
"Batteriespeicher"
|
||||
],
|
||||
"parentRequirements": [],
|
||||
"childRequirements": [],
|
||||
"implemented": [],
|
||||
"prefix": "ENELIX",
|
||||
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Batterie"
|
||||
}
|
||||
+1176
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
|
||||
|
||||
## 0.1 Build 1 - Erste Beta - 2026-09-29
|
||||
|
||||
### Enthalten
|
||||
|
||||
- Enelix Manager mit PV- und Peak-Betrieb, Priorisierung und Diagnose.
|
||||
- Anlagenweite Einspeisebegrenzung, Netzfahrplan und Prognosetopologie.
|
||||
- Batterie, Warmwassererwaermer, Pufferspeicher und Waermepumpe.
|
||||
- Verbraucher 1-Stufig sowie Easee Gateway und beide Ladestationsvarianten.
|
||||
- Lizenzpruefung fuer Manager und einzeln freischaltbare Verbrauchermodule.
|
||||
- Idempotente Demoanlage fuer Installation und Integrationstests.
|
||||
|
||||
### Beta-Aenderungen
|
||||
|
||||
- Stufenlose Leistungsbereiche fuer Batteriespeicher.
|
||||
- Ladefreigabe neuer Ladestationsinstanzen standardmaessig aktiviert.
|
||||
- Easee-Ladestationen laden einen fehlenden Initialstatus ueber die Observations-API nach und starten bei noch unbekannter Ausgangsphase konservativ einphasig.
|
||||
- Wartende Easee-Ladestationen erhalten bei positiver Stromvorgabe einmal pro Fahrzeugverbindung einen Startbefehl; Freigabe- und Blockiergruende werden diagnostiziert.
|
||||
- Fehlende oder veraltete Verbraucher blockieren die Verteilung an aktuelle Verbraucher nicht mehr.
|
||||
- Die Stoerungsueberwachung fordert einen fehlenden Geraetezugang innerhalb ihres Retry-Zyklus automatisch neu an.
|
||||
- Vollstaendige PHP-, Struktur- und Symcon-Modultests fuer alle Module.
|
||||
|
||||
### Hinweise
|
||||
|
||||
- Diese Version ist fuer kontrollierte Anlagen- und Feldtests bestimmt.
|
||||
- Vor produktivem Einsatz sind Sicherung und anlagenspezifische Abnahme erforderlich.
|
||||
- Korrekturen werden vorwaerts ueber `develop` und anschliessend `beta` verteilt.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Easee Gateway
|
||||
|
||||
Gemeinsames IP-Symcon-Splittermodul fuer ein Easee-Nutzerkonto. Es verwaltet
|
||||
Anmeldung, Token, Ereignisverbindung und REST-Stromvorgaben fuer mehrere
|
||||
Ladestationen.
|
||||
|
||||
Konfiguration, Betrieb und Fehlerbehandlung:
|
||||
[Moduldokumentation](../docs/module/Easee-Gateway/README.md)
|
||||
|
||||
Kind-Gateway-Vertrag:
|
||||
[Schnittstellenbeschreibung](../docs/Schnittstelle-Easee-Gateway.md)
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "Active",
|
||||
"caption": "Gateway aktiv"
|
||||
},
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "Username",
|
||||
"caption": "Easee Benutzername"
|
||||
},
|
||||
{
|
||||
"type": "PasswordTextBox",
|
||||
"name": "Password",
|
||||
"caption": "Easee Passwort"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "VerifyCertificate",
|
||||
"caption": "TLS-Zertifikat pruefen"
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "Ein Gateway wird von allen Easee-Ladestationen desselben Kontos gemeinsam verwendet."
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "Button",
|
||||
"caption": "Verbindung neu aufbauen",
|
||||
"onClick": "IPS_RequestAction($id, \"Reconnect\", false);"
|
||||
}
|
||||
],
|
||||
"status": [
|
||||
{
|
||||
"code": 201,
|
||||
"icon": "error",
|
||||
"caption": "Easee-Zugangsdaten fehlen"
|
||||
},
|
||||
{
|
||||
"code": 202,
|
||||
"icon": "error",
|
||||
"caption": "Easee-Anmeldung fehlgeschlagen"
|
||||
},
|
||||
{
|
||||
"code": 203,
|
||||
"icon": "error",
|
||||
"caption": "Easee-Ereignisverbindung fehlgeschlagen"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}",
|
||||
"name": "EaseeGateway",
|
||||
"type": 2,
|
||||
"vendor": "Enelix",
|
||||
"aliases": [
|
||||
"Easee Gateway"
|
||||
],
|
||||
"parentRequirements": [
|
||||
"{79827379-F36E-4ADA-8A95-5F8D1DC92FA9}"
|
||||
],
|
||||
"childRequirements": [
|
||||
"{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}"
|
||||
],
|
||||
"implemented": [
|
||||
"{018EF6B5-AB94-40C6-AA53-46943E824ACF}",
|
||||
"{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}"
|
||||
],
|
||||
"prefix": "ENELIXEASEE",
|
||||
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/EaseeGateway"
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
<?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);
|
||||
|
||||
case 'StartCharging':
|
||||
return json_encode(
|
||||
$this->sendeLadestart($seriennummer),
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
return json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Unbekannte Gateway-Aktion.',
|
||||
], JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function stelleWebSocketParentSicher(): bool
|
||||
{
|
||||
$instanz = IPS_GetInstance($this->InstanceID);
|
||||
$parentID = (int) $instanz['ConnectionID'];
|
||||
|
||||
if ($parentID <= 0 && !$this->RequireParent(self::WEBSOCKET_MODULE_ID)) {
|
||||
$this->setzeLetztenFehler('WebSocket-Client konnte nicht erstellt werden.');
|
||||
return false;
|
||||
}
|
||||
|
||||
$instanz = IPS_GetInstance($this->InstanceID);
|
||||
$parentID = (int) $instanz['ConnectionID'];
|
||||
if ($parentID <= 0 || !IPS_InstanceExists($parentID)) {
|
||||
$this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.');
|
||||
return false;
|
||||
}
|
||||
|
||||
$parent = IPS_GetInstance($parentID);
|
||||
if ($parent['ModuleInfo']['ModuleID'] !== self::WEBSOCKET_MODULE_ID) {
|
||||
$this->setzeLetztenFehler('Ungueltige WebSocket-Schnittstelle.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function pflegeVerbindung(): void
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('Active') || $this->ReadPropertyBoolean('Testmodus')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$jetzt = time();
|
||||
$letzteAushandlung = (int) $this->GetBuffer('LastNegotiation');
|
||||
$letzterEmpfang = (int) $this->GetBuffer('LastReceive');
|
||||
$bereit = $this->GetBuffer('SignalRReady') === '1';
|
||||
|
||||
if ($bereit) {
|
||||
$this->sendeSignalRRahmen(['type' => 6]);
|
||||
if ($letzterEmpfang > 0 && ($jetzt - $letzterEmpfang) <= 90) {
|
||||
return;
|
||||
}
|
||||
$this->SetBuffer('SignalRReady', '0');
|
||||
$this->setzeVerbunden(false);
|
||||
}
|
||||
|
||||
if (($jetzt - $letzteAushandlung) >= 45) {
|
||||
$this->verbindeSignalR();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendeHandshake();
|
||||
}
|
||||
|
||||
private function verbindeSignalR(): bool
|
||||
{
|
||||
if ($this->ReadPropertyBoolean('Testmodus')) {
|
||||
$this->SetStatus(102);
|
||||
$this->setzeVerbunden(true);
|
||||
return true;
|
||||
}
|
||||
if (!$this->stelleAccessTokenSicher()) {
|
||||
$this->SetStatus(202);
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->SetBuffer('LastNegotiation', (string) time());
|
||||
$token = $this->GetBuffer('AccessToken');
|
||||
$antwort = $this->httpAnfrage(
|
||||
'POST',
|
||||
self::SIGNALR_BASE_URL . '/negotiate?negotiateVersion=1',
|
||||
'',
|
||||
$token
|
||||
);
|
||||
if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) {
|
||||
$token = $this->GetBuffer('AccessToken');
|
||||
$antwort = $this->httpAnfrage(
|
||||
'POST',
|
||||
self::SIGNALR_BASE_URL . '/negotiate?negotiateVersion=1',
|
||||
'',
|
||||
$token
|
||||
);
|
||||
}
|
||||
if (!$antwort['success']) {
|
||||
$this->SetStatus(203);
|
||||
$this->setzeLetztenFehler(
|
||||
'Easee-Ereignisverbindung fehlgeschlagen: HTTP '
|
||||
. $antwort['httpCode']
|
||||
. ($antwort['error'] !== '' ? ' / ' . $antwort['error'] : '')
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
$daten = json_decode($antwort['body'], true);
|
||||
$verbindungstoken = is_array($daten)
|
||||
? (string) ($daten['connectionToken'] ?? $daten['connectionId'] ?? '')
|
||||
: '';
|
||||
if ($verbindungstoken === '') {
|
||||
$this->SetStatus(203);
|
||||
$this->setzeLetztenFehler('Easee liefert kein Verbindungstoken.');
|
||||
return false;
|
||||
}
|
||||
|
||||
$url = 'wss://streams.easee.com/hubs/chargers?id='
|
||||
. rawurlencode($verbindungstoken)
|
||||
. '&access_token=' . rawurlencode($token);
|
||||
$this->SetBuffer('WebSocketURL', $url);
|
||||
$this->SetBuffer('SignalRReady', '0');
|
||||
$this->SetBuffer('ReceiveBuffer', '');
|
||||
$this->SetBuffer('SubscribedThisConnection', '{}');
|
||||
$this->SetBuffer('PendingInvocations', '{}');
|
||||
$this->WriteAttributeString('ObservationCache', '{}');
|
||||
$this->setzeVerbunden(false);
|
||||
|
||||
if (!$this->konfiguriereWebSocketParent($url)) {
|
||||
$this->SetStatus(203);
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->sendeHandshake();
|
||||
return true;
|
||||
}
|
||||
|
||||
private function konfiguriereWebSocketParent(string $url): bool
|
||||
{
|
||||
$parentID = (int) IPS_GetInstance($this->InstanceID)['ConnectionID'];
|
||||
if ($parentID <= 0) {
|
||||
$this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.');
|
||||
return false;
|
||||
}
|
||||
|
||||
IPS_SetProperty($parentID, 'Active', true);
|
||||
IPS_SetProperty($parentID, 'URL', $url);
|
||||
IPS_SetProperty(
|
||||
$parentID,
|
||||
'VerifyCertificate',
|
||||
$this->ReadPropertyBoolean('VerifyCertificate')
|
||||
);
|
||||
IPS_SetProperty($parentID, 'Headers', '[]');
|
||||
IPS_ApplyChanges($parentID);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function sendeHandshake(): void
|
||||
{
|
||||
$this->sendeRohdaten(
|
||||
json_encode(['protocol' => 'json', 'version' => 1], JSON_THROW_ON_ERROR)
|
||||
. self::RECORD_SEPARATOR
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $rahmen */
|
||||
private function sendeSignalRRahmen(array $rahmen): void
|
||||
{
|
||||
$this->sendeRohdaten(
|
||||
json_encode($rahmen, JSON_THROW_ON_ERROR) . self::RECORD_SEPARATOR
|
||||
);
|
||||
}
|
||||
|
||||
private function sendeRohdaten(string $nutzdaten): void
|
||||
{
|
||||
if ($this->ReadPropertyBoolean('Testmodus')) {
|
||||
return;
|
||||
}
|
||||
$this->SendDataToParent(json_encode([
|
||||
'DataID' => self::SIMPLE_TX_DATA_ID,
|
||||
'Buffer' => $nutzdaten,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function verarbeiteSignalRRahmen(string $rahmen): void
|
||||
{
|
||||
if ($rahmen === '{}') {
|
||||
$this->SetBuffer('SignalRReady', '1');
|
||||
$this->SetStatus(102);
|
||||
$this->setzeLetztenFehler('');
|
||||
$this->setzeVerbunden(true);
|
||||
$this->sendeAlleAbonnements();
|
||||
return;
|
||||
}
|
||||
|
||||
$nachricht = json_decode($rahmen, true);
|
||||
if (!is_array($nachricht)) {
|
||||
$this->setzeLetztenFehler('Ungueltige Easee-Ereignisnachricht.');
|
||||
return;
|
||||
}
|
||||
if (isset($nachricht['error'])) {
|
||||
$this->SetStatus(203);
|
||||
$this->setzeLetztenFehler('Easee-Ereignisfehler: ' . $nachricht['error']);
|
||||
return;
|
||||
}
|
||||
|
||||
$typ = (int) ($nachricht['type'] ?? 0);
|
||||
if ($typ === 6) {
|
||||
return;
|
||||
}
|
||||
if ($typ === 7) {
|
||||
$this->SetBuffer('SignalRReady', '0');
|
||||
$this->setzeVerbunden(false);
|
||||
$this->setzeLetztenFehler('Easee-Ereignisverbindung wurde beendet.');
|
||||
return;
|
||||
}
|
||||
|
||||
$seriennummer = '';
|
||||
if ($typ === 3 && isset($nachricht['invocationId'])) {
|
||||
$offen = $this->lesePufferArray('PendingInvocations');
|
||||
$aufrufID = (string) $nachricht['invocationId'];
|
||||
$seriennummer = (string) ($offen[$aufrufID] ?? '');
|
||||
unset($offen[$aufrufID]);
|
||||
$this->SetBuffer(
|
||||
'PendingInvocations',
|
||||
json_encode($offen, JSON_THROW_ON_ERROR)
|
||||
);
|
||||
}
|
||||
|
||||
foreach (['arguments', 'result'] as $feld) {
|
||||
if (!isset($nachricht[$feld])) {
|
||||
continue;
|
||||
}
|
||||
$abonnements = array_keys($this->lesePufferArray('Subscriptions'));
|
||||
foreach (EaseeGatewayProtokoll::extrahiereBeobachtungen(
|
||||
$nachricht[$feld],
|
||||
$abonnements,
|
||||
$seriennummer
|
||||
) as $beobachtung) {
|
||||
$this->veroeffentlicheBeobachtung(
|
||||
$beobachtung['Seriennummer'],
|
||||
$beobachtung['ID'],
|
||||
$beobachtung['Wert']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function registriereAbonnement(string $seriennummer): void
|
||||
{
|
||||
$abonnements = $this->lesePufferArray('Subscriptions');
|
||||
if (!isset($abonnements[$seriennummer])) {
|
||||
$abonnements[$seriennummer] = true;
|
||||
$this->SetBuffer(
|
||||
'Subscriptions',
|
||||
json_encode($abonnements, JSON_THROW_ON_ERROR)
|
||||
);
|
||||
$this->SetValue('SubscriptionCount', count($abonnements));
|
||||
}
|
||||
|
||||
if ($this->GetBuffer('SignalRReady') === '1') {
|
||||
$this->sendeAbonnement($seriennummer);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendeAlleAbonnements(): void
|
||||
{
|
||||
foreach (array_keys($this->lesePufferArray('Subscriptions')) as $seriennummer) {
|
||||
$this->sendeAbonnement((string) $seriennummer);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendeAbonnement(string $seriennummer): void
|
||||
{
|
||||
$gesendet = $this->lesePufferArray('SubscribedThisConnection');
|
||||
if (isset($gesendet[$seriennummer])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$aufrufID = (string) (((int) $this->GetBuffer('InvocationID')) + 1);
|
||||
$this->SetBuffer('InvocationID', $aufrufID);
|
||||
$offen = $this->lesePufferArray('PendingInvocations');
|
||||
$offen[$aufrufID] = $seriennummer;
|
||||
$this->SetBuffer(
|
||||
'PendingInvocations',
|
||||
json_encode($offen, JSON_THROW_ON_ERROR)
|
||||
);
|
||||
|
||||
$this->sendeSignalRRahmen([
|
||||
'type' => 1,
|
||||
'invocationId' => $aufrufID,
|
||||
'target' => 'SubscribeWithCurrentState',
|
||||
'arguments' => [$seriennummer, true],
|
||||
]);
|
||||
|
||||
$gesendet[$seriennummer] = true;
|
||||
$this->SetBuffer(
|
||||
'SubscribedThisConnection',
|
||||
json_encode($gesendet, JSON_THROW_ON_ERROR)
|
||||
);
|
||||
}
|
||||
|
||||
private function veroeffentlicheBeobachtung(
|
||||
string $seriennummer,
|
||||
int $id,
|
||||
$wert
|
||||
): void {
|
||||
if (
|
||||
$seriennummer === ''
|
||||
|| !in_array($id, EaseeGatewayProtokoll::BEOBACHTUNGEN, true)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->speichereBeobachtungImCache($seriennummer, $id, $wert);
|
||||
|
||||
$this->SendDataToChildren(json_encode([
|
||||
'DataID' => self::CHILD_EVENT_DATA_ID,
|
||||
'Buffer' => json_encode([
|
||||
'type' => 'Observation',
|
||||
'serialNumber' => $seriennummer,
|
||||
'id' => $id,
|
||||
'value' => $wert,
|
||||
'timestamp' => time(),
|
||||
], JSON_THROW_ON_ERROR),
|
||||
], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function speichereBeobachtungImCache(
|
||||
string $seriennummer,
|
||||
int $id,
|
||||
$wert
|
||||
): void {
|
||||
$cache = $this->leseAttributArray('ObservationCache');
|
||||
if (!isset($cache[$seriennummer]) || !is_array($cache[$seriennummer])) {
|
||||
$cache[$seriennummer] = [];
|
||||
}
|
||||
if ($id === 109 && (int) $wert === 1) {
|
||||
foreach ([110, 120, 182, 183, 184, 185] as $sessionID) {
|
||||
$cache[$seriennummer][(string) $sessionID] = 0;
|
||||
}
|
||||
}
|
||||
$cache[$seriennummer][(string) $id] = $wert;
|
||||
$cache[$seriennummer]['updated'] = time();
|
||||
$this->WriteAttributeString(
|
||||
'ObservationCache',
|
||||
json_encode($cache, JSON_THROW_ON_ERROR)
|
||||
);
|
||||
}
|
||||
|
||||
private function stelleAktuellenStatusSicher(string $seriennummer): void
|
||||
{
|
||||
$cache = $this->leseAttributArray('ObservationCache');
|
||||
$zustand = $cache[$seriennummer] ?? [];
|
||||
if (
|
||||
(is_array($zustand) && array_key_exists('109', $zustand))
|
||||
|| $this->ReadPropertyBoolean('Testmodus')
|
||||
|| $this->GetBuffer('SignalRReady') !== '1'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$antwort = $this->autorisierteApiAnfrage(
|
||||
'GET',
|
||||
'/state/' . rawurlencode($seriennummer) . '/observations?ids='
|
||||
. rawurlencode(implode(',', EaseeGatewayProtokoll::BEOBACHTUNGEN)),
|
||||
''
|
||||
);
|
||||
if (!($antwort['success'] ?? false)) {
|
||||
$this->setzeLetztenFehler(
|
||||
'Easee-Status konnte nicht geladen werden: '
|
||||
. (string) ($antwort['error'] ?? 'Unbekannter API-Fehler.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$daten = json_decode((string) ($antwort['body'] ?? ''), true);
|
||||
if (!is_array($daten)) {
|
||||
$this->setzeLetztenFehler('Easee-Statusantwort ist ungueltig.');
|
||||
return;
|
||||
}
|
||||
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen(
|
||||
$daten,
|
||||
[$seriennummer],
|
||||
$seriennummer
|
||||
);
|
||||
$betriebsstatusGeladen = false;
|
||||
foreach ($beobachtungen as $beobachtung) {
|
||||
$this->speichereBeobachtungImCache(
|
||||
$beobachtung['Seriennummer'],
|
||||
$beobachtung['ID'],
|
||||
$beobachtung['Wert']
|
||||
);
|
||||
$betriebsstatusGeladen = $betriebsstatusGeladen
|
||||
|| $beobachtung['ID'] === 109;
|
||||
}
|
||||
if (!$betriebsstatusGeladen) {
|
||||
$this->setzeLetztenFehler(
|
||||
'Easee-Statusantwort enthaelt keinen Betriebsstatus (109).'
|
||||
);
|
||||
return;
|
||||
}
|
||||
$this->setzeLetztenFehler('');
|
||||
}
|
||||
|
||||
private function erstelleStatusantwort(string $seriennummer): string
|
||||
{
|
||||
$this->stelleAktuellenStatusSicher($seriennummer);
|
||||
$cache = $this->leseAttributArray('ObservationCache');
|
||||
return json_encode([
|
||||
'success' => true,
|
||||
'connected' => $this->GetBuffer('SignalRReady') === '1'
|
||||
|| $this->ReadPropertyBoolean('Testmodus'),
|
||||
'state' => $cache[$seriennummer] ?? [],
|
||||
], JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function setzeDynamischenLadestrom(string $seriennummer, float $ampere): array
|
||||
{
|
||||
if ($seriennummer === '') {
|
||||
return ['success' => false, 'error' => 'Seriennummer fehlt.'];
|
||||
}
|
||||
if (
|
||||
!is_finite($ampere)
|
||||
|| floor($ampere) !== $ampere
|
||||
|| $ampere < 0
|
||||
|| $ampere > 32
|
||||
|| ($ampere > 0 && $ampere < 6)
|
||||
) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Strom muss 0 A oder eine ganze Zahl zwischen 6 und 32 A sein.',
|
||||
];
|
||||
}
|
||||
if ($this->ReadPropertyBoolean('Testmodus')) {
|
||||
$this->SetBuffer('LetzterTestbefehl', json_encode([
|
||||
'serialNumber' => $seriennummer,
|
||||
'amps' => $ampere,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
return ['success' => true, 'httpCode' => 200, 'body' => '{}'];
|
||||
}
|
||||
|
||||
return $this->autorisierteApiAnfrage(
|
||||
'POST',
|
||||
'/api/chargers/' . rawurlencode($seriennummer)
|
||||
. '/commands/set_dynamic_charger_current',
|
||||
json_encode([
|
||||
'amps' => (int) round($ampere),
|
||||
'minutes' => 0,
|
||||
], JSON_THROW_ON_ERROR)
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function sendeLadestart(string $seriennummer): array
|
||||
{
|
||||
if ($seriennummer === '') {
|
||||
return ['success' => false, 'error' => 'Seriennummer fehlt.'];
|
||||
}
|
||||
if ($this->ReadPropertyBoolean('Testmodus')) {
|
||||
return ['success' => true, 'httpCode' => 200, 'body' => '{}'];
|
||||
}
|
||||
|
||||
return $this->autorisierteApiAnfrage(
|
||||
'POST',
|
||||
'/api/chargers/' . rawurlencode($seriennummer)
|
||||
. '/commands/start_charging',
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function autorisierteApiAnfrage(
|
||||
string $methode,
|
||||
string $pfad,
|
||||
string $inhalt
|
||||
): array {
|
||||
if (!$this->stelleAccessTokenSicher()) {
|
||||
return ['success' => false, 'error' => 'Kein Easee-Access-Token.'];
|
||||
}
|
||||
|
||||
$antwort = $this->httpAnfrage(
|
||||
$methode,
|
||||
self::API_BASE_URL . $pfad,
|
||||
$inhalt,
|
||||
$this->GetBuffer('AccessToken')
|
||||
);
|
||||
if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) {
|
||||
$antwort = $this->httpAnfrage(
|
||||
$methode,
|
||||
self::API_BASE_URL . $pfad,
|
||||
$inhalt,
|
||||
$this->GetBuffer('AccessToken')
|
||||
);
|
||||
}
|
||||
if (!$antwort['success']) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $antwort['error'] !== ''
|
||||
? $antwort['error']
|
||||
: 'Easee-HTTP-Fehler ' . $antwort['httpCode'] . '.',
|
||||
'httpCode' => $antwort['httpCode'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'httpCode' => $antwort['httpCode'],
|
||||
'body' => $antwort['body'],
|
||||
];
|
||||
}
|
||||
|
||||
private function erneuereZugangsdaten(bool $neuVerbinden = true): bool
|
||||
{
|
||||
$tokenpaar = null;
|
||||
$refreshToken = $this->GetBuffer('RefreshToken');
|
||||
if ($refreshToken !== '') {
|
||||
$tokenpaar = $this->fordereTokenpaarAn(
|
||||
self::API_BASE_URL . '/api/accounts/refresh_token',
|
||||
['refreshToken' => $refreshToken]
|
||||
);
|
||||
}
|
||||
if ($tokenpaar === null) {
|
||||
$tokenpaar = $this->fordereTokenpaarAn(
|
||||
self::API_BASE_URL . '/api/accounts/login',
|
||||
[
|
||||
'userName' => $this->ReadPropertyString('Username'),
|
||||
'password' => $this->ReadPropertyString('Password'),
|
||||
]
|
||||
);
|
||||
}
|
||||
if ($tokenpaar === null || !isset($tokenpaar['accessToken'])) {
|
||||
$this->setzeLetztenFehler('Easee-Anmeldung oder Token-Erneuerung fehlgeschlagen.');
|
||||
$this->SetStatus(202);
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->SetBuffer('AccessToken', (string) $tokenpaar['accessToken']);
|
||||
if (isset($tokenpaar['refreshToken'])) {
|
||||
$this->SetBuffer('RefreshToken', (string) $tokenpaar['refreshToken']);
|
||||
}
|
||||
if ($neuVerbinden) {
|
||||
return $this->verbindeSignalR();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function stelleAccessTokenSicher(): bool
|
||||
{
|
||||
return $this->GetBuffer('AccessToken') !== ''
|
||||
|| $this->erneuereZugangsdaten(false);
|
||||
}
|
||||
|
||||
/** @param array<string, string> $nutzdaten
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function fordereTokenpaarAn(string $url, array $nutzdaten): ?array
|
||||
{
|
||||
$antwort = $this->httpAnfrage(
|
||||
'POST',
|
||||
$url,
|
||||
json_encode($nutzdaten, JSON_THROW_ON_ERROR)
|
||||
);
|
||||
if (!$antwort['success']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$daten = json_decode($antwort['body'], true);
|
||||
return is_array($daten) ? $daten : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, body: string, error: string, httpCode: int}
|
||||
*/
|
||||
private function httpAnfrage(
|
||||
string $methode,
|
||||
string $url,
|
||||
string $inhalt = '',
|
||||
string $bearerToken = ''
|
||||
): array {
|
||||
$kopf = ['Accept: application/json', 'Content-Type: application/json'];
|
||||
if ($bearerToken !== '') {
|
||||
$kopf[] = 'Authorization: Bearer ' . $bearerToken;
|
||||
}
|
||||
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
return [
|
||||
'success' => false,
|
||||
'body' => '',
|
||||
'error' => 'HTTP-Anfrage konnte nicht initialisiert werden.',
|
||||
'httpCode' => 0,
|
||||
];
|
||||
}
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => $methode,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_HTTPHEADER => $kopf,
|
||||
CURLOPT_POSTFIELDS => $inhalt,
|
||||
CURLOPT_SSL_VERIFYPEER => $this->ReadPropertyBoolean('VerifyCertificate'),
|
||||
CURLOPT_SSL_VERIFYHOST => $this->ReadPropertyBoolean('VerifyCertificate') ? 2 : 0,
|
||||
]);
|
||||
|
||||
$antwort = curl_exec($curl);
|
||||
$fehler = curl_error($curl);
|
||||
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
curl_close($curl);
|
||||
|
||||
return [
|
||||
'success' => $antwort !== false
|
||||
&& $fehler === ''
|
||||
&& $httpStatus >= 200
|
||||
&& $httpStatus < 300,
|
||||
'body' => $antwort === false ? '' : (string) $antwort,
|
||||
'error' => $fehler,
|
||||
'httpCode' => $httpStatus,
|
||||
];
|
||||
}
|
||||
|
||||
private function setzeVerbunden(bool $verbunden): void
|
||||
{
|
||||
$this->SetValue('Connected', $verbunden);
|
||||
$this->SendDataToChildren(json_encode([
|
||||
'DataID' => self::CHILD_EVENT_DATA_ID,
|
||||
'Buffer' => json_encode([
|
||||
'type' => 'GatewayStatus',
|
||||
'connected' => $verbunden,
|
||||
], JSON_THROW_ON_ERROR),
|
||||
], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function setzeLetztenFehler(string $nachricht): void
|
||||
{
|
||||
$this->SetValue('LastError', $nachricht);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function leseAttributArray(string $name): array
|
||||
{
|
||||
$wert = json_decode($this->ReadAttributeString($name), true);
|
||||
return is_array($wert) ? $wert : [];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function lesePufferArray(string $name): array
|
||||
{
|
||||
$wert = json_decode($this->GetBuffer($name), true);
|
||||
return is_array($wert) ? $wert : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Ladestation Gateway
|
||||
|
||||
Eventbasiertes Enelix-EMS-Verbrauchermodul fuer eine Easee-Ladestation. Das
|
||||
Modul liest Fahrzeugstatus und aktive 1-/3-Phasenladung direkt aus Easee
|
||||
Observations und sendet Stromvorgaben ueber das verbundene Easee Gateway.
|
||||
|
||||
Konfiguration, Betriebsarten und Tests:
|
||||
[Moduldokumentation](../docs/module/Ladestation-Gateway/README.md)
|
||||
|
||||
Gateway-Vertrag:
|
||||
[Schnittstellenbeschreibung](../docs/Schnittstelle-Easee-Gateway.md)
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"type": "Select",
|
||||
"name": "Betriebsmodus",
|
||||
"caption": "Variante",
|
||||
"options": [
|
||||
{
|
||||
"caption": "Easee",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"caption": "Easee - Nur Solarladen",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "Ladestationskennung",
|
||||
"caption": "Easee Seriennummer"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "MaximalerLadestrom",
|
||||
"caption": "Maximaler Ladestrom",
|
||||
"minimum": 6,
|
||||
"maximum": 32,
|
||||
"suffix": " A"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "Ladefreigabe",
|
||||
"caption": "Ladefreigabe beim Start"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "Solarladen",
|
||||
"caption": "Solarladen beim Start"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "PrioritaetPV",
|
||||
"caption": "Prioritaet PV",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "PrioritaetPeak",
|
||||
"caption": "Prioritaet Peak",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "Meldeintervall",
|
||||
"caption": "Meldeintervall",
|
||||
"minimum": 1,
|
||||
"suffix": " s"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "VorgabeTimeout",
|
||||
"caption": "Vorgabe-Timeout",
|
||||
"minimum": 1,
|
||||
"suffix": " s"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "EinstellungenInVisu",
|
||||
"caption": "Einstellungen in Visualisierung"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "DiagnosevariablenAnzeigen",
|
||||
"caption": "Diagnosevariablen anzeigen"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "LoggingEin",
|
||||
"caption": "Diagnoseprotokoll aktivieren"
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "Easee-Zugangsdaten werden ausschliesslich im verbundenen Easee Gateway gespeichert."
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "Button",
|
||||
"caption": "Status neu anfordern",
|
||||
"onClick": "IPS_RequestAction($id, \"StatusAnfordern\", false);"
|
||||
}
|
||||
],
|
||||
"status": [
|
||||
{
|
||||
"code": 201,
|
||||
"icon": "error",
|
||||
"caption": "Konfiguration ungueltig"
|
||||
},
|
||||
{
|
||||
"code": 202,
|
||||
"icon": "error",
|
||||
"caption": "Easee Gateway nicht verbunden"
|
||||
},
|
||||
{
|
||||
"code": 203,
|
||||
"icon": "inactive",
|
||||
"caption": "Warte auf Easee-Status"
|
||||
},
|
||||
{
|
||||
"code": 204,
|
||||
"icon": "error",
|
||||
"caption": "Easee Ladestation meldet Fehler"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}",
|
||||
"name": "LadestationGateway",
|
||||
"type": 3,
|
||||
"vendor": "Enelix",
|
||||
"aliases": [
|
||||
"Ladestation Gateway"
|
||||
],
|
||||
"parentRequirements": [
|
||||
"{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}"
|
||||
],
|
||||
"childRequirements": [],
|
||||
"implemented": [
|
||||
"{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}"
|
||||
],
|
||||
"prefix": "ENELIX",
|
||||
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/LadestationGateway"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
|
||||
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
|
||||
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
|
||||
require_once __DIR__ . '/../libs/LadestationAdapter.php';
|
||||
require_once __DIR__ . '/../libs/LadestationRegler.php';
|
||||
|
||||
use Belevo\EnelixEMS\LadestationAdapter;
|
||||
use Belevo\EnelixEMS\LadestationRegler;
|
||||
use Belevo\EnelixEMS\LadestationTransport;
|
||||
use Belevo\EnelixEMS\Nachrichtenvertrag;
|
||||
use Belevo\EnelixEMS\VerbraucherBasisTrait;
|
||||
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
|
||||
|
||||
class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstelle
|
||||
{
|
||||
use VerbraucherBasisTrait;
|
||||
|
||||
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
|
||||
private const STATUS_AKTIV = 102;
|
||||
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
|
||||
private const STATUS_KOMMUNIKATIONSFEHLER = 202;
|
||||
|
||||
/** @var list<string> */
|
||||
private const DIAGNOSE_VARIABLEN = [
|
||||
'Istleistung',
|
||||
'Leistungsquelle',
|
||||
'Sollleistung',
|
||||
'SollwertGueltig',
|
||||
'Verfuegbar',
|
||||
'AenderungMoeglich',
|
||||
'Stoerung',
|
||||
'Stoertext',
|
||||
'LetzterGeraetebefehl',
|
||||
'LeistungsangebotDiagnose',
|
||||
];
|
||||
|
||||
public function Create(): void
|
||||
{
|
||||
parent::Create();
|
||||
|
||||
$this->registriereVerbraucherBasis();
|
||||
$this->RegisterPropertyInteger('Geraetetyp', 0);
|
||||
$this->RegisterPropertyString('Geraeteadresse', '');
|
||||
$this->RegisterPropertyString('GeraeteID', '');
|
||||
$this->RegisterPropertyString('Seriennummer', '');
|
||||
$this->RegisterPropertyString('Benutzername', '');
|
||||
$this->RegisterPropertyString('Passwort', '');
|
||||
$this->RegisterPropertyInteger('MaximalerLadestrom', 16);
|
||||
$this->RegisterPropertyInteger('Abfrageintervall', 5);
|
||||
$this->RegisterPropertyBoolean('Ladefreigabe', true);
|
||||
$this->RegisterPropertyBoolean('Solarladen', true);
|
||||
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
|
||||
|
||||
// Ausschliesslich fuer den automatisierten Symcon-Funktionstest.
|
||||
$this->RegisterPropertyBoolean('Testmodus', false);
|
||||
$this->RegisterPropertyString('Testantwort', '');
|
||||
|
||||
$this->RegisterVariableBoolean('FahrzeugVerbunden', 'Fahrzeug verbunden', '~Switch', 100);
|
||||
$this->RegisterVariableBoolean('FahrzeugGeladen', 'Fahrzeug geladen', '~Switch', 110);
|
||||
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 120);
|
||||
$this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 130);
|
||||
$this->RegisterVariableInteger('Phasenzahl', 'Phasenzahl', '', 140);
|
||||
|
||||
$this->RegisterAttributeBoolean('InitialwerteGesetzt', false);
|
||||
$this->RegisterAttributeBoolean('KonfigurationLadefreigabe', false);
|
||||
$this->RegisterAttributeBoolean('KonfigurationSolarladen', true);
|
||||
$this->RegisterAttributeBoolean('ZustandLadefreigabe', false);
|
||||
$this->RegisterAttributeBoolean('ZustandSolarladen', true);
|
||||
$this->RegisterAttributeBoolean('ZustandFahrzeugVerbunden', false);
|
||||
$this->RegisterAttributeBoolean('ZustandFahrzeugGeladen', false);
|
||||
$this->RegisterAttributeInteger('ZustandFahrzeugstatus', 0);
|
||||
$this->RegisterAttributeFloat('ZustandLadestrom', 0.0);
|
||||
$this->RegisterAttributeInteger('ZustandPhasenzahl', 0);
|
||||
$this->RegisterAttributeFloat('ZustandIstleistung', 0.0);
|
||||
$this->RegisterAttributeInteger('ZustandSollleistung', 0);
|
||||
$this->RegisterAttributeBoolean('ZustandSollwertGueltig', false);
|
||||
$this->RegisterAttributeBoolean('ZustandVerfuegbar', false);
|
||||
$this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false);
|
||||
$this->RegisterAttributeBoolean('ZustandStoerung', false);
|
||||
$this->RegisterAttributeString('ZustandStoertext', '');
|
||||
$this->RegisterAttributeString('Leistungsangebot', '[0]');
|
||||
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
|
||||
$this->RegisterAttributeInteger('LetzteManagerID', 0);
|
||||
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
|
||||
$this->RegisterAttributeInteger('LetzterGesetzterStrom', -1);
|
||||
|
||||
$this->RegisterTimer(
|
||||
'Statuszyklus',
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'Statuszyklus', false);"
|
||||
);
|
||||
$this->RegisterTimer(
|
||||
'Meldezyklus',
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
|
||||
);
|
||||
$this->RegisterTimer(
|
||||
'RueckmeldungVerzoegert',
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
|
||||
);
|
||||
$this->RegisterTimer(
|
||||
'VorgabeTimeout',
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', false);"
|
||||
);
|
||||
}
|
||||
|
||||
public function ApplyChanges(): void
|
||||
{
|
||||
parent::ApplyChanges();
|
||||
|
||||
$this->initialisiereLokaleEinstellungen();
|
||||
$this->aktualisiereVariablen();
|
||||
|
||||
// Ein persistiertes Ladeende nach Neueinrichtung oder Modulupdate einmal neu pruefen.
|
||||
$this->setzeZustand('FahrzeugGeladen', false);
|
||||
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
|
||||
|
||||
try {
|
||||
$this->pruefeKonfiguration();
|
||||
} catch (Throwable $fehler) {
|
||||
$this->deaktiviereTimer();
|
||||
$this->setzeZustand('Verfuegbar', false);
|
||||
$this->setzeZustand('AenderungMoeglich', false);
|
||||
$this->setzeStoerung($fehler->getMessage());
|
||||
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
|
||||
$this->protokolliere('Konfiguration', $fehler->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$this->SetTimerInterval('Statuszyklus', $this->ReadPropertyInteger('Abfrageintervall') * 1000);
|
||||
$this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000);
|
||||
$this->aktualisiere(true);
|
||||
}
|
||||
|
||||
public function RequestAction($ident, $wert): void
|
||||
{
|
||||
switch ($ident) {
|
||||
case 'Aktiv':
|
||||
$this->SetValue('Aktiv', (bool) $wert);
|
||||
if (!(bool) $wert) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
}
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
case 'Ladefreigabe':
|
||||
case 'Solarladen':
|
||||
$this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert);
|
||||
$this->setzeSichtbareVariable($ident, (bool) $wert);
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
case 'Statuszyklus':
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
case 'VorgabeTimeout':
|
||||
$this->SetTimerInterval('VorgabeTimeout', 0);
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
case 'Melden':
|
||||
if ((bool) $wert) {
|
||||
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
|
||||
}
|
||||
$this->aktualisiere(false);
|
||||
$this->sendeVerbraucherdaten();
|
||||
return;
|
||||
|
||||
case 'ManagerdatenEmpfangen':
|
||||
if (!is_string($wert)) {
|
||||
throw new InvalidArgumentException('Managerdaten muessen als JSON uebergeben werden.');
|
||||
}
|
||||
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($daten)) {
|
||||
throw new InvalidArgumentException('Managerdaten muessen ein JSON-Objekt sein.');
|
||||
}
|
||||
$this->ManagerdatenEmpfangen($daten);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $daten */
|
||||
public function ManagerdatenEmpfangen(array $daten): void
|
||||
{
|
||||
Nachrichtenvertrag::pruefeManagerdaten($daten);
|
||||
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
|
||||
throw new InvalidArgumentException('Managerdaten sind an eine andere Instanz adressiert.');
|
||||
}
|
||||
|
||||
$managerID = $daten['Kopf']['AbsenderID'];
|
||||
if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) {
|
||||
throw new InvalidArgumentException('Der Manager hat diese Ladestation nicht aktiv zugeordnet.');
|
||||
}
|
||||
|
||||
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
|
||||
$this->aktualisiere(false, false);
|
||||
$sollleistung = $daten['Sollleistung_W'];
|
||||
if ($sollleistung === null) {
|
||||
if ((bool) $this->leseZustand('SollwertGueltig')
|
||||
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->aktualisiere(false, false);
|
||||
}
|
||||
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
|
||||
return;
|
||||
}
|
||||
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) {
|
||||
throw new InvalidArgumentException(
|
||||
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
|
||||
);
|
||||
}
|
||||
|
||||
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
|
||||
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
|
||||
$this->setzeZustand('Sollleistung', $sollleistung);
|
||||
$this->setzeZustand('SollwertGueltig', true);
|
||||
$this->SetTimerInterval('VorgabeTimeout', $this->ReadPropertyInteger('VorgabeTimeout') * 1000);
|
||||
$this->aktualisiere(false, false);
|
||||
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
|
||||
}
|
||||
|
||||
private function aktualisiere(bool $meldungPlanen, bool $statusLesen = true): void
|
||||
{
|
||||
try {
|
||||
if ($statusLesen) {
|
||||
$this->leseGeraetestatus();
|
||||
}
|
||||
|
||||
$aktiv = (bool) $this->GetValue('Aktiv');
|
||||
$ladefreigabe = $this->ReadAttributeBoolean('ZustandLadefreigabe');
|
||||
$verbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden');
|
||||
$geladen = $this->ReadAttributeBoolean('ZustandFahrzeugGeladen');
|
||||
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
|
||||
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
|
||||
$solarladen = $this->ReadAttributeBoolean('ZustandSolarladen');
|
||||
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
|
||||
=== Nachrichtenvertrag::BETRIEBSART_PEAK;
|
||||
$verfuegbar = LadestationRegler::leistungsangebot(
|
||||
$aktiv,
|
||||
$ladefreigabe,
|
||||
$verbunden,
|
||||
$geladen,
|
||||
$phasenzahl,
|
||||
$maximalstrom,
|
||||
$solarladen,
|
||||
false
|
||||
) !== [0];
|
||||
$angebot = LadestationRegler::leistungsangebot(
|
||||
$aktiv,
|
||||
$ladefreigabe,
|
||||
$verbunden,
|
||||
$geladen,
|
||||
$phasenzahl,
|
||||
$maximalstrom,
|
||||
$solarladen,
|
||||
$peakbetrieb
|
||||
);
|
||||
$angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR);
|
||||
$this->WriteAttributeString('Leistungsangebot', $angebotJson);
|
||||
$this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson);
|
||||
$this->setzeZustand('Verfuegbar', $verfuegbar);
|
||||
$this->setzeZustand('AenderungMoeglich', $verfuegbar && count($angebot) > 1);
|
||||
|
||||
$sollleistung = $this->bestimmeWirksameSollleistung($angebot);
|
||||
$this->setzeLadestrom($sollleistung);
|
||||
$this->setzeStoerung('');
|
||||
$this->SetStatus(self::STATUS_AKTIV);
|
||||
} catch (Throwable $fehler) {
|
||||
$this->setzeZustand('Verfuegbar', false);
|
||||
$this->setzeZustand('AenderungMoeglich', false);
|
||||
$this->setzeStoerung($fehler->getMessage());
|
||||
$this->SetStatus(self::STATUS_KOMMUNIKATIONSFEHLER);
|
||||
$this->protokolliere('Geraetekommunikation', $fehler->getMessage());
|
||||
}
|
||||
|
||||
if ($meldungPlanen) {
|
||||
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
|
||||
}
|
||||
}
|
||||
|
||||
private function leseGeraetestatus(): void
|
||||
{
|
||||
$transport = new LadestationTransport(
|
||||
fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage)
|
||||
);
|
||||
$antwort = $transport->statusLesen(
|
||||
$this->ReadPropertyInteger('Geraetetyp'),
|
||||
$this->geraetekonfiguration()
|
||||
);
|
||||
$rohstatus = LadestationAdapter::dekodiereStatus(
|
||||
$this->ReadPropertyInteger('Geraetetyp'),
|
||||
$antwort
|
||||
);
|
||||
$status = LadestationRegler::normalisiereStatus(
|
||||
$rohstatus,
|
||||
$this->ReadAttributeBoolean('ZustandFahrzeugVerbunden'),
|
||||
$this->ReadAttributeBoolean('ZustandFahrzeugGeladen'),
|
||||
$this->ReadAttributeInteger('LetzterGesetzterStrom') >= 6
|
||||
);
|
||||
|
||||
foreach ($status as $ident => $wert) {
|
||||
$this->setzeZustand($ident, $wert);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<int> $angebot */
|
||||
private function bestimmeWirksameSollleistung(array $angebot): int
|
||||
{
|
||||
if ((bool) $this->leseZustand('SollwertGueltig')) {
|
||||
$sollleistung = (int) $this->leseZustand('Sollleistung');
|
||||
if (in_array($sollleistung, $angebot, true)) {
|
||||
return $sollleistung;
|
||||
}
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
}
|
||||
|
||||
if (!$this->ReadAttributeBoolean('ZustandSolarladen') && $angebot !== [0]) {
|
||||
return $angebot[count($angebot) - 1];
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function setzeLadestrom(int $leistung): void
|
||||
{
|
||||
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
|
||||
$strom = $leistung === 0
|
||||
? 0
|
||||
: LadestationRegler::stromFuerLeistung($leistung, $phasenzahl);
|
||||
if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$transport = new LadestationTransport(
|
||||
fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage)
|
||||
);
|
||||
$transport->ladestromSetzen(
|
||||
$this->ReadPropertyInteger('Geraetetyp'),
|
||||
$this->geraetekonfiguration(),
|
||||
$strom
|
||||
);
|
||||
$this->WriteAttributeInteger('LetzterGesetzterStrom', $strom);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $anfrage
|
||||
*/
|
||||
private function fuehreHttpAnfrageAus(array $anfrage): string
|
||||
{
|
||||
$protokoll = [
|
||||
'Methode' => $anfrage['Methode'],
|
||||
'URL' => $anfrage['URL'],
|
||||
];
|
||||
$this->setzeSichtbareVariable(
|
||||
'LetzterGeraetebefehl',
|
||||
json_encode($protokoll, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
|
||||
);
|
||||
|
||||
if ($this->ReadPropertyBoolean('Testmodus')) {
|
||||
$url = (string) $anfrage['URL'];
|
||||
if (str_ends_with($url, '/api/status')
|
||||
|| str_ends_with($url, '/mqtt?payload=')
|
||||
|| str_contains($url, '/pico/charging/')
|
||||
) {
|
||||
return $this->ReadPropertyString('Testantwort');
|
||||
}
|
||||
return '{}';
|
||||
}
|
||||
|
||||
$curl = curl_init();
|
||||
if ($curl === false) {
|
||||
throw new RuntimeException('HTTP-Anfrage konnte nicht initialisiert werden.');
|
||||
}
|
||||
|
||||
curl_setopt($curl, CURLOPT_URL, (string) $anfrage['URL']);
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5);
|
||||
curl_setopt($curl, CURLOPT_TIMEOUT, 10);
|
||||
if ($anfrage['Methode'] === 'POST') {
|
||||
curl_setopt($curl, CURLOPT_POST, true);
|
||||
}
|
||||
if (isset($anfrage['Benutzername'], $anfrage['Passwort'])) {
|
||||
curl_setopt($curl, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||
curl_setopt(
|
||||
$curl,
|
||||
CURLOPT_USERPWD,
|
||||
(string) $anfrage['Benutzername'] . ':' . (string) $anfrage['Passwort']
|
||||
);
|
||||
}
|
||||
|
||||
$antwort = curl_exec($curl);
|
||||
if ($antwort === false) {
|
||||
$fehler = curl_error($curl);
|
||||
curl_close($curl);
|
||||
throw new RuntimeException('HTTP-Anfrage fehlgeschlagen: ' . $fehler);
|
||||
}
|
||||
$status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
curl_close($curl);
|
||||
if ($status >= 400) {
|
||||
throw new RuntimeException('Geraet antwortet mit HTTP-Status ' . $status . '.');
|
||||
}
|
||||
|
||||
return (string) $antwort;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function geraetekonfiguration(): array
|
||||
{
|
||||
return [
|
||||
'Geraeteadresse' => $this->ReadPropertyString('Geraeteadresse'),
|
||||
'GeraeteID' => $this->ReadPropertyString('GeraeteID'),
|
||||
'Seriennummer' => $this->ReadPropertyString('Seriennummer'),
|
||||
'Benutzername' => $this->ReadPropertyString('Benutzername'),
|
||||
'Passwort' => $this->ReadPropertyString('Passwort'),
|
||||
];
|
||||
}
|
||||
|
||||
private function sendeVerbraucherdaten(): void
|
||||
{
|
||||
foreach ($this->zugeordneteManagerIDs() as $managerID) {
|
||||
$daten = $this->baueVerbraucherdaten($managerID);
|
||||
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
|
||||
try {
|
||||
IPS_RequestAction(
|
||||
$managerID,
|
||||
'VerbraucherdatenEmpfangen',
|
||||
json_encode($daten, JSON_THROW_ON_ERROR)
|
||||
);
|
||||
} catch (Throwable $fehler) {
|
||||
$this->protokolliere('Managerkommunikation', $fehler->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function baueVerbraucherdaten(int $managerID): array
|
||||
{
|
||||
$stoerung = (bool) $this->leseZustand('Stoerung');
|
||||
|
||||
return [
|
||||
'Kopf' => [
|
||||
'Version' => Nachrichtenvertrag::VERSION,
|
||||
'AbsenderID' => $this->InstanceID,
|
||||
'EmpfaengerID' => $managerID,
|
||||
'Zeitpunkt' => time(),
|
||||
],
|
||||
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
|
||||
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
|
||||
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
|
||||
'Leistungswerte_W' => $this->leseLeistungsangebot(),
|
||||
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
|
||||
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
|
||||
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
|
||||
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN,
|
||||
'Zustand' => [
|
||||
[
|
||||
'Kennung' => 'Sollleistung_W',
|
||||
'Art' => 'Sollwert',
|
||||
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
|
||||
? (int) $this->leseZustand('Sollleistung')
|
||||
: null,
|
||||
'Einheit' => 'W',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'FahrzeugVerbunden',
|
||||
'Art' => 'Status',
|
||||
'Wert' => (bool) $this->leseZustand('FahrzeugVerbunden'),
|
||||
'Einheit' => '',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'FahrzeugGeladen',
|
||||
'Art' => 'Status',
|
||||
'Wert' => (bool) $this->leseZustand('FahrzeugGeladen'),
|
||||
'Einheit' => '',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'Fahrzeugstatus',
|
||||
'Art' => 'Status',
|
||||
'Wert' => (int) $this->leseZustand('Fahrzeugstatus'),
|
||||
'Einheit' => '',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'Phasenzahl',
|
||||
'Art' => 'Status',
|
||||
'Wert' => (int) $this->leseZustand('Phasenzahl'),
|
||||
'Einheit' => '',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'Ladestrom_A',
|
||||
'Art' => 'Istwert',
|
||||
'Wert' => (float) $this->leseZustand('Ladestrom'),
|
||||
'Einheit' => 'A',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'Ladefreigabe',
|
||||
'Art' => 'Status',
|
||||
'Wert' => $this->ReadAttributeBoolean('ZustandLadefreigabe'),
|
||||
'Einheit' => '',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'Solarladen',
|
||||
'Art' => 'Status',
|
||||
'Wert' => $this->ReadAttributeBoolean('ZustandSolarladen'),
|
||||
'Einheit' => '',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'Ladefehler',
|
||||
'Art' => 'Stoerung',
|
||||
'Wert' => $stoerung,
|
||||
'Einheit' => '',
|
||||
'Text' => (string) $this->leseZustand('Stoertext'),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function zugeordneteManagerIDs(): array
|
||||
{
|
||||
$ergebnis = [];
|
||||
foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) {
|
||||
try {
|
||||
$automatisch = (bool) IPS_GetProperty($managerID, 'AutomatischeSuche');
|
||||
$property = $automatisch ? 'AutomatischeVerbraucherZuordnung' : 'VerbraucherZuordnung';
|
||||
$zuordnung = $this->leseManagerZuordnung($managerID, $property);
|
||||
if ($automatisch && $zuordnung === []) {
|
||||
$zuordnung = $this->leseManagerZuordnung($managerID, 'VerbraucherZuordnung');
|
||||
}
|
||||
} catch (Throwable $fehler) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($zuordnung as $eintrag) {
|
||||
if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) {
|
||||
continue;
|
||||
}
|
||||
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
|
||||
if ($instanzID === $this->InstanceID) {
|
||||
$ergebnis[] = (int) $managerID;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ergebnis));
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
private function leseManagerZuordnung(int $managerID, string $property): array
|
||||
{
|
||||
$zuordnung = json_decode(
|
||||
(string) IPS_GetProperty($managerID, $property),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
|
||||
return is_array($zuordnung) ? $zuordnung : [];
|
||||
}
|
||||
|
||||
private function pruefeKonfiguration(): void
|
||||
{
|
||||
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
|
||||
if ($this->ReadPropertyInteger($property) < 0) {
|
||||
throw new InvalidArgumentException($property . ' muss mindestens 0 sein.');
|
||||
}
|
||||
}
|
||||
foreach (['Meldeintervall', 'VorgabeTimeout', 'Abfrageintervall'] as $property) {
|
||||
if ($this->ReadPropertyInteger($property) <= 0) {
|
||||
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
|
||||
}
|
||||
}
|
||||
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
|
||||
if ($maximalstrom < 6 || $maximalstrom > 32) {
|
||||
throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.');
|
||||
}
|
||||
|
||||
$geraetetyp = $this->ReadPropertyInteger('Geraetetyp');
|
||||
$konfiguration = $this->geraetekonfiguration();
|
||||
LadestationAdapter::statusAnfrage($geraetetyp, $konfiguration);
|
||||
LadestationAdapter::steuerAnfragen($geraetetyp, $konfiguration, 0);
|
||||
}
|
||||
|
||||
private function initialisiereLokaleEinstellungen(): void
|
||||
{
|
||||
$initialisiert = $this->ReadAttributeBoolean('InitialwerteGesetzt');
|
||||
$ladefreigabe = $this->ReadPropertyBoolean('Ladefreigabe');
|
||||
$solarladen = $this->ReadPropertyBoolean('Solarladen');
|
||||
|
||||
if (!$initialisiert
|
||||
|| $ladefreigabe !== $this->ReadAttributeBoolean('KonfigurationLadefreigabe')
|
||||
) {
|
||||
$this->WriteAttributeBoolean('ZustandLadefreigabe', $ladefreigabe);
|
||||
}
|
||||
if (!$initialisiert
|
||||
|| $solarladen !== $this->ReadAttributeBoolean('KonfigurationSolarladen')
|
||||
) {
|
||||
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
|
||||
}
|
||||
|
||||
$this->WriteAttributeBoolean('KonfigurationLadefreigabe', $ladefreigabe);
|
||||
$this->WriteAttributeBoolean('KonfigurationSolarladen', $solarladen);
|
||||
$this->WriteAttributeBoolean('InitialwerteGesetzt', true);
|
||||
}
|
||||
|
||||
private function aktualisiereVariablen(): void
|
||||
{
|
||||
if ($this->ReadPropertyBoolean('EinstellungenInVisu')) {
|
||||
$this->RegisterVariableBoolean('Ladefreigabe', 'Ladefreigabe', '~Switch', 150);
|
||||
$this->RegisterVariableBoolean('Solarladen', 'Solarladen', '~Switch', 160);
|
||||
$this->EnableAction('Ladefreigabe');
|
||||
$this->EnableAction('Solarladen');
|
||||
$this->SetValue('Ladefreigabe', $this->ReadAttributeBoolean('ZustandLadefreigabe'));
|
||||
$this->SetValue('Solarladen', $this->ReadAttributeBoolean('ZustandSolarladen'));
|
||||
} else {
|
||||
$this->entferneVariable('Ladefreigabe');
|
||||
$this->entferneVariable('Solarladen');
|
||||
}
|
||||
|
||||
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
|
||||
$this->registriereVerbraucherDiagnose();
|
||||
$this->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 200);
|
||||
$this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 210);
|
||||
$this->SetValue(
|
||||
'LeistungsangebotDiagnose',
|
||||
$this->ReadAttributeString('Leistungsangebot')
|
||||
);
|
||||
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
|
||||
if (!in_array($ident, ['LetzterGeraetebefehl', 'LeistungsangebotDiagnose'], true)) {
|
||||
$this->setzeSichtbareVariable($ident, $this->leseZustand($ident));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
|
||||
$this->entferneVariable($ident);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function entferneVariable(string $ident): void
|
||||
{
|
||||
$id = @$this->GetIDForIdent($ident);
|
||||
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
|
||||
$this->UnregisterVariable($ident);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param mixed $wert */
|
||||
private function setzeZustand(string $ident, $wert): void
|
||||
{
|
||||
switch ($ident) {
|
||||
case 'FahrzeugVerbunden':
|
||||
case 'FahrzeugGeladen':
|
||||
case 'SollwertGueltig':
|
||||
case 'Verfuegbar':
|
||||
case 'AenderungMoeglich':
|
||||
case 'Stoerung':
|
||||
$this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert);
|
||||
break;
|
||||
case 'Fahrzeugstatus':
|
||||
case 'Phasenzahl':
|
||||
case 'Sollleistung':
|
||||
$this->WriteAttributeInteger('Zustand' . $ident, (int) $wert);
|
||||
break;
|
||||
case 'Ladestrom_A':
|
||||
$ident = 'Ladestrom';
|
||||
$this->WriteAttributeFloat('ZustandLadestrom', (float) $wert);
|
||||
break;
|
||||
case 'Istleistung_W':
|
||||
$ident = 'Istleistung';
|
||||
$this->WriteAttributeFloat('ZustandIstleistung', (float) $wert);
|
||||
break;
|
||||
case 'Stoertext':
|
||||
$this->WriteAttributeString('ZustandStoertext', (string) $wert);
|
||||
break;
|
||||
default:
|
||||
throw new LogicException('Unbekannter Zustand: ' . $ident);
|
||||
}
|
||||
$this->setzeSichtbareVariable($ident, $wert);
|
||||
if ($ident === 'Istleistung') {
|
||||
$this->setzeSichtbareVariable(
|
||||
'Leistungsquelle',
|
||||
Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return mixed */
|
||||
private function leseZustand(string $ident)
|
||||
{
|
||||
if ($ident === 'Ladefreigabe' || $ident === 'Solarladen') {
|
||||
return $this->ReadAttributeBoolean('Zustand' . $ident);
|
||||
}
|
||||
if (in_array($ident, [
|
||||
'FahrzeugVerbunden', 'FahrzeugGeladen', 'SollwertGueltig',
|
||||
'Verfuegbar', 'AenderungMoeglich', 'Stoerung',
|
||||
], true)) {
|
||||
return $this->ReadAttributeBoolean('Zustand' . $ident);
|
||||
}
|
||||
if (in_array($ident, ['Fahrzeugstatus', 'Phasenzahl', 'Sollleistung'], true)) {
|
||||
return $this->ReadAttributeInteger('Zustand' . $ident);
|
||||
}
|
||||
if ($ident === 'Ladestrom' || $ident === 'Istleistung') {
|
||||
return $this->ReadAttributeFloat('Zustand' . $ident);
|
||||
}
|
||||
if ($ident === 'Stoertext') {
|
||||
return $this->ReadAttributeString('ZustandStoertext');
|
||||
}
|
||||
if ($ident === 'Leistungsquelle') {
|
||||
return Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN;
|
||||
}
|
||||
throw new LogicException('Unbekannter Zustand: ' . $ident);
|
||||
}
|
||||
|
||||
/** @param mixed $wert */
|
||||
private function setzeSichtbareVariable(string $ident, $wert): void
|
||||
{
|
||||
$id = @$this->GetIDForIdent($ident);
|
||||
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
|
||||
$this->SetValue($ident, $wert);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function leseLeistungsangebot(): array
|
||||
{
|
||||
$angebot = json_decode(
|
||||
$this->ReadAttributeString('Leistungsangebot'),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
|
||||
return is_array($angebot) ? array_map('intval', $angebot) : [0];
|
||||
}
|
||||
|
||||
private function setzeStoerung(string $text): void
|
||||
{
|
||||
$this->setzeZustand('Stoerung', $text !== '');
|
||||
$this->setzeZustand('Stoertext', $text);
|
||||
}
|
||||
|
||||
private function deaktiviereTimer(): void
|
||||
{
|
||||
foreach (['Statuszyklus', 'Meldezyklus', 'RueckmeldungVerzoegert', 'VorgabeTimeout'] as $timer) {
|
||||
$this->SetTimerInterval($timer, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private function protokolliere(string $bereich, string $nachricht): void
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('LoggingEin')) {
|
||||
return;
|
||||
}
|
||||
$this->SendDebug($bereich, $nachricht, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Lizenzierung",
|
||||
"expanded": true,
|
||||
"items": [
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "Lizenzcode",
|
||||
"caption": "Lizenzcode",
|
||||
"placeholder": "ENX-XXXX-XXXX-XXXX-XXXX"
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"name": "LizenzInstallationID",
|
||||
"caption": "Installations-ID wird erzeugt"
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"name": "LizenzInformation",
|
||||
"caption": "Lizenzcode fehlt."
|
||||
},
|
||||
{
|
||||
"type": "Button",
|
||||
"name": "LizenzPruefen",
|
||||
"caption": "Lizenz pruefen und binden",
|
||||
"onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);"
|
||||
},
|
||||
{
|
||||
"type": "Button",
|
||||
"name": "LizenzVerwalten",
|
||||
"caption": "Lizenz verwalten",
|
||||
"link": true,
|
||||
"onClick": "echo 'https://license.enelix.ch';"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Grundeinstellungen",
|
||||
"expanded": true,
|
||||
"items": [
|
||||
{
|
||||
"type": "Select",
|
||||
"name": "Rolle",
|
||||
"caption": "Rolle",
|
||||
"options": [
|
||||
{"caption": "Alleine", "value": 0},
|
||||
{"caption": "Hauptmanager", "value": 1},
|
||||
{"caption": "Untermanager", "value": 2}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "SelectVariable",
|
||||
"name": "NetzleistungVariableID",
|
||||
"caption": "Netzleistung"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "SollwertSolarladen",
|
||||
"caption": "Sollwert Solarladen",
|
||||
"suffix": " W",
|
||||
"digits": 1
|
||||
},
|
||||
{
|
||||
"type": "Select",
|
||||
"name": "Lastspitzenmodus",
|
||||
"caption": "Lastspitzenmodus",
|
||||
"onChange": "IPS_RequestAction($id, 'FormLastspitzenmodus', $Lastspitzenmodus);",
|
||||
"options": [
|
||||
{"caption": "Aus", "value": 0},
|
||||
{"caption": "Konstant", "value": 1},
|
||||
{"caption": "Monatlich", "value": 2}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "Lastspitzengrenze",
|
||||
"caption": "Lastspitzengrenze",
|
||||
"suffix": " W",
|
||||
"digits": 1,
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"type": "Button",
|
||||
"name": "MonatsgrenzenUmschalten",
|
||||
"caption": "Monatsgrenzen anzeigen",
|
||||
"visible": false,
|
||||
"onClick": "IPS_RequestAction($id, 'FormMonatsgrenzenUmschalten', true);"
|
||||
},
|
||||
{
|
||||
"type": "List",
|
||||
"name": "Monatsgrenzen",
|
||||
"caption": "Monatliche Lastspitzengrenzen",
|
||||
"rowCount": 12,
|
||||
"add": false,
|
||||
"delete": false,
|
||||
"sortable": false,
|
||||
"loadValuesFromConfiguration": false,
|
||||
"visible": false,
|
||||
"columns": [
|
||||
{
|
||||
"caption": "Monat",
|
||||
"name": "Monat",
|
||||
"width": "auto",
|
||||
"save": true
|
||||
},
|
||||
{
|
||||
"caption": "Grenze",
|
||||
"name": "Grenze_W",
|
||||
"width": "180px",
|
||||
"save": true,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " W",
|
||||
"digits": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Monatsnummer",
|
||||
"name": "MonatIndex",
|
||||
"width": "0px",
|
||||
"visible": false,
|
||||
"save": true
|
||||
}
|
||||
],
|
||||
"values": []
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"name": "PeakShavingTitel",
|
||||
"caption": "Peak Shaving am Netzanschlusspunkt"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "EinspeisebegrenzungAktiv",
|
||||
"caption": "Anlagenweite Einspeisebegrenzung aktivieren"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "Einspeisegrenze",
|
||||
"caption": "Maximale Einspeisung der Gesamtanlage",
|
||||
"suffix": " W",
|
||||
"minimum": 0.0,
|
||||
"digits": 1
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "AutomatischeSuche",
|
||||
"caption": "Verbraucher automatisch suchen",
|
||||
"onChange": "IPS_RequestAction($id, 'FormAutomatischeSuche', $AutomatischeSuche);"
|
||||
},
|
||||
{
|
||||
"type": "SelectObject",
|
||||
"name": "SuchbereichID",
|
||||
"caption": "Suchbereich",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"type": "Button",
|
||||
"name": "VerbraucherAktualisieren",
|
||||
"caption": "Verbraucher aktualisieren",
|
||||
"visible": true,
|
||||
"onClick": "IPS_RequestAction($id, 'FormVerbraucherAktualisieren', json_encode(['SuchbereichID' => $SuchbereichID, 'Zuordnung' => $AutomatischeVerbraucherZuordnung]));"
|
||||
},
|
||||
{
|
||||
"type": "List",
|
||||
"name": "VerbraucherZuordnung",
|
||||
"caption": "Verbraucher manuell auswaehlen",
|
||||
"rowCount": 6,
|
||||
"add": true,
|
||||
"delete": true,
|
||||
"sortable": true,
|
||||
"visible": false,
|
||||
"columns": [
|
||||
{
|
||||
"caption": "Instanz",
|
||||
"name": "InstanzID",
|
||||
"width": "auto",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectInstance"}
|
||||
},
|
||||
{
|
||||
"caption": "Aktiv",
|
||||
"name": "Aktiv",
|
||||
"width": "100px",
|
||||
"add": false,
|
||||
"edit": {"type": "CheckBox"}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "List",
|
||||
"name": "AutomatischeVerbraucherZuordnung",
|
||||
"caption": "Gefundene Verbraucher",
|
||||
"rowCount": 8,
|
||||
"add": false,
|
||||
"delete": false,
|
||||
"loadValuesFromConfiguration": false,
|
||||
"visible": true,
|
||||
"columns": [
|
||||
{
|
||||
"caption": "Verbraucher",
|
||||
"name": "Name",
|
||||
"width": "auto",
|
||||
"save": true
|
||||
},
|
||||
{
|
||||
"caption": "Instanz-ID",
|
||||
"name": "InstanzID",
|
||||
"width": "100px",
|
||||
"save": true
|
||||
},
|
||||
{
|
||||
"caption": "Verwenden",
|
||||
"name": "Aktiv",
|
||||
"width": "100px",
|
||||
"save": true,
|
||||
"edit": {"type": "CheckBox"}
|
||||
}
|
||||
],
|
||||
"values": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Anlagentopologie",
|
||||
"expanded": true,
|
||||
"items": [
|
||||
{
|
||||
"type": "RowLayout",
|
||||
"items": [
|
||||
{
|
||||
"type": "SelectVariable",
|
||||
"name": "NetzbezugEnergieVariableID",
|
||||
"caption": "Netzbezugszaehler"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "NetzbezugEnergiefaktor",
|
||||
"caption": "Faktor",
|
||||
"digits": 6
|
||||
},
|
||||
{
|
||||
"type": "SelectVariable",
|
||||
"name": "NetzeinspeisungEnergieVariableID",
|
||||
"caption": "Einspeisezaehler"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "NetzeinspeisungEnergiefaktor",
|
||||
"caption": "Faktor",
|
||||
"digits": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "Energiezaehler werden in kWh erwartet; ueber den Faktor koennen beispielsweise Wh (0,001) umgerechnet werden."
|
||||
},
|
||||
{
|
||||
"type": "List",
|
||||
"name": "AnlagenWechselrichter",
|
||||
"caption": "Wechselrichter",
|
||||
"rowCount": 4,
|
||||
"add": true,
|
||||
"delete": true,
|
||||
"sortable": true,
|
||||
"columns": [
|
||||
{
|
||||
"caption": "ID",
|
||||
"name": "ID",
|
||||
"width": "130px",
|
||||
"add": "wr-1",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "Name",
|
||||
"name": "Name",
|
||||
"width": "auto",
|
||||
"add": "Wechselrichter",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "Typ",
|
||||
"name": "Typ",
|
||||
"width": "130px",
|
||||
"add": "pv",
|
||||
"edit": {
|
||||
"type": "Select",
|
||||
"options": [
|
||||
{"caption": "PV", "value": "pv"},
|
||||
{"caption": "Batterie", "value": "battery"},
|
||||
{"caption": "Hybrid", "value": "hybrid"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "AC Nennleistung",
|
||||
"name": "ACNennleistung_kW",
|
||||
"width": "150px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " kW",
|
||||
"minimum": 0.0,
|
||||
"digits": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Istleistung",
|
||||
"name": "IstleistungVariableID",
|
||||
"width": "140px",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectVariable"}
|
||||
},
|
||||
{
|
||||
"caption": "Messfaktor",
|
||||
"name": "Istleistungsfaktor",
|
||||
"width": "110px",
|
||||
"add": 1.0,
|
||||
"edit": {"type": "NumberSpinner", "digits": 4}
|
||||
},
|
||||
{
|
||||
"caption": "Erzeugungsenergie",
|
||||
"name": "ErzeugungsenergieVariableID",
|
||||
"width": "160px",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectVariable"}
|
||||
},
|
||||
{
|
||||
"caption": "Energiefaktor",
|
||||
"name": "Erzeugungsenergiefaktor",
|
||||
"width": "110px",
|
||||
"add": 1.0,
|
||||
"edit": {"type": "NumberSpinner", "digits": 6}
|
||||
},
|
||||
{
|
||||
"caption": "PV-Begrenzung",
|
||||
"name": "BegrenzungVariableID",
|
||||
"width": "150px",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectVariable"}
|
||||
},
|
||||
{
|
||||
"caption": "Stellwert",
|
||||
"name": "Begrenzungsart",
|
||||
"width": "120px",
|
||||
"add": "percent",
|
||||
"edit": {
|
||||
"type": "Select",
|
||||
"options": [
|
||||
{"caption": "Prozent", "value": "percent"},
|
||||
{"caption": "Watt", "value": "watt"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "List",
|
||||
"name": "AnlagenPVFlaechen",
|
||||
"caption": "PV-Flaechen",
|
||||
"rowCount": 6,
|
||||
"add": true,
|
||||
"delete": true,
|
||||
"sortable": true,
|
||||
"columns": [
|
||||
{
|
||||
"caption": "ID",
|
||||
"name": "ID",
|
||||
"width": "120px",
|
||||
"add": "pv-1",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "Name",
|
||||
"name": "Name",
|
||||
"width": "auto",
|
||||
"add": "PV-Flaeche",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "DC-Leistung",
|
||||
"name": "DCLeistung_kWp",
|
||||
"width": "130px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " kWp",
|
||||
"minimum": 0.0,
|
||||
"digits": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Neigung",
|
||||
"name": "Neigung_Grad",
|
||||
"width": "110px",
|
||||
"add": 30.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " Grad",
|
||||
"minimum": 0.0,
|
||||
"maximum": 90.0,
|
||||
"digits": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Azimut",
|
||||
"name": "Azimut_Grad",
|
||||
"width": "110px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " Grad",
|
||||
"minimum": -180.0,
|
||||
"maximum": 180.0,
|
||||
"digits": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Wechselrichter-ID",
|
||||
"name": "WechselrichterID",
|
||||
"width": "150px",
|
||||
"add": "wr-1",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "MPPT",
|
||||
"name": "MPPT",
|
||||
"width": "90px",
|
||||
"add": "",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "Module",
|
||||
"name": "Modulanzahl",
|
||||
"width": "90px",
|
||||
"add": 0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"minimum": 0,
|
||||
"digits": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Modulleistung",
|
||||
"name": "Modulleistung_Wp",
|
||||
"width": "130px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " Wp",
|
||||
"minimum": 0.0,
|
||||
"digits": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "List",
|
||||
"name": "AnlagenBatterien",
|
||||
"caption": "Batteriespeicher",
|
||||
"rowCount": 4,
|
||||
"add": true,
|
||||
"delete": true,
|
||||
"sortable": true,
|
||||
"columns": [
|
||||
{
|
||||
"caption": "ID",
|
||||
"name": "ID",
|
||||
"width": "120px",
|
||||
"add": "bat-1",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "Name",
|
||||
"name": "Name",
|
||||
"width": "auto",
|
||||
"add": "Batteriespeicher",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "Nennkapazitaet",
|
||||
"name": "Nennkapazitaet_kWh",
|
||||
"width": "150px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " kWh",
|
||||
"minimum": 0.0,
|
||||
"digits": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Nutzkapazitaet",
|
||||
"name": "Nutzkapazitaet_kWh",
|
||||
"width": "150px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " kWh",
|
||||
"minimum": 0.0,
|
||||
"digits": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Max. Laden",
|
||||
"name": "MaxLadeleistung_kW",
|
||||
"width": "130px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " kW",
|
||||
"minimum": 0.0,
|
||||
"digits": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Max. Entladen",
|
||||
"name": "MaxEntladeleistung_kW",
|
||||
"width": "140px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " kW",
|
||||
"minimum": 0.0,
|
||||
"digits": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Wechselrichter-ID",
|
||||
"name": "WechselrichterID",
|
||||
"width": "150px",
|
||||
"add": "wr-1",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "Kopplung",
|
||||
"name": "Kopplung",
|
||||
"width": "130px",
|
||||
"add": "ac",
|
||||
"edit": {
|
||||
"type": "Select",
|
||||
"options": [
|
||||
{"caption": "AC", "value": "ac"},
|
||||
{"caption": "DC", "value": "dc"},
|
||||
{"caption": "Hybrid", "value": "hybrid"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Leistung",
|
||||
"name": "LeistungVariableID",
|
||||
"width": "140px",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectVariable"}
|
||||
},
|
||||
{
|
||||
"caption": "Messfaktor",
|
||||
"name": "Leistungsfaktor",
|
||||
"width": "110px",
|
||||
"add": 1.0,
|
||||
"edit": {"type": "NumberSpinner", "digits": 4}
|
||||
},
|
||||
{
|
||||
"caption": "Ladezustand",
|
||||
"name": "SOCVariableID",
|
||||
"width": "140px",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectVariable"}
|
||||
},
|
||||
{
|
||||
"caption": "Ladeenergie",
|
||||
"name": "LadeenergieVariableID",
|
||||
"width": "140px",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectVariable"}
|
||||
},
|
||||
{
|
||||
"caption": "Entladeenergie",
|
||||
"name": "EntladeenergieVariableID",
|
||||
"width": "150px",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectVariable"}
|
||||
},
|
||||
{
|
||||
"caption": "Energiefaktor",
|
||||
"name": "Energiefaktor",
|
||||
"width": "110px",
|
||||
"add": 1.0,
|
||||
"edit": {"type": "NumberSpinner", "digits": 6}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Erweiterte Einstellungen",
|
||||
"items": [
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "KeepAlive",
|
||||
"caption": "Keep alive",
|
||||
"suffix": " s",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "VerbraucherTimeout",
|
||||
"caption": "Verbraucher-Timeout",
|
||||
"suffix": " s",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "MesswertMaxAlter",
|
||||
"caption": "Maximales Messwertalter",
|
||||
"suffix": " s",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "Umschaltdifferenz",
|
||||
"caption": "Mindestdifferenz vor Umschaltung",
|
||||
"suffix": " %",
|
||||
"minimum": 0,
|
||||
"maximum": 100,
|
||||
"digits": 1
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "Netzleistungsfaktor",
|
||||
"caption": "Netzleistungsfaktor",
|
||||
"digits": 4
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "DiagnosevariablenAnzeigen",
|
||||
"caption": "Diagnosevariablen anlegen"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "LoggingEin",
|
||||
"caption": "Laufendes Debug-Logging aktivieren"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Energieaufzeichnung",
|
||||
"expanded": true,
|
||||
"items": [
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "EnergieaufzeichnungAktiv",
|
||||
"caption": "Leistungs- und Energiewerte automatisch aufzeichnen"
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "PV und Batterie werden aus der Anlagentopologie summiert. Der Hausverbrauch wird aus PV + Netz - Batterie berechnet. Netz: positiv Bezug; Batterie: positiv Laden."
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "MesswerteAnzeigen",
|
||||
"caption": "Einzelne Leistungs- und Energiewerte unter dem Manager anzeigen"
|
||||
},
|
||||
{
|
||||
"type": "RowLayout",
|
||||
"items": [
|
||||
{
|
||||
"type": "Select",
|
||||
"name": "LeistungsaufzeichnungMinuten",
|
||||
"caption": "Leistung verdichten",
|
||||
"options": [
|
||||
{"caption": "1 Minute", "value": 1},
|
||||
{"caption": "5 Minuten", "value": 5},
|
||||
{"caption": "1 Stunde", "value": 60}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "LeistungLoeschenMonate",
|
||||
"caption": "Leistung loeschen nach",
|
||||
"minimum": 0,
|
||||
"suffix": " Monaten (0 = nie)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "RowLayout",
|
||||
"items": [
|
||||
{
|
||||
"type": "Select",
|
||||
"name": "EnergieaufzeichnungMinuten",
|
||||
"caption": "Energie verdichten",
|
||||
"options": [
|
||||
{"caption": "1 Minute", "value": 1},
|
||||
{"caption": "5 Minuten", "value": 5},
|
||||
{"caption": "1 Stunde", "value": 60}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "EnergieVerdichtenMonate",
|
||||
"caption": "Auf Tageswerte verdichten nach",
|
||||
"minimum": 0,
|
||||
"suffix": " Monaten"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "EnergieLoeschenMonate",
|
||||
"caption": "Energie loeschen nach",
|
||||
"minimum": 0,
|
||||
"suffix": " Monaten (0 = nie)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "EnergyPieAnzeigen",
|
||||
"caption": "Energy Pie unter dem Manager anzeigen",
|
||||
"onChange": "IPS_RequestAction($id, 'FormEnergyPieAnzeigen', $EnergyPieAnzeigen);"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "EnergiediagrammeAnzeigen",
|
||||
"caption": "Leistungs- und Energiediagramm unter dem Manager anzeigen",
|
||||
"onChange": "IPS_RequestAction($id, 'FormEnergiediagrammeAnzeigen', $EnergiediagrammeAnzeigen);"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "FunFactsAnzeigen",
|
||||
"caption": "Energy Facts unter dem Manager anzeigen",
|
||||
"onChange": "IPS_RequestAction($id, 'FormFunFactsAnzeigen', $FunFactsAnzeigen);"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "EnergieflussAnzeigen",
|
||||
"caption": "Energiefluss unter dem Manager anzeigen",
|
||||
"onChange": "IPS_RequestAction($id, 'FormEnergieflussAnzeigen', $EnergieflussAnzeigen);"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Prognose / Forecast",
|
||||
"items": [
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "PrognoseAktiv",
|
||||
"caption": "Prognosen aktivieren",
|
||||
"onChange": "IPS_RequestAction($id, 'FormPrognoseAktiv', $PrognoseAktiv);"
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"name": "PrognoseLizenzInformation",
|
||||
"caption": "Prognoselizenz wird geprueft."
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "Forecast verwendet dieselben Messquellen aus der Anlagentopologie und den daraus berechneten Hausverbrauch."
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "NetzfahrplanAktiv",
|
||||
"caption": "Intelligenten Netzfahrplan zur Vermeidung von PV-Abregelung verwenden"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "PrognoseSendeintervall",
|
||||
"caption": "Aktuelle Messwerte an den Prognosedienst senden alle",
|
||||
"minimum": 60,
|
||||
"maximum": 3600,
|
||||
"suffix": " Sekunden"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "SDL / VGT",
|
||||
"items": [
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "SDLAnschluss",
|
||||
"caption": "Konfiguration als JSON"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Stoerueberwachung",
|
||||
"items": [
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "StoerueberwachungAktiv",
|
||||
"caption": "Stoerungen sicher an license.enelix.ch uebertragen"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "StoerungsSendeintervall",
|
||||
"caption": "Heartbeat-Intervall",
|
||||
"minimum": 60,
|
||||
"maximum": 3600,
|
||||
"suffix": " Sekunden"
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "Die Freischaltung als jaehrlich erneuerbare Lizenz ist vorbereitet und wird spaeter aktiviert."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "Button",
|
||||
"caption": "Regelung jetzt ausfuehren",
|
||||
"onClick": "IPS_RequestAction($id, 'Regeln', true);"
|
||||
}
|
||||
],
|
||||
"status": [
|
||||
{"code": 102, "icon": "active", "caption": "Aktiv"},
|
||||
{"code": 201, "icon": "inactive", "caption": "Netzleistungsmessung fehlt oder ist veraltet"},
|
||||
{"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"},
|
||||
{"code": 203, "icon": "error", "caption": "Lizenz nicht freigegeben oder Verbraucherkontingent ueberschritten"}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
+3046
File diff suppressed because it is too large
Load Diff
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,69 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
|
||||
|
||||
## Status
|
||||
|
||||
Das Repository befindet sich im Aufbau. Aktuell ist ausschliesslich der gemeinsame Nachrichtenvertrag implementiert. Noch nicht implementierte Module werden nicht als leere Platzhalter angelegt.
|
||||
Version 0.1 Build 1 ist die erste Beta fuer kontrollierte Anlagen- und
|
||||
Feldtests. Der gemeinsame Nachrichtenvertrag, die Verbraucherbasis, der Manager
|
||||
und alle unten aufgefuehrten Module sind implementiert. Produktiver Einsatz
|
||||
setzt eine anlagenspezifische Pruefung, Sicherung und Rueckfallplanung voraus.
|
||||
|
||||
## Geplante Module
|
||||
## Module
|
||||
|
||||
- Manager
|
||||
- Batterie
|
||||
- Wassererwaermer
|
||||
- Pufferspeicher
|
||||
- Verbraucher 1-Stufig
|
||||
- Waermepumpe
|
||||
- Ladestation Stand-Alone
|
||||
- Ladestation Gateway
|
||||
- Easee Gateway
|
||||
- Manager (implementiert)
|
||||
- Batterie (implementiert)
|
||||
- Wassererwaermer (implementiert)
|
||||
- Pufferspeicher (implementiert)
|
||||
- Verbraucher 1-Stufig (implementiert)
|
||||
- Waermepumpe (implementiert)
|
||||
- Ladestation Stand-Alone (implementiert)
|
||||
- Ladestation Gateway (implementiert)
|
||||
- Easee Gateway (implementiert)
|
||||
|
||||
Die Verbraucher kommunizieren mit dem Manager ueber den gemeinsamen Vertrag in `libs/`. Der Manager waehlt die Verbraucher aus; in einem Verbraucher gibt es keine Manager-ID-Property.
|
||||
Die Properties, Variablen, Zustandsdaten und offenen Punkte stehen in der
|
||||
[Modulübersicht](docs/module/README.md). Gemeinsame Felder werden dort nicht
|
||||
abweichend neu definiert, sondern verweisen auf den zentralen Vertrag.
|
||||
|
||||
Die Verbraucher kommunizieren mit dem Manager ueber den gemeinsamen Vertrag
|
||||
in `libs/`. Der Manager waehlt die Verbraucher aus; in einem Verbraucher gibt
|
||||
es keine Manager-ID-Property.
|
||||
|
||||
## Betriebsartabhaengige Leistungsangebote
|
||||
|
||||
Der Nachrichtenvertrag `4.0` uebermittelt in beiden Richtungen verpflichtend
|
||||
die aktuelle `Betriebsart`. Zulaessige Werte sind `PV` fuer Solarbetrieb und
|
||||
`Peak` fuer die Lastspitzenbegrenzung. Dadurch kann jeder Verbraucher fuer
|
||||
beide Betriebsarten unterschiedliche PowerSteps melden.
|
||||
|
||||
Ein Betriebsartwechsel wird synchronisiert:
|
||||
|
||||
1. Der Manager bestimmt die neue Betriebsart.
|
||||
2. Er sendet sie mit `Sollleistung_W: null` an alle aktiven Verbraucher.
|
||||
3. `null` ist nur eine Ankuendigung und kein Schaltbefehl.
|
||||
4. Jeder Verbraucher berechnet sein zustandsabhaengiges Leistungsangebot neu
|
||||
und meldet es mit derselben Betriebsart zurueck.
|
||||
5. Der Manager verteilt konkrete Sollleistungen an alle bereits
|
||||
synchronisierten Verbraucher.
|
||||
6. Fehlende, veraltete oder noch nicht umgeschaltete Verbraucher bleiben von
|
||||
der Verteilung ausgeschlossen und werden als Stoerung ausgewiesen.
|
||||
|
||||
Der Manager verwendet damit nie ein PV-Angebot fuer Peak oder umgekehrt. Ein
|
||||
nicht antwortender Verbraucher blockiert die Regelung der uebrigen Anlage
|
||||
nicht; sein aktueller Einfluss ist bereits in der Netzleistungsmessung
|
||||
enthalten. Eine technische Schaltsperre oder eine Mindestzeit kann das Angebot
|
||||
weiterhin auf die aktuell gehaltene Leistung begrenzen.
|
||||
|
||||
| Verbraucher | PV-Angebot | Peak-Angebot |
|
||||
| --- | --- | --- |
|
||||
| Verbraucher 1-Stufig | Normal `[0, Nennleistung]`; bei faelliger Tagesmindestlaufzeit `[Nennleistung]` | Normal `[0]`; bei faelliger Tagesmindestlaufzeit konfigurierbar `[0, Nennleistung]` oder `[Nennleistung]` |
|
||||
| Ladestation Stand-Alone | Mit Solarladen `[0, ...Ladestufen]`, sonst maximale Ladeleistung | Mit Solarladen `[0]`, sonst `[0, ...Ladestufen]` |
|
||||
| Ladestation Gateway | Mit Solarladen `[0, ...Ladestufen]`, sonst maximale Ladeleistung | Mit Solarladen `[0]`, sonst `[0, ...Ladestufen]` |
|
||||
| Warmwassererwaermer | Unter Mindesttemperatur maximale Stufe, sonst zustandsabhaengig | Unter wirksamer Mindesttemperatur `[0, ...Leistungsstufen]`, sonst `[0]` |
|
||||
| Pufferspeicher | Unter Einschaltschwelle `[0, ...Leistungsstufen]`, sonst `[0]` | Unabhaengig vom Zustand `[0]` |
|
||||
| Batterie | SoC-abhaengiges Lade-/Entladeraster | SoC-, Reserve- und netzabhaengiges Angebot |
|
||||
|
||||
Die vollstaendigen Nachrichtenfelder und JSON-Beispiele stehen in der
|
||||
[Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md). Hintergrund,
|
||||
Alternativen und Folgen beschreibt
|
||||
[ADR 0004](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md).
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
@@ -32,7 +80,18 @@ composer install
|
||||
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 |
|
||||
| --- | --- |
|
||||
@@ -40,5 +99,25 @@ Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingeche
|
||||
| `beta` | Beta |
|
||||
| `develop` | Testing |
|
||||
|
||||
Die ausfuehrliche Schnittstellenbeschreibung steht unter [docs/Schnittstelle.md](docs/Schnittstelle.md).
|
||||
## Dokumentation
|
||||
|
||||
- [Changelog](CHANGELOG.md)
|
||||
- [Zentrale Liste offener Teamentscheidungen](docs/Offene-Punkte.md)
|
||||
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
|
||||
- [Batterieschnittstelle](docs/Schnittstelle-Batterie.md)
|
||||
- [ADR 0004: Betriebsartabhaengige Leistungsangebote](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md)
|
||||
- [ADR 0005: Anlagentopologie fuer Prognosen](docs/adr/0005-anlagentopologie-fuer-prognosen.md)
|
||||
- [Manager-Modul inklusive Lizenzierung](docs/module/Manager/README.md)
|
||||
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md)
|
||||
- [Pufferspeicher-Modul](docs/module/Pufferspeicher/README.md)
|
||||
- [Batteriemodul](docs/module/Batterie/README.md)
|
||||
- [Verbraucher-1-Stufig-Modul](docs/module/Verbraucher-1-Stufig/README.md)
|
||||
- [Ladestation-Stand-Alone-Modul](docs/module/Ladestation-Stand-Alone/README.md)
|
||||
- [Ladestation-Gateway-Modul](docs/module/Ladestation-Gateway/README.md)
|
||||
- [Easee-Gateway-Modul](docs/module/Easee-Gateway/README.md)
|
||||
- [Easee-Gateway-Schnittstelle](docs/Schnittstelle-Easee-Gateway.md)
|
||||
- [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md)
|
||||
- [Migration Verbraucher 1-Stufig](docs/migration/Verbraucher-1-Stufig.md)
|
||||
- [Migration Batterie](docs/migration/Batterie.md)
|
||||
- [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md)
|
||||
- [Modulübersicht](docs/module/README.md)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -2,6 +2,7 @@
|
||||
"name": "belevo/enelix-ems",
|
||||
"description": "Energiemanagement und steuerbare Verbraucher fuer IP-Symcon",
|
||||
"type": "library",
|
||||
"license": "proprietary",
|
||||
"require": {
|
||||
"php": ">=8.0"
|
||||
},
|
||||
@@ -20,10 +21,19 @@
|
||||
},
|
||||
"scripts": {
|
||||
"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: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": [
|
||||
"@lint",
|
||||
"@test"
|
||||
],
|
||||
"check:verbraucher-einstufig": [
|
||||
"@lint:verbraucher-einstufig",
|
||||
"@test:verbraucher-einstufig"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Obere Anschlüsse des Managers
|
||||
|
||||
> Status: Lizenzierung, Prognose und Stoerueberwachung sind an
|
||||
> `license.enelix.ch` angebunden. SDL/VGT bleibt als optionaler Anschluss
|
||||
> vorbereitet.
|
||||
|
||||
Die Anschluesse werden ausschliesslich im Manager konfiguriert. Sie erzeugen
|
||||
keine Abhaengigkeit zwischen Enelix EMS und Enelix Utils.
|
||||
|
||||
| Anschluss | Aufgabe | Gegenstelle |
|
||||
| --- | --- | --- |
|
||||
| SDL/VGT | Zeitlich gueltige Leistungsauftraege und Rueckmeldungen | Optional das unabhaengige Utils-Modul VGT-Schnittstelle |
|
||||
| Prognose / Forecast | Topologie, Telemetrie und Netzfahrplan | `license.enelix.ch/api/v1/installations/{id}/prognosis/*` |
|
||||
| Lizenzierung | Pruefung freigeschalteter Manager-Funktionen | `POST https://license.enelix.ch/api/v1/licenses/activate` |
|
||||
| Stoerueberwachung | Vollsnapshot aktiver Manager- und Geraetestoerungen | `PUT https://license.enelix.ch/api/v1/installations/{id}/faults` |
|
||||
|
||||
## Stoerungsdatenfluss
|
||||
|
||||
1. Jedes Geraetemodul meldet seinen Zustand ueber den bestehenden
|
||||
Verbraucher-Nachrichtenvertrag an den Manager.
|
||||
2. Der Manager sammelt aktive Eintraege mit `Art = Stoerung`, fuegt eigene
|
||||
Regelungsstoerungen hinzu und normalisiert sie.
|
||||
3. Der Manager speichert lokal einen deterministisch sortierten Vollsnapshot.
|
||||
4. Bei Zustandsaenderung oder spaetestens im konfigurierten Heartbeat-Intervall
|
||||
sendet er den Snapshot mit dem vorhandenen Geraete-Bearer-Token.
|
||||
5. Ein leerer Snapshot loest zuvor aktive Stoerungen im Portal auf.
|
||||
|
||||
Jede Meldung besteht aus `sourceType`, `sourceId`, `sourceName`, `code`,
|
||||
`severity` und `message`. Die stabile Kombination aus `sourceId` und
|
||||
`code` identifiziert ein Ereignis ueber mehrere Snapshots hinweg.
|
||||
|
||||
Fehler der externen Uebertragung blockieren die lokale EMS-Regelung nicht. Der
|
||||
Manager wiederholt mit exponentiellem Abstand zwischen 30 und 900 Sekunden.
|
||||
Nach HTTP 401 oder 403 verwirft er den Geraetezugang und fordert ihn bei der
|
||||
naechsten Lizenzaktivierung neu an.
|
||||
|
||||
## Lizenzvorbereitung
|
||||
|
||||
Die Stoerueberwachung ist technisch als separat schaltbare Manager-Funktion
|
||||
gekapselt. Das Portal kann spaeter eine Berechtigung mit dem Katalogschluessel
|
||||
`fault_monitoring`, einer Laufzeit von einem Jahr und einem erneuerten
|
||||
`validUntil` ausliefern. In der ersten Ausbaustufe wird diese zusaetzliche
|
||||
Jahresberechtigung noch nicht erzwungen; Voraussetzung bleibt eine gueltige
|
||||
Managerlizenz samt Geraetezugang.
|
||||
|
||||
## Gemeinsame Grundsaetze
|
||||
|
||||
- Anbieterformate werden am Manageranschluss uebersetzt und gelangen nicht in
|
||||
die Verbraucher-Schnittstelle.
|
||||
- Jeder Anschluss meldet `NichtVerwendet`, `Wartet`, `Verbunden` oder
|
||||
`Fehler`.
|
||||
- Fehlende optionale Anschluesse duerfen die lokale EMS-Grundfunktion nicht
|
||||
blockieren.
|
||||
- Zugangstoken werden weder als Property noch in Diagnosevariablen oder Logs
|
||||
ausgegeben.
|
||||
## Noch festzulegen
|
||||
|
||||
- Aufbau, Gueltigkeitszeitraum und Rueckmeldung eines SDL-Auftrags
|
||||
- Produkt- und Zahlungsmodell der jaehrlichen Stoerueberwachungslizenz
|
||||
- Verhalten nach Ablauf einer spaeter aktivierten Stoerueberwachungslizenz
|
||||
@@ -0,0 +1,122 @@
|
||||
# Offene Punkte
|
||||
|
||||
Diese Datei ist die zentrale, repositoryuebergreifende Liste fuer Themen, die das
|
||||
Enelix-Team noch entscheiden muss. Sie dient als Arbeitsgrundlage fuer
|
||||
Sprintmeetings. Modulinterne Implementierungsdetails bleiben in der jeweiligen
|
||||
Moduldokumentation.
|
||||
|
||||
## Arbeitsweise
|
||||
|
||||
- Neue Punkte erhalten fortlaufende IDs im Format `OP-001`.
|
||||
- Erlaubte Status sind `Offen`, `In Klaerung`, `Entschieden` und `Zurueckgestellt`.
|
||||
- Ein Eintrag beschreibt die konkrete Entscheidungsfrage, nicht nur ein Stichwort.
|
||||
- Entscheidungen bleiben in dieser Datei erhalten und verweisen auf ADR, Issue oder Commit.
|
||||
- Neue Punkte werden standardmaessig ohne Verantwortlichen und Ziel-Sprint erfasst.
|
||||
- Im Sprintmeeting werden Status, Verantwortlicher und Ziel-Sprint gepflegt.
|
||||
|
||||
## Aufnahmeschranke fuer offene Punkte und Issues
|
||||
|
||||
Ein neuer Eintrag wird nur angelegt, wenn er konkret, abgrenzbar und pruefbar ist.
|
||||
|
||||
- Eine offene Entscheidung nennt eine konkrete Entscheidungsfrage, ihren Kontext und die Auswirkung der Entscheidung.
|
||||
- Ein Bug beschreibt betroffenes Verhalten, Ist- und Sollzustand sowie eine nachvollziehbare Ausloesung.
|
||||
- Ein Feature oder Task nennt Anwendungsfall, Ausloeser oder Eingabe, erwartetes Verhalten und ein pruefbares Ergebnis.
|
||||
- Ein Eintrag behandelt genau ein zusammenhaengendes Thema und kann unabhaengig umgesetzt oder entschieden werden.
|
||||
- Zu grosse oder gebuendelte Anforderungen werden vor der Aufnahme in kleinere, einzeln pruefbare Punkte zerlegt.
|
||||
|
||||
Abstrakte Ziele, Visionen und Loesungsideen ohne konkretes Verhalten werden nicht aufgenommen.
|
||||
Die Rueckmeldung lautet in diesem Fall sinngemaess:
|
||||
|
||||
> Nicht aufgenommen: Der Punkt ist noch zu abstrakt. Bitte in konkrete,
|
||||
> einzeln ausfuehrbare und pruefbare Probleme, Entscheidungen oder Schritte unterteilen.
|
||||
|
||||
Beispiel fuer einen ausreichend konkreten Ausgangspunkt:
|
||||
|
||||
> Die Temperatureinheit des Boilers kann zwischen Celsius und Fahrenheit
|
||||
> eingestellt werden; Anzeige und Grenzwerte verwenden die gewaehlte Einheit.
|
||||
|
||||
Beispiel fuer einen noch zu abstrakten Punkt:
|
||||
|
||||
> Der Manager soll aufgrund einer Prognose regeln.
|
||||
|
||||
Hier fehlen unter anderem Prognoseart, geregelte Verbraucher, konkretes Regelziel,
|
||||
Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
|
||||
|
||||
## Uebersicht
|
||||
|
||||
| ID | Status | Bereich | Kurzthema |
|
||||
| --- | --- | --- | --- |
|
||||
| OP-001 | Entschieden | Lizenzierung | Betriebsort des Lizenzservers |
|
||||
| OP-002 | Erledigt | Grundeinrichtung | Generator und kostenpflichtige Ersteinrichtung |
|
||||
| OP-003 | Offen | Lizenzportal | VGT-Integration in license.enelix.ch |
|
||||
| OP-004 | Entschieden | Lizenzierung | Lizenzmodell des Verbrauchskostenreports |
|
||||
| OP-005 | Offen | Teststrategie | Standort fuer Feldtests |
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
### OP-003: VGT-Integration in license.enelix.ch
|
||||
|
||||
- **Entscheidungsfrage:** Soll die VGT-Anwendung als Bestandteil in die
|
||||
Oberflaeche von `license.enelix.ch` integriert werden?
|
||||
- **Kontext:** Zu klaeren ist, ob die VGT-Anwendung innerhalb der Oberflaeche von
|
||||
`license.enelix.ch` oder getrennt davon bereitgestellt wird.
|
||||
- **Auswirkung:** Die Entscheidung legt fest, ob die VGT-Anwendung Teil dieser
|
||||
Oberflaeche oder eine eigenstaendige Anwendung bleibt.
|
||||
- **Verantwortlich:** Offen
|
||||
- **Ziel-Sprint:** Offen
|
||||
- **Ergebnis/Verweis:** Offen
|
||||
|
||||
### OP-005: Standort fuer Feldtests
|
||||
|
||||
- **Entscheidungsfrage:** An welchem Standort koennen die Module aus Enelix EMS
|
||||
und Enelix Utils unter realen Einsatzbedingungen getestet werden?
|
||||
- **Kontext:** Fuer die Module aus beiden Repositories soll ein geeigneter
|
||||
Standort fuer Feldtests festgelegt werden.
|
||||
- **Auswirkung:** Die Entscheidung legt fest, wo die praktische Validierung der
|
||||
Module unter realen Einsatzbedingungen stattfindet.
|
||||
- **Verantwortlich:** Offen
|
||||
- **Ziel-Sprint:** Offen
|
||||
- **Ergebnis/Verweis:** Offen
|
||||
|
||||
## Entschiedene Punkte
|
||||
|
||||
### OP-001: Betriebsort des Lizenzservers
|
||||
|
||||
- **Entscheidung:** Der Entwicklungs-Lizenzdienst wird unter
|
||||
`https://license.enelix.ch` betrieben.
|
||||
- **Manager-Endpunkt:** `POST /api/v1/licenses/activate`
|
||||
- **Ergebnis/Verweis:** [Obere Anschluesse](Obere-Anschluesse.md) und
|
||||
[Manager](module/Manager/README.md)
|
||||
|
||||
### OP-002: Generator und kostenpflichtige Ersteinrichtung
|
||||
|
||||
- **Entscheidung:** Der automatische Systemgenerator und die kostenpflichtige
|
||||
Ersteinrichtung sind derselbe Vorgang.
|
||||
- **Abrechnung:** Eine Bestellung aus dem Systemkonfigurator enthaelt
|
||||
automatisch die noch nicht bezahlten Einrichtungskosten fuer den Manager und
|
||||
die konfigurierten Verbrauchermodule. Bereits bezahlte Einrichtungsmengen
|
||||
werden je Anlage angerechnet. Direkte Lizenzbestellungen enthalten keine
|
||||
Einrichtungskosten.
|
||||
- **Status:** Erledigt
|
||||
- **Ergebnis/Verweis:** Umsetzung im Lizenzportal unter
|
||||
`https://license.enelix.ch`.
|
||||
|
||||
### OP-004: Lizenzmodell des Verbrauchskostenreports
|
||||
|
||||
- **Entscheidung:** Der Verbrauchskostenreport verwendet eine Grundlizenz sowie
|
||||
getrennte Kontingente fuer Stromzaehler und Nebenzaehler. Die Anzahl der
|
||||
konfigurierten Zaehler wird bei der Lizenzpruefung beruecksichtigt.
|
||||
- **Status:** Entschieden und implementiert
|
||||
- **Ergebnis/Verweis:** Enelix Utils, Commit `1f945be` und
|
||||
`Verbrauchskostenreport/module.php`.
|
||||
|
||||
## Vorlage fuer neue Punkte
|
||||
|
||||
```markdown
|
||||
### OP-NNN: Kurztitel
|
||||
|
||||
- **Entscheidungsfrage:** Welche konkrete Entscheidung muss das Team treffen?
|
||||
- **Verantwortlich:** Offen
|
||||
- **Ziel-Sprint:** Offen
|
||||
- **Ergebnis/Verweis:** Offen
|
||||
```
|
||||
@@ -0,0 +1,112 @@
|
||||
# Schnittstelle Batterie
|
||||
|
||||
Diese Beschreibung ergaenzt den allgemeinen
|
||||
[Manager-Verbraucher-Vertrag](Schnittstelle.md) fuer das Batteriemodul.
|
||||
Vertragsversion ist 4.0.
|
||||
|
||||
## Manager an Batterie
|
||||
|
||||
Die Batterie empfaengt den unveraenderten gemeinsamen Datensatz:
|
||||
|
||||
~~~json
|
||||
{
|
||||
"Kopf": {
|
||||
"Version": "4.0",
|
||||
"AbsenderID": 10001,
|
||||
"EmpfaengerID": 20001,
|
||||
"Zeitpunkt": 1788825600
|
||||
},
|
||||
"Betriebsart": "PV",
|
||||
"Sollleistung_W": -1500
|
||||
}
|
||||
~~~
|
||||
|
||||
Sollleistung_W verwendet folgende Semantik:
|
||||
|
||||
- positiver Wert: Batterie laden
|
||||
- negativer Wert: Batterie entladen
|
||||
- 0: Leistungsregister auf 0 setzen
|
||||
- null: nur Betriebsart synchronisieren und neues Angebot anfordern
|
||||
|
||||
Ein konkreter Wert muss im zuletzt fuer dieselbe Betriebsart gemeldeten
|
||||
Einzelwert oder Leistungsbereich enthalten sein. Waehrend der Aenderungssperre
|
||||
wird nur die Wiederholung des aktuellen Sollwerts akzeptiert.
|
||||
|
||||
## Batterie an Manager
|
||||
|
||||
Die Batterie verwendet alle Pflichtfelder des gemeinsamen Vertrags und
|
||||
ergaenzt folgende Zustandseintraege:
|
||||
|
||||
| Kennung | Art | Typ | Einheit | Bedeutung |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Sollleistung_W | Sollwert | Integer oder null | W | Aktuell gueltige Manager-Vorgabe |
|
||||
| Ladezustand_Prozent | Istwert | Float oder null | % | Physischer SoC |
|
||||
| HystereseAktiv | Status | Boolean | - | Reserve-Hysterese ist aktiv |
|
||||
| Batteriesteuerung | Status | Integer | - | 1 Wechselrichter, 2 Enelix |
|
||||
| Messwertfehler | Stoerung | Boolean | - | Pflichtmesswert fehlt oder ist ungueltig |
|
||||
| Registerfehler | Stoerung | Boolean | - | Mindestens ein Schreibbefehl ist fehlgeschlagen |
|
||||
|
||||
Beispiel:
|
||||
|
||||
~~~json
|
||||
{
|
||||
"Kopf": {
|
||||
"Version": "4.0",
|
||||
"AbsenderID": 20001,
|
||||
"EmpfaengerID": 10001,
|
||||
"Zeitpunkt": 1788825602
|
||||
},
|
||||
"Betriebsart": "PV",
|
||||
"PrioritaetPV": 0,
|
||||
"PrioritaetPeak": 0,
|
||||
"Leistungswerte_W": [{"Von_W": -5000, "Bis_W": 5000}],
|
||||
"AenderungMoeglich": true,
|
||||
"Verfuegbar": true,
|
||||
"Istleistung_W": -250.0,
|
||||
"Leistungsquelle": 2,
|
||||
"Zustand": [
|
||||
{
|
||||
"Kennung": "Sollleistung_W",
|
||||
"Art": "Sollwert",
|
||||
"Wert": -537,
|
||||
"Einheit": "W"
|
||||
},
|
||||
{
|
||||
"Kennung": "Ladezustand_Prozent",
|
||||
"Art": "Istwert",
|
||||
"Wert": 54.2,
|
||||
"Einheit": "%"
|
||||
}
|
||||
]
|
||||
}
|
||||
~~~
|
||||
|
||||
Ist die Leistungsmessung ungueltig, wird Istleistung_W als null und
|
||||
Leistungsquelle als 0 gemeldet. Bei einem gueltigen Messwert ist
|
||||
Leistungsquelle 2. Frei regelbare Lade- und Entladeangebote werden als
|
||||
inklusive Bereiche in ganzen Watt gemeldet. Feste Schutz- und Peakvorgaben
|
||||
bleiben einzelne Leistungswerte.
|
||||
|
||||
## Ereignisse
|
||||
|
||||
Das Modul registriert VM_UPDATE fuer:
|
||||
|
||||
- maximale Ladeleistung
|
||||
- maximale Entladeleistung
|
||||
- Ladezustand
|
||||
- Netzleistung
|
||||
- aktuelle Batterieleistung
|
||||
|
||||
Jede Aktualisierung berechnet Zustand und Angebot neu und plant eine
|
||||
gebuendelte Rueckmeldung. Registerwerte werden nur geschrieben, wenn sich der
|
||||
resultierende Befehl geaendert hat.
|
||||
|
||||
## Registerausgang
|
||||
|
||||
Der technische Ausgang besteht aus ausgewaehlten numerischen
|
||||
IP-Symcon-Variablen. Das Modul verwendet RequestAction und setzt diese
|
||||
Variablen nicht mit SetValue. Damit bleibt der jeweilige Modbus-, Skript- oder
|
||||
Geraeteadapter fuer die konkrete Registerkommunikation verantwortlich.
|
||||
|
||||
Die genaue Herstellerabbildung steht in der
|
||||
[Moduldokumentation](module/Batterie/README.md).
|
||||
@@ -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.
|
||||
+108
-25
@@ -1,6 +1,6 @@
|
||||
# EMS-Schnittstelle
|
||||
|
||||
Vertragsversion: `3.0`
|
||||
Vertragsversion: `4.0`
|
||||
|
||||
Es gibt genau eine fachliche Empfangsmethode je Richtung:
|
||||
|
||||
@@ -13,7 +13,7 @@ public function VerbraucherdatenEmpfangen(array $daten): void;
|
||||
|
||||
| Feld | Typ | Bedeutung |
|
||||
| --- | --- | --- |
|
||||
| `Version` | Text | Vertragsversion `3.0` |
|
||||
| `Version` | Text | Vertragsversion `4.0` |
|
||||
| `AbsenderID` | Ganzzahl | Sendende Symcon-Instanz |
|
||||
| `EmpfaengerID` | Ganzzahl | Empfangende Symcon-Instanz |
|
||||
| `Zeitpunkt` | Ganzzahl | Unixzeit in UTC |
|
||||
@@ -23,61 +23,144 @@ public function VerbraucherdatenEmpfangen(array $daten): void;
|
||||
```json
|
||||
{
|
||||
"Kopf": {
|
||||
"Version": "3.0",
|
||||
"Version": "4.0",
|
||||
"AbsenderID": 10001,
|
||||
"EmpfaengerID": 20001,
|
||||
"Zeitpunkt": 1788825600
|
||||
},
|
||||
"Betriebsart": "PV",
|
||||
"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
|
||||
|
||||
```json
|
||||
{
|
||||
"Kopf": {
|
||||
"Version": "3.0",
|
||||
"Version": "4.0",
|
||||
"AbsenderID": 20001,
|
||||
"EmpfaengerID": 10001,
|
||||
"Zeitpunkt": 1788825602
|
||||
},
|
||||
"Betriebsart": "Peak",
|
||||
"PrioritaetPV": 0,
|
||||
"PrioritaetPeak": 0,
|
||||
"Leistungswerte_W": [
|
||||
-3000,
|
||||
-2000,
|
||||
{"Von_W": -1000, "Bis_W": -500},
|
||||
0,
|
||||
100,
|
||||
{"Von_W": 1000, "Bis_W": 2000},
|
||||
3000
|
||||
],
|
||||
"AenderungMoeglich": true,
|
||||
"Leistungswerte_W": [0],
|
||||
"AenderungMoeglich": false,
|
||||
"Verfuegbar": true,
|
||||
"Istleistung_W": 1498.5,
|
||||
"Leistungsquelle": 2,
|
||||
"Istleistung_W": 0,
|
||||
"Leistungsquelle": 1,
|
||||
"Zustand": [
|
||||
{
|
||||
"Kennung": "Sollleistung_W",
|
||||
"Art": "Sollwert",
|
||||
"Wert": 1501,
|
||||
"Wert": 0,
|
||||
"Einheit": "W"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Feste Regeln
|
||||
## Betriebsart-Synchronisation
|
||||
|
||||
- Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet eine hoehere Prioritaet.
|
||||
1. Der Manager bestimmt `PV` oder `Peak`.
|
||||
2. Meldungen einer anderen Betriebsart werden nicht zur Verteilung verwendet.
|
||||
3. Der Manager sendet diesen Verbrauchern eine Betriebsart-Ankuendigung mit
|
||||
`Sollleistung_W=null`.
|
||||
4. Jeder Verbraucher berechnet und meldet seine PowerSteps fuer diese
|
||||
Betriebsart.
|
||||
5. Der Manager verteilt Sollleistungen an alle bereits synchronisierten
|
||||
Verbraucher.
|
||||
6. Fehlende, veraltete oder noch nicht umgeschaltete Verbraucher werden nicht
|
||||
angesteuert und als Stoerung ausgewiesen; sie blockieren die aktuellen
|
||||
Verbraucher nicht.
|
||||
|
||||
Damit kann jeder Verbrauchertyp unterschiedliche Angebote fuer PV und Peak
|
||||
melden, ohne dass der Manager seine interne Geraetelogik kennen muss.
|
||||
|
||||
## Feste Regeln
|
||||
|
||||
- Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet hoehere Prioritaet.
|
||||
- Jeder verfuegbare, synchronisierte Verbraucher mit einem nicht leeren
|
||||
`Leistungswerte_W`-Angebot erhaelt einen Sollwert aus genau diesem Angebot.
|
||||
- `AenderungMoeglich=false` kennzeichnet ein fixes Angebot und ist kein
|
||||
Ausschlussgrund. Ein Angebot `[11000]` fuehrt deshalb zwingend zu `11000 W`.
|
||||
- Prioritaeten verteilen nur die ueber den jeweiligen Mindestwert hinaus
|
||||
verfuegbare Leistung; sie duerfen keinen angebotenen Mindestwert verdrängen.
|
||||
- `Leistungsquelle`: 0 nicht vorhanden, 1 berechnet, 2 gemessen.
|
||||
- Bei Leistungsquelle 0 ist `Istleistung_W` zwingend `null`.
|
||||
- Leistungsbereiche enthalten jeden ganzen Wattwert von `Von_W` bis `Bis_W` einschliesslich.
|
||||
- Die Leistungswerte sind aufsteigend, eindeutig und ueberschneiden sich nicht.
|
||||
- `Zustand` enthaelt immer den gemeinsamen Eintrag `Sollleistung_W` und daneben nur benoetigte geraetespezifische Eintraege.
|
||||
- Eine aktive Stoerung wird mit `Art=Stoerung` und `Wert=true` gemeldet; eine behobene mit `false`.
|
||||
- Verbraucher werden ausschliesslich im Manager zugeordnet. Der Verbraucher besitzt keine Manager-ID-Property.
|
||||
- Leistungsbereiche enthalten jeden ganzen Wattwert von `Von_W` bis `Bis_W`.
|
||||
- Leistungswerte sind aufsteigend, eindeutig und ueberschneiden sich nicht.
|
||||
- `Zustand` enthaelt immer `Sollleistung_W`.
|
||||
- Verbraucher werden ausschliesslich im Manager zugeordnet.
|
||||
- Der Verbraucher besitzt keine Manager-ID-Property.
|
||||
|
||||
## Technische Umsetzung in IP-Symcon
|
||||
|
||||
Der Transport erfolgt ueber `IPS_RequestAction` mit JSON. `MessageSink`
|
||||
erkennt registrierte Aenderungen. Empfang und Neuberechnung sind intern
|
||||
entkoppelt, damit keine gegenseitige Endlosschleife entsteht.
|
||||
|
||||
Das PHP-Interface legt nur die Empfangsmethode fest. Die gemeinsamen
|
||||
Symcon-Datenpunkte registriert `VerbraucherBasisTrait`.
|
||||
|
||||
### Gemeinsame Properties aller Verbraucher
|
||||
|
||||
| Ident | Typ | Standard | Beschreibung |
|
||||
| --- | --- | --- | --- |
|
||||
| `PrioritaetPV` | Integer | `0` | Prioritaet in der Betriebsart PV |
|
||||
| `PrioritaetPeak` | Integer | `0` | Prioritaet in der Betriebsart Peak |
|
||||
| `Meldeintervall` | Integer | `10` | Vollstaendige Rueckmeldung in Sekunden |
|
||||
| `VorgabeTimeout` | Integer | `120` | Ablaufzeit einer Sollleistung |
|
||||
| `EinstellungenInVisu` | Boolean | `false` | Lokale Einstellungen in der Visualisierung |
|
||||
| `LoggingEin` | Boolean | `false` | Laufendes Diagnoseprotokoll |
|
||||
|
||||
### Gemeinsame Variablen aller Verbraucher
|
||||
|
||||
| Ident | Typ / Zugriff | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; Start `false` |
|
||||
| `Istleistung` | Float / Anzeige | Aktuelle Leistung in W |
|
||||
| `Leistungsquelle` | Integer / Anzeige | 0 nicht vorhanden, 1 berechnet, 2 gemessen |
|
||||
| `Sollleistung` | Integer / Anzeige | Angenommene oder lokal erzwungene Vorgabe |
|
||||
| `SollwertGueltig` | Boolean / Anzeige | Aktuelle, nicht abgelaufene Vorgabe |
|
||||
| `Verfuegbar` | Boolean / Anzeige | Verbraucher grundsaetzlich verfuegbar |
|
||||
| `AenderungMoeglich` | Boolean / Anzeige | Neue Vorgabe darf uebernommen werden |
|
||||
| `Stoerung` | Boolean / Anzeige | Mindestens eine Stoerung aktiv |
|
||||
| `Stoertext` | String / Anzeige | Zusammengefasste Stoerbeschreibung |
|
||||
|
||||
`Leistungswerte_W`, `Betriebsart` und `Zustand` werden intern gehalten
|
||||
und direkt in die Nachricht geschrieben.
|
||||
|
||||
## Batteriespezifische Erweiterung
|
||||
|
||||
Die Batterie verwendet denselben Vertrag 4.0 und ergaenzt Zustandseintraege
|
||||
fuer Ladezustand, Hysterese, Steuerungsmodus, Messwertfehler und
|
||||
Registerfehler. Positive Leistung bedeutet Laden, negative Leistung
|
||||
Entladen. Die vollstaendige Semantik und Registeranbindung beschreibt die
|
||||
[Schnittstelle Batterie](Schnittstelle-Batterie.md).
|
||||
|
||||
## Easee-Gateway-Transport
|
||||
|
||||
Der technische JSON-Vertrag zwischen `EaseeGateway` und
|
||||
`LadestationGateway` ist getrennt vom fachlichen Managervertrag dokumentiert:
|
||||
[Easee-Gateway-Schnittstelle](Schnittstelle-Easee-Gateway.md). Die
|
||||
Ladestation uebersetzt Gateway-Ereignisse in den hier beschriebenen
|
||||
Verbrauchervertrag `4.0`.
|
||||
|
||||
## Zeitverhalten
|
||||
|
||||
- Rueckmeldung nach Start, relevanten Aenderungen und alle `Meldeintervall`
|
||||
Sekunden.
|
||||
- Laufende Vorgaben werden vom Manager standardmaessig erneuert.
|
||||
- Nach `VorgabeTimeout` ist eine nicht erneuerte Vorgabe ungueltig.
|
||||
- Nach einem Neustart wird keine alte Vorgabe ungeprueft aufgenommen.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# ADR 0001: Warmwassererwaermer verwendet ausschliesslich Vertrag 3.0
|
||||
|
||||
## Status
|
||||
|
||||
Akzeptiert.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Der neue `VerbraucherWarmwassererwaermer` implementiert die bestehende
|
||||
`VerbraucherSchnittstelle` und den `Nachrichtenvertrag` in Version `3.0`.
|
||||
Die Enelix-1-Variablen `Power`, `PowerSteps`, `PV_Prio`, `Sperre_Prio`,
|
||||
`Is_Peak_Shaving` und `Leistung_Delta` werden nicht als zweite parallele
|
||||
Manager-Schnittstelle weitergefuehrt.
|
||||
|
||||
Der Manager bleibt allein fuer die Betriebsart und Leistungsverteilung
|
||||
verantwortlich. Lokale Temperatur- und Hygienesicherheit wird als eingeschraenktes
|
||||
Leistungsangebot mit `AenderungMoeglich=false` gemeldet.
|
||||
|
||||
## Folgen
|
||||
|
||||
- Es gibt genau eine fachliche Empfangsmethode pro Richtung.
|
||||
- Der Verbraucher hat keine Manager-ID-Property.
|
||||
- Alte Instanzen werden anhand der Migrationstabelle neu konfiguriert.
|
||||
- Manager und Verbraucher koennen unabhaengig getestet werden.
|
||||
@@ -0,0 +1,74 @@
|
||||
# ADR 0002: Verbraucher 1-Stufig arbeitet ereignisbasiert
|
||||
|
||||
## Status
|
||||
|
||||
Akzeptiert, am 17. September 2026 um getrennte Mindestzeiten erweitert.
|
||||
|
||||
## Kontext
|
||||
|
||||
Das Enelix-1-Modul berechnete seinen Zustand in einem festen Intervall und
|
||||
bildete Lastwechselsperren ueber `Interval`, `IdleCounterMax` und weitere
|
||||
zyklusabhaengige Zaehler ab. Ein erster Enelix-2-Stand ersetzte dies durch
|
||||
einen allgemeinen `Umschaltabstand`. Dieser konnte unterschiedliche
|
||||
Geraeteanforderungen fuer Ein- und Aus-Zustand nicht ausdruecken.
|
||||
|
||||
Bei einem asynchron schaltenden Geraet kann ausserdem der Aktorbefehl nicht als
|
||||
physische Zustandsbestaetigung gelten.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Der `VerbraucherEinStufig` verwendet keine zyklische Regelberechnung.
|
||||
Schaltkontakt, Rueckmeldung, Manager-Vorgabe, Freigabe, Vorgabeablauf und
|
||||
Tagesplanung loesen die Regelung direkt aus.
|
||||
|
||||
Der allgemeine `Umschaltabstand` entfaellt. An seine Stelle treten:
|
||||
|
||||
- `Mindesteinschaltdauer` in Sekunden, Standard 5,
|
||||
- `Mindestausschaltdauer` in Sekunden, Standard 5.
|
||||
|
||||
Die passende Mindestzeit beginnt mit dem bestaetigten Zustandswechsel. Ohne
|
||||
separate Rueckmeldung bestaetigt die Aktorvariable den Wechsel unmittelbar.
|
||||
Mit separater Rueckmeldung beginnt die Mindestzeit erst, wenn deren Wert den
|
||||
Zielzustand erreicht.
|
||||
|
||||
Bis dahin bleiben Istleistung und Leistungsangebot beim rueckgemeldeten
|
||||
Zustand. `SchaltbefehlAusstehend=true`,
|
||||
`AenderungMoeglich=false` und `Schaltbereit=false` machen die laufende
|
||||
Umschaltung fuer den Manager sichtbar.
|
||||
|
||||
Das gemeinsame `Meldeintervall` bleibt erhalten, weil Vertrag `3.0`
|
||||
zusaetzlich zu Ereignismeldungen eine periodische Vollmeldung fordert. Dieser
|
||||
Timer ist kein Regelzyklus.
|
||||
|
||||
Die Tagesmindestlaufzeit wird in realen Sekunden und lokaler Symcon-Zeitzone
|
||||
gezaehlt. Bei separater Rueckmeldung zaehlt ausschliesslich der bestaetigte
|
||||
Ein-Zustand.
|
||||
|
||||
## Alternativen
|
||||
|
||||
- Ein fester Regelzyklus wurde verworfen, weil Reaktionszeit und Zeitregeln
|
||||
wieder voneinander abhaengen wuerden.
|
||||
- Ein allgemeiner Umschaltabstand wurde verworfen, weil Ein- und Aus-Zustand
|
||||
unterschiedliche Mindestzeiten benoetigen koennen.
|
||||
- Der Aktorbefehl als sofortige physische Bestaetigung wurde bei vorhandener
|
||||
Rueckmeldung verworfen.
|
||||
- Ein zusaetzlicher Rueckmelde-Timeout wurde nicht eingefuehrt. Ein
|
||||
ausstehender Befehl bleibt transparent sichtbar, bis ein neues Ereignis den
|
||||
Zustand klaert.
|
||||
|
||||
## Folgen
|
||||
|
||||
- Lastwechsel reagieren ohne Polling auf relevante Ereignisse.
|
||||
- `Interval`, `IdleCounterMax` und `Umschaltabstand` entfallen.
|
||||
- Waerend Mindestzeiten und ausstehenden Rueckmeldungen wird nur die
|
||||
bestaetigte Istleistung angeboten.
|
||||
- Der Manager erhaelt Status und Restmindestzeit als Zustandseintraege.
|
||||
- Eine unerwartete Abweichung zwischen Aktor und Rueckmeldung ausserhalb eines
|
||||
laufenden Schaltvorgangs wird als Stoerung gemeldet.
|
||||
- Tageslaufzeit und Mindestzeiten sind unabhaengig von einer Zyklusdauer.
|
||||
- Das Modul bleibt vollstaendig im Repository Enelix EMS.
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- Eine spaetere sperrbare Variante wird separat spezifiziert.
|
||||
- Ein konfigurierbarer Rueckmelde-Timeout benoetigt eine eigene Entscheidung.
|
||||
@@ -0,0 +1,43 @@
|
||||
# ADR 0003: Standardisierte Teststrategie
|
||||
|
||||
## Kontext
|
||||
|
||||
Die Module besitzen PHPUnit-Tests, ihre Prüfungen in einer echten
|
||||
IP-Symcon-Installation waren jedoch unterschiedlich aufgebaut. Dadurch fehlten
|
||||
ein einheitlicher Aufruf, sicherer Cleanup, maschinenlesbare Berichte und eine
|
||||
verbindliche Regel für neue Module.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Enelix verwendet zwei Testebenen. PHPUnit bleibt die schnelle Pflichtprüfung bei
|
||||
jedem Push. Zusätzlich erhält jedes Modul einen registrierten Symcon-Modultest
|
||||
mit dem gemeinsamen `TestContext`. Der Runner unterstützt `single`,
|
||||
`affected` und `all`, isoliert jeden Testlauf und liefert Konsole, JSON und
|
||||
JUnit XML.
|
||||
|
||||
Der Manager-Test erzeugt jeden implementierten Verbrauchertyp und prüft ihn
|
||||
einzeln sowie in einer gemeinsamen Konstellation. Neue Verbrauchertypen müssen
|
||||
diese Matrix erweitern.
|
||||
|
||||
## Alternativen
|
||||
|
||||
- Nur PHPUnit: verworfen, weil das reale Objektmodell, Actions und
|
||||
Modulinteraktionen nicht abgedeckt werden.
|
||||
- Freie Schnellausführungs-Skripte pro Modul: verworfen, weil Aufbau, Cleanup
|
||||
und Berichte erneut auseinanderlaufen würden.
|
||||
- Ein drittes Test-Repository: vorerst verworfen, weil Tests zusammen mit dem
|
||||
jeweiligen Modul versioniert und atomar geändert werden sollen.
|
||||
|
||||
## Folgen
|
||||
|
||||
Jedes neue Modul benötigt zusätzlich zu Unit-Tests einen Manifest-Eintrag und
|
||||
einen Symcon-Test. Der Vertrags-Unit-Test verhindert unregistrierte Module.
|
||||
Integrationstests benötigen einen isolierten IP-Symcon-8.x-Runner. Testobjekte
|
||||
dürfen ausschließlich innerhalb der vom Framework erzeugten Kategorie liegen.
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- Bereitstellung und Registrierung des Gitea-Runners mit Label `symcon-8`.
|
||||
- Festlegung der Aufbewahrungsdauer für JSON- und JUnit-Artefakte.
|
||||
- Erweiterung der Manager-Matrix, sobald weitere Verbrauchertypen umgesetzt
|
||||
werden.
|
||||
@@ -0,0 +1,76 @@
|
||||
# ADR 0004: Betriebsartabhaengige Leistungsangebote
|
||||
|
||||
## Kontext
|
||||
|
||||
Der Nachrichtenvertrag 3.0 uebermittelte vom Manager nur
|
||||
`Sollleistung_W`. Verbraucher meldeten ein einziges Leistungsangebot und
|
||||
kannten die aktuelle Betriebsart nicht. Damit konnten Module keine
|
||||
unterschiedlichen PowerSteps fuer PV- und Peakbetrieb bereitstellen.
|
||||
|
||||
Eine einfache Ergaenzung des Sollwertpakets reicht nicht aus: Beim Wechsel der
|
||||
Betriebsart besitzt der Manager zunaechst noch das Angebot der vorherigen
|
||||
Betriebsart. Eine sofortige Verteilung koennte deshalb einen Sollwert erzeugen,
|
||||
den der Verbraucher im neuen Modus ablehnen muss.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Der Vertrag wird inkompatibel auf Version `4.0` angehoben.
|
||||
|
||||
- Managerdaten und Verbraucherdaten enthalten verpflichtend `Betriebsart`
|
||||
mit dem Wert `PV` oder `Peak`.
|
||||
- `Sollleistung_W` in Managerdaten darf `null` sein.
|
||||
- `null` ist eine reine Betriebsart-Ankuendigung und kein Schaltbefehl.
|
||||
- Der Verbraucher uebernimmt die Betriebsart, berechnet sein Leistungsangebot
|
||||
neu und meldet dieses mit derselben Betriebsart zurueck.
|
||||
- Der Manager verwendet nur Angebote seiner aktuellen Betriebsart.
|
||||
- Solange mindestens ein aktiver Verbraucher nicht synchronisiert ist, erfolgt
|
||||
keine Verteilung.
|
||||
- Nach einem Betriebsartwechsel verwirft der Manager seinen Sollwertcache.
|
||||
|
||||
Die konkrete Differenz zwischen PV- und Peakangebot bleibt Verantwortung des
|
||||
Verbrauchermoduls. Die derzeit implementierten Verbraucher uebernehmen die
|
||||
zustandsabhaengigen Enelix-1-Angebote gezielt:
|
||||
|
||||
- Der einstufige Verbraucher bietet in Peak normalerweise `[0]`. Bei faelliger
|
||||
Tagesmindestlaufzeit ist konfigurierbar, ob `[0, Nennleistung]` angeboten
|
||||
oder nur die Nennleistung erzwungen wird.
|
||||
- Die Ladestation bietet in Peak mit Solarladen `[0]`, ohne Solarladen dagegen
|
||||
`[0, ...Ladestufen]` an.
|
||||
- Der Warmwassererwaermer bietet unter seiner wirksamen Mindesttemperatur auch
|
||||
in Peak `[0, ...Leistungsstufen]` an.
|
||||
- Der Pufferspeicher bietet im Peakbetrieb unabhaengig vom Zustand `[0]` an.
|
||||
- Technische Schaltsperren und ausstehende Rueckmeldungen duerfen das Angebot
|
||||
weiterhin auf die aktuell gehaltene Leistung begrenzen.
|
||||
|
||||
## Alternativen
|
||||
|
||||
### Zwei Angebote gleichzeitig melden
|
||||
|
||||
Separate Felder fuer PV- und Peak-PowerSteps wuerden den Umschalt-Handshake
|
||||
vermeiden. Sie verdoppeln jedoch alle Angebotsdaten und zwingen jedes Modul,
|
||||
beide Zustaende jederzeit parallel zu berechnen.
|
||||
|
||||
### Betriebsart ohne Synchronisation senden
|
||||
|
||||
Der Manager koennte Betriebsart und Sollleistung in einem Paket senden. Das
|
||||
erste Kommando nach einem Wechsel waere dann aus dem alten Angebot berechnet
|
||||
und koennte ungueltig sein.
|
||||
|
||||
### Betriebsart aus einer Manager-Variable lesen
|
||||
|
||||
Eine direkte Objektkopplung wuerde die definierte Schnittstelle umgehen,
|
||||
mehrere Manager erschweren und Verbraucher unnoetig an die Managerinstanz
|
||||
binden.
|
||||
|
||||
## Folgen
|
||||
|
||||
- Alle Manager und Verbraucher einer Installation muessen gemeinsam auf
|
||||
Vertrag 4.0 aktualisiert werden.
|
||||
- Version 3.0 und 4.0 koennen nicht innerhalb derselben Managerzuordnung
|
||||
gemischt werden.
|
||||
- Ein Betriebsartwechsel benoetigt mindestens einen zusaetzlichen
|
||||
Nachrichtenumlauf.
|
||||
- Tests pruefen Vertragsvalidierung, Synchronisation und modulspezifische
|
||||
Angebote fuer PV und Peak.
|
||||
- ADR 0001 bleibt als historische Entscheidung erhalten, wird fuer den
|
||||
aktuellen Vertrag aber durch diese Entscheidung abgeloest.
|
||||
@@ -0,0 +1,76 @@
|
||||
# ADR 0005: Anlagentopologie als Manager-Stammdaten
|
||||
|
||||
## Kontext
|
||||
|
||||
Die Prognose benoetigt technische Stammdaten zu PV-Flaechen,
|
||||
Wechselrichtern und Batteriespeichern. Diese Daten beschreiben die reale
|
||||
Installation und werden auch von der lokalen Regelung benoetigt. Eine
|
||||
unabhaengige Pflege im Prognoseportal wuerde zwei konkurrierende Wahrheiten
|
||||
erzeugen.
|
||||
|
||||
Hybridwechselrichter benoetigen eine ausdrueckliche Topologie. PV und Batterie
|
||||
duerfen nicht als zwei unabhaengige AC-Quellen mit jeweils voller
|
||||
Wechselrichterleistung behandelt werden.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Der Enelix-Manager ist die fuehrende Quelle fuer die technische
|
||||
Anlagentopologie. Er speichert drei getrennte Listen:
|
||||
|
||||
- Wechselrichter mit Typ, AC-Nennleistung und optionalen AC-Grenzen,
|
||||
- PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichterbezug,
|
||||
- Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und
|
||||
Wechselrichterbezug.
|
||||
|
||||
Jede Komponente besitzt eine innerhalb ihrer Liste eindeutige, stabile ID.
|
||||
PV-Flaechen und Batterien referenzieren einen Wechselrichter ueber diese ID.
|
||||
|
||||
Die AC-Nennleistung gehoert zum Wechselrichter. Die DC-Leistung gehoert zur
|
||||
PV-Flaeche. Modulanzahl und Modulleistung sind optionale Detailangaben und
|
||||
muessen gemeinsam gepflegt werden.
|
||||
|
||||
Bei einem Hybridwechselrichter bleiben PV-Flaechen und Batterie getrennte
|
||||
Komponenten. Der Exportvertrag liefert zusaetzlich eine gemeinsame AC-Grenze,
|
||||
die beide Seiten demselben Wechselrichter zuordnet. Damit darf ihre kombinierte
|
||||
AC-Leistung die Wechselrichtergrenze nicht unabhaengig mehrfach ausschoepfen.
|
||||
|
||||
Der Manager exportiert die normalisierte Struktur mit Vertragsversion '1.0'
|
||||
und seiner stabilen Lizenz-Installations-ID. Eine aggregierte Zusammenfassung
|
||||
erleichtert die schrittweise Anbindung bestehender Prognoseberechnungen.
|
||||
|
||||
Tarife, Prognosevarianten und rein prognosespezifische Annahmen bleiben im
|
||||
Prognoseportal. Dort werden die vom Manager gelieferten technischen Stammdaten
|
||||
standardmaessig nur angezeigt.
|
||||
|
||||
## Alternativen
|
||||
|
||||
### Vollstaendige Pflege im Prognoseportal
|
||||
|
||||
Diese Variante waere fuer den Prognosedienst einfach, erzeugt aber
|
||||
Doppelpflege und kann von der lokal tatsaechlich installierten Anlage
|
||||
abweichen.
|
||||
|
||||
### Batterie und PV als ein Hybridobjekt speichern
|
||||
|
||||
Ein einzelnes Objekt waere kompakt, bildet mehrere PV-Flaechen, MPPT-Eingaenge
|
||||
und spaetere Erweiterungen jedoch schlecht ab. Ausserdem gingen die getrennten
|
||||
Kapazitaets- und Leistungsgrenzen verloren.
|
||||
|
||||
### AC- und DC-Leistung an jeder PV-Flaeche speichern
|
||||
|
||||
Das wuerde bei mehreren Flaechen an einem Wechselrichter die AC-Leistung
|
||||
mehrfach zaehlen. Deshalb wird AC am Wechselrichter und DC an der Flaeche
|
||||
gespeichert.
|
||||
|
||||
## Folgen
|
||||
|
||||
- Bestehende Installationen bleiben mit drei leeren Listen gueltig.
|
||||
- Fehlerhafte Referenzen oder unplausible technische Grenzen sperren die
|
||||
Manager-Konfiguration kontrolliert.
|
||||
- Ein Hybridwechselrichter kann mehrere PV-Flaechen und Batteriespeicher
|
||||
verbinden, besitzt aber nur eine gemeinsame AC-Nennleistung.
|
||||
- Das Prognoseportal muss die technischen Stammdaten aus dem Managervertrag
|
||||
lesen und eigene Eingaben dafuer als schreibgeschuetzt behandeln.
|
||||
- Fuer den automatischen Upload ist ein eigener, widerrufbarer
|
||||
Installationszugang erforderlich. Der Lizenzcode selbst wird nicht als
|
||||
dauerhaftes API-Secret verwendet.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Migration Batterie aus Enelix 1
|
||||
|
||||
## Ziel
|
||||
|
||||
Die bisherige Batterie wird als Enelix-2-Verbraucher auf Vertrag 4.0
|
||||
umgestellt. Das alte Repository bleibt unveraendert und dient nur als
|
||||
Verhaltensreferenz.
|
||||
|
||||
## Uebernommen
|
||||
|
||||
- positive Sollleistung fuer Laden und negative Sollleistung fuer Entladen
|
||||
- dynamische maximale Lade- und Entladeleistung aus Variablen
|
||||
- Ladezustand, Netzleistung und aktuelle Batterieleistung als Eingaben
|
||||
- Reserve-, Mindestladezustands- und Hystereselogik
|
||||
- getrennte Angebote fuer PV und Peak
|
||||
- Wechselrichter- oder Enelix-Steuerung
|
||||
- Registercodes fuer generisch, GoodWe, SolarEdge und Sigenergy
|
||||
- Aufsummierung der bezogenen Energie
|
||||
|
||||
## Angepasst
|
||||
|
||||
| Enelix 1 | Enelix 2 |
|
||||
| --- | --- |
|
||||
| zyklischer Timer Do_UserCalc | Messwert- und Managerereignisse |
|
||||
| IdleCounter in Zyklen | Aenderungssperre in Sekunden mit Einmaltimer |
|
||||
| PowerSteps als Modulvariable | Leistungswerte_W direkt im Vertrag |
|
||||
| 250-W-Grundraster mit 50-W-Feinwerten | inklusive Leistungsbereiche in ganzen Watt |
|
||||
| interne Stellwertvariablen | ausgewaehlte Registervariablen mit RequestAction |
|
||||
| fest verdrahtete Herstellerhilfsvariablen | dynamisch sichtbare Registerauswahl |
|
||||
| fest codierte 2-%-Hysterese | Property LadezustandHysterese |
|
||||
| berechnete Istleistung | gemessene Istleistung, Leistungsquelle 2 |
|
||||
| Interval-basierte Energie | Zeitintegration zwischen Ereignissen und Vollmeldungen |
|
||||
| Is_Peak_Shaving als Variable | Betriebsart PV oder Peak im Vertrag 4.0 |
|
||||
|
||||
## Neu implementiert
|
||||
|
||||
- Validierung aller Mess- und Registervariablen
|
||||
- Pflichtaktion auf jeder Zielregistervariable
|
||||
- Messwertalter und sicherer Zustand bei ungueltigen Messwerten
|
||||
- Sollwert-Timeout
|
||||
- Diagnosevariablen, Registerfehlerstatus und Debug-Logging
|
||||
- standardisierte PHPUnit- und Symcon-8.0-Laufzeittests
|
||||
- explizite Schnittstellenbeschreibung
|
||||
|
||||
## Verworfen
|
||||
|
||||
- direkter SetValue-Aufruf auf internen Leistungs- und Modusvariablen
|
||||
- zyklische Regelung mit frei konfigurierbarem Interval
|
||||
- doppelte Peak-/PV-Codepfade mit identischer Registerabbildung
|
||||
- ungenutzte Batteriespannungsproperty
|
||||
- CheckIdle mit globalem GetValue ohne Objekt-ID
|
||||
|
||||
## Umstellung einer Anlage
|
||||
|
||||
1. Bestehende Batterieinstanz und alle bisherigen Variablen-IDs dokumentieren.
|
||||
2. Sicherung der IP-Symcon-Konfiguration erstellen.
|
||||
3. Neue Enelix-2-Batterieinstanz anlegen.
|
||||
4. Fuenf Messwertvariablen zuordnen.
|
||||
5. Batterietyp waehlen und die eingeblendeten Registervariablen zuordnen.
|
||||
6. Reserve, Mindestladezustand und Aenderungssperre uebertragen.
|
||||
7. Neue Batterie im Enelix-2-Manager zuordnen.
|
||||
8. Diagnose und Logging aktivieren.
|
||||
9. Laden, Entladen, 0 W und Wechselrichtermodus unter Aufsicht testen.
|
||||
10. Alte Instanz erst nach erfolgreicher Feldpruefung deaktivieren.
|
||||
|
||||
Ein automatisches Loeschen oder Ueberschreiben der alten Instanz findet nicht
|
||||
statt.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Batterie
|
||||
|
||||
> Status: implementiert. Zielplattform ist IP-Symcon ab Version 8.0,
|
||||
> Nachrichtenvertrag 4.0.
|
||||
|
||||
## Verantwortung
|
||||
|
||||
Das Modul bildet einen Batteriespeicher als bidirektionalen Enelix-Verbraucher
|
||||
ab. Es berechnet das betriebsart- und ladezustandsabhaengige
|
||||
Leistungsangebot, empfaengt Sollleistungen vom Manager und uebersetzt sie in
|
||||
herstellerspezifische Registerwerte.
|
||||
|
||||
Positive Leistung bedeutet Laden, negative Leistung Entladen. Die
|
||||
Geraeteanbindung erfolgt ausschliesslich ueber vom Benutzer ausgewaehlte
|
||||
numerische IP-Symcon-Variablen. Schreibziele muessen eine Aktion besitzen.
|
||||
|
||||
## Ereignismodell
|
||||
|
||||
Fachliche Neuberechnungen werden ausgeloest durch:
|
||||
|
||||
- Aktualisierung eines der fuenf Messwerte
|
||||
- Managerdaten mit Betriebsart oder Sollleistung
|
||||
- lokale Aenderung der Variablen Aktiv
|
||||
- Ablauf von Vorgabe-Timeout oder Aenderungssperre
|
||||
- manuelle Aktualisierung in der Konfigurationsmaske
|
||||
|
||||
Der Meldezyklus verschickt eine Vollmeldung und dient nicht als Regelzyklus.
|
||||
Ein periodischer Berechnungstimer oder Idle-Counter existiert nicht.
|
||||
|
||||
## Properties
|
||||
|
||||
### Gemeinsame Verbraucherproperties
|
||||
|
||||
PrioritaetPV, PrioritaetPeak, Meldeintervall, VorgabeTimeout,
|
||||
EinstellungenInVisu und LoggingEin stammen aus VerbraucherBasisTrait.
|
||||
|
||||
### Batteriespezifische Properties
|
||||
|
||||
| Property | Typ | Standard | Bedeutung |
|
||||
| --- | --- | ---: | --- |
|
||||
| Batterietyp | Integer | 0 | 0 unkonfiguriert, 1 generisch, 2 GoodWe, 3 SolarEdge, 4 Sigenergy |
|
||||
| Batteriemanagement | Integer | 1 | 1 Wechselrichter, 2 Enelix |
|
||||
| MaxLadeleistungVariableID | Integer | 0 | Dynamische Ladegrenze in W |
|
||||
| MaxEntladeleistungVariableID | Integer | 0 | Dynamische Entladegrenze in W |
|
||||
| LadezustandVariableID | Integer | 0 | SoC in Prozent |
|
||||
| NetzleistungVariableID | Integer | 0 | Positiv Netzbezug, negativ Einspeisung |
|
||||
| IstleistungVariableID | Integer | 0 | Positiv Laden, negativ Entladen |
|
||||
| ManagementRegisterVariableID | Integer | 0 | Steuerungsquelle des Wechselrichters |
|
||||
| ModusRegisterVariableID | Integer | 0 | Laden-/Entladen-Code |
|
||||
| LeistungsRegisterVariableID | Integer | 0 | GoodWe-Leistungsregister in W |
|
||||
| LadeleistungRegisterVariableID | Integer | 0 | Ladeleistung in W beziehungsweise kW |
|
||||
| EntladeleistungRegisterVariableID | Integer | 0 | Entladeleistung in W beziehungsweise kW |
|
||||
| ReserveLadezustand | Float | 20 | Peakshaving-Reserve in Prozent |
|
||||
| MindestLadezustand | Float | 10 | Untere Entladegrenze in Prozent |
|
||||
| LadezustandHysterese | Float | 2 | Hysterese oberhalb der Reserve |
|
||||
| MesswertMaxAlter | Integer | 60 | Maximales Messwertalter in Sekunden |
|
||||
| Aenderungssperre | Integer | 4 | Sperrzeit nach Sollwertaenderung |
|
||||
| DiagnosevariablenAnzeigen | Boolean | false | Technische Variablen einblenden |
|
||||
|
||||
## Registeradapter
|
||||
|
||||
| Typ | WR-Management | Enelix-Management | Laden | Entladen |
|
||||
| --- | ---: | ---: | --- | --- |
|
||||
| Generisch | 0 | 1 | Modus 0, Ladeleistung W | Modus 1, Entladeleistung W |
|
||||
| GoodWe | 1 | 2 | Modus 11, Betrag W | Modus 12, Betrag W |
|
||||
| SolarEdge | 1 | 4 | Modus 3, Ladeleistung W | Modus 4, Entladeleistung W |
|
||||
| Sigenergy | 0 | 1 | Modus 3, Ladeleistung kW | Modus 6, Entladeleistung kW |
|
||||
|
||||
Beim Wechsel in die Wechselrichtersteuerung werden zuerst die zum Typ
|
||||
gehoerenden Leistungsregister auf 0 gesetzt und danach der Automatikcode
|
||||
geschrieben. Im Enelix-Modus werden zuerst die Leistungswerte, dann Modus und
|
||||
zuletzt Management geschrieben.
|
||||
|
||||
## Leistungsangebot
|
||||
|
||||
Die Batterie meldet innerhalb der dynamischen Lade- und Entladegrenzen
|
||||
inklusive Leistungsbereiche in ganzen Watt. Der Manager kann dadurch jeden
|
||||
ganzzahligen Sollwert innerhalb des aktuell erlaubten Bereichs vorgeben. Die
|
||||
Grenzen werden abgerundet, damit keine dynamische Maximalleistung ueberschritten
|
||||
wird.
|
||||
|
||||
PV und Peak verwenden weiterhin die bisherige SoC-Logik. Feste Schutz- oder
|
||||
Peakvorgaben bleiben Einzelwerte; frei regelbare Angebote werden als Bereiche
|
||||
gemeldet. Die Hysterese wird als persistenter Modulzustand gefuehrt. Bei
|
||||
Wechselrichtersteuerung, lokaler Deaktivierung oder ungueltigen Messwerten
|
||||
lautet das Angebot [0].
|
||||
|
||||
## Variablen
|
||||
|
||||
Immer sichtbar sind Aktiv und Ladestatus. Ladestatus verwendet:
|
||||
|
||||
| Wert | Bedeutung |
|
||||
| ---: | --- |
|
||||
| 0 | Messwert ungueltig oder unbekannt |
|
||||
| 1 | Ruhezustand |
|
||||
| 2 | Laden |
|
||||
| 3 | Entladen |
|
||||
|
||||
Die Diagnoseoption ergaenzt die gemeinsamen Verbraucherdiagnosen sowie
|
||||
Ladezustand, Netzleistung, Leistungsgrenzen, Hysterese, Steuerungsmodus,
|
||||
BezogeneEnergie, LeistungsangebotDiagnose und LetzterRegisterbefehl.
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
- Status 201: Konfiguration oder Variablentyp ungueltig
|
||||
- Status 202: Messwert fehlt, ist veraltet oder fachlich ungueltig
|
||||
- Status 203: RequestAction auf mindestens ein Register ist fehlgeschlagen
|
||||
|
||||
Ein ungueltiger oder abgelaufener Sollwert wird verworfen und durch 0 ersetzt.
|
||||
Die konkrete Stoerung wird im Vertrag und bei aktivierter Diagnose in
|
||||
Stoerung und Stoertext gemeldet.
|
||||
|
||||
## Managerkommunikation
|
||||
|
||||
Die fachliche Schnittstelle ist in
|
||||
[Schnittstelle Batterie](../../Schnittstelle-Batterie.md) beschrieben.
|
||||
Transport und gemeinsame Felder folgen
|
||||
[EMS-Schnittstelle](../../Schnittstelle.md).
|
||||
|
||||
## Tests
|
||||
|
||||
- BatterieReglerTest: Leistungsbereiche, Hysterese, PV/Peak und Herstellerabbildung
|
||||
- BatterieModulstrukturTest: Metadaten, Formular, Ereignismodell und Manager-ID
|
||||
- Symcon/modules/Batterie.php: reale Modulinstanz und Registeraktionen unter
|
||||
IP-Symcon 8.0
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,118 @@
|
||||
# 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` wartet auf Start,
|
||||
`3` laedt, `4` geladen oder `5` Fehler.
|
||||
|
||||
Optional sichtbar sind die gemeinsamen EMS-Diagnosewerte sowie
|
||||
`GatewayVerbunden`, `ApiMaximalstrom`, `EaseeAktiviert`,
|
||||
`AutorisierungErforderlich`, `RemoteStartErforderlich`, `SmartCharging`,
|
||||
`GrundKeinLadestrom`, `LetzterGeraetebefehl`, `LetzterStartbefehl` und
|
||||
`LeistungsangebotDiagnose`. `GrundKeinLadestrom=55` bedeutet bei Easee, dass
|
||||
die Autorisierung fehlt.
|
||||
|
||||
## 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. Meldet Easee bei positiver Stromvorgabe weiterhin
|
||||
`Awaiting Start`, sendet das Modul einmal pro Fahrzeugverbindung zusaetzlich
|
||||
`start_charging`. Dieser Befehl autorisiert den Ladevorgang, uebersteuert aber
|
||||
keinen vorhandenen Easee-Ladeplan.
|
||||
|
||||
## Managerkommunikation
|
||||
|
||||
Das Modul implementiert den
|
||||
[EMS-Nachrichtenvertrag `4.0`](../../Schnittstelle.md). Es wird im Manager
|
||||
wie die Stand-Alone-Ladestation unter dem Lizenzkatalog `ev_charger`
|
||||
gefuehrt. Es gibt keine Manager-ID-Property; die Zuordnung erfolgt nur im
|
||||
Manager.
|
||||
|
||||
## Inbetriebnahme
|
||||
|
||||
1. Ein konfiguriertes Easee Gateway mit Status `102` bereitstellen.
|
||||
2. Darunter **Ladestation Gateway** erstellen.
|
||||
3. Variante, Seriennummer und elektrische Maximalgrenze konfigurieren.
|
||||
4. Ladefreigabe und Solarladen festlegen.
|
||||
5. Die Instanz im Enelix Manager aktiv zuordnen.
|
||||
6. Ohne Fahrzeug Status `1` und Angebot `[0]` pruefen.
|
||||
7. Fahrzeug verbinden und die API-Werte fuer Status und Phasenzahl kontrollieren.
|
||||
8. Erst danach `Aktiv` einschalten und eine kleine Vorgabe testen.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
composer check
|
||||
tests/Symcon/bin/run-symcon-tests.sh single LadestationGateway
|
||||
```
|
||||
@@ -0,0 +1,296 @@
|
||||
# Ladestation Stand-Alone
|
||||
|
||||
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
|
||||
|
||||
Das Modul bindet eine einzelne Ladestation direkt an Enelix EMS an. Es liest
|
||||
den Fahrzeug- und Ladezustand ueber die Geraete-API, ermittelt die Phasenzahl
|
||||
wie Enelix 1 und setzt den vom Manager gewaehlten Ladestrom. Es benoetigt keine
|
||||
Gateway-Instanz und hat keine technische Beziehung zum separaten Modul
|
||||
**Ladestation Gateway**.
|
||||
|
||||
## Funktionsumfang
|
||||
|
||||
- direkte Statusabfrage und Steuerung der unterstuetzten Ladestationen,
|
||||
- Fahrzeug-, Lade- und Phasenerkennung nach der Enelix-1-Logik,
|
||||
- Leistungsangebote fuer PV- und Peakbetrieb,
|
||||
- lokale Freigabe und Umschaltung zwischen Solar- und Normalbetrieb,
|
||||
- Kommunikation mit dem Enelix Manager ueber den Nachrichtenvertrag `4.0`,
|
||||
- optional sichtbare Diagnosevariablen und schaltbares Debug-Logging,
|
||||
- automatisierte Tests mit Fake-HTTP-Transport ohne reale Ladestation.
|
||||
|
||||
## Unterstuetzte Geraete
|
||||
|
||||
| Geraetetyp | Statusabfrage | Steuerung | Erforderliche Konfiguration |
|
||||
| --- | --- | --- | --- |
|
||||
| go-e Charger, alte API | `GET /mqtt?payload=` | `alw` und `amp` | IP-Adresse oder Hostname |
|
||||
| go-e Charger Gemini / Gemini flex | `GET /api/status` | `frc` und `amp` | IP-Adresse oder Hostname |
|
||||
| smart-me Pico | Pico-Charging-API | Load-Management-Current-API | Geraete-ID, Seriennummer, Benutzername und Passwort |
|
||||
|
||||
Bei go-e wird die lokale HTTP-API verwendet. Die Geraeteadresse wird ohne
|
||||
`http://`, Pfad oder Parameter eingetragen. Die Pico-Anbindung verwendet HTTPS
|
||||
und HTTP Basic Auth gegen `api.smart-me.com`. Zugangsdaten werden weder als
|
||||
Variable noch im Debug-Protokoll oder im Diagnosefeld
|
||||
`LetzterGeraetebefehl` ausgegeben.
|
||||
|
||||
## Fahrzeug- und Phasenerkennung
|
||||
|
||||
Die Auswertung folgt bewusst dem Verhalten der bisherigen Enelix-1-Ladestation:
|
||||
|
||||
1. Bei go-e gilt das Fahrzeug als verbunden, wenn `car != 1` ist. Bei Pico
|
||||
wird entsprechend `State != 1` ausgewertet.
|
||||
2. Die gemessene Leistung wird bei der alten go-e-API mit Faktor 10, bei
|
||||
Gemini direkt in Watt und bei Pico von kW in Watt umgerechnet.
|
||||
3. Bei einer Leistung ueber 7500 W wird dreiphasiges Laden erkannt. Darunter
|
||||
wird bei verbundenem Fahrzeug einphasiges Laden angenommen.
|
||||
4. Der Ladestrom wird aus der Leistung mit `230 V` einphasig beziehungsweise
|
||||
`1.71 * 400 V` dreiphasig berechnet.
|
||||
5. War das Fahrzeug bereits im vorherigen Zyklus verbunden und liegt der
|
||||
ermittelte Maximalstrom aus Ladestrom plus `2.5 A` unter `6 A`, wird
|
||||
`FahrzeugGeladen=true` gesetzt.
|
||||
|
||||
Der normalisierte `Fahrzeugstatus` verwendet folgende Werte:
|
||||
|
||||
| Wert | Bedeutung |
|
||||
| ---: | --- |
|
||||
| `0` | unbekannt oder noch nicht ermittelt |
|
||||
| `1` | kein Fahrzeug verbunden |
|
||||
| `2` | Fahrzeug verbunden und bereit |
|
||||
| `3` | Fahrzeug laedt |
|
||||
| `4` | Fahrzeug geladen |
|
||||
|
||||
## Leistungsregelung
|
||||
|
||||
Ein Leistungsangebot groesser als `0 W` wird nur erzeugt, wenn das Modul
|
||||
aktiviert, die Ladefreigabe gesetzt, ein Fahrzeug verbunden und das Fahrzeug
|
||||
noch nicht als geladen erkannt ist. Andernfalls meldet das Modul `[0]` und
|
||||
stoppt die Ladestation.
|
||||
|
||||
| Betriebsart | Solarladen | Leistungsangebot |
|
||||
| --- | --- | --- |
|
||||
| PV | ein | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum |
|
||||
| PV | aus | ausschliesslich die maximale Ladeleistung |
|
||||
| Peak | ein | ausschliesslich `[0]`; die Ladestation wird gestoppt |
|
||||
| Peak | aus | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum |
|
||||
|
||||
Die festen ein- und dreiphasigen Leistungsstufen sowie deren Rundung wurden aus
|
||||
Enelix 1 uebernommen. Ohne gueltige Manager-Vorgabe faehrt das Modul bei
|
||||
aktivem Solarladen mit `0 A`; bei ausgeschaltetem Solarladen verwendet es die
|
||||
maximale angebotene Leistung.
|
||||
|
||||
## Bedienung
|
||||
|
||||
| Ident | Wirkung |
|
||||
| --- | --- |
|
||||
| `Aktiv` | Gemeinsame lokale EMS-Freigabe. `false` setzt das Angebot auf `[0]` und stoppt die Ladestation. |
|
||||
| `Ladefreigabe` | Lokale Freigabe fuer das Laden. Diese Variable ist nur mit `EinstellungenInVisu=true` sichtbar. |
|
||||
| `Solarladen` | Schaltet das oben beschriebene PV-/Peak-Verhalten um. Diese Variable ist nur mit `EinstellungenInVisu=true` sichtbar. |
|
||||
|
||||
Die Properties `Ladefreigabe` und `Solarladen` definieren die Startwerte. Eine
|
||||
Aenderung der jeweiligen Property wird beim Anwenden der Konfiguration in den
|
||||
lokalen Zustand uebernommen. Eine Bedienung der Variablen verwirft eine noch
|
||||
gueltige Manager-Vorgabe und berechnet das Leistungsangebot sofort neu.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Gemeinsame EMS-Einstellungen
|
||||
|
||||
| Property | Typ | Standard | Beschreibung |
|
||||
| --- | --- | ---: | --- |
|
||||
| `PrioritaetPV` | Integer | `0` | Prioritaet des Verbrauchers im PV-Betrieb. |
|
||||
| `PrioritaetPeak` | Integer | `0` | Prioritaet des Verbrauchers im Peakbetrieb. |
|
||||
| `Meldeintervall` | Integer | `10 s` | Intervall fuer die periodische Vollmeldung an zugeordnete Manager. |
|
||||
| `VorgabeTimeout` | Integer | `120 s` | Gueltigkeitsdauer einer Manager-Vorgabe ohne Erneuerung. |
|
||||
| `EinstellungenInVisu` | Boolean | `false` | Blendet `Ladefreigabe` und `Solarladen` als bedienbare Variablen ein. |
|
||||
| `LoggingEin` | Boolean | `false` | Aktiviert das laufende Debug-Protokoll des Moduls. |
|
||||
|
||||
### Ladestation und Diagnose
|
||||
|
||||
| Property | Typ | Standard | Beschreibung |
|
||||
| --- | --- | ---: | --- |
|
||||
| `Geraetetyp` | Integer | `0` | `1` go-e alt, `2` go-e Gemini, `3` smart-me Pico; `0` ist nicht konfiguriert. |
|
||||
| `Geraeteadresse` | String | leer | IP-Adresse oder Hostname fuer beide go-e-Varianten. |
|
||||
| `GeraeteID` | String | leer | Geraete-ID fuer die Pico-Statusabfrage. |
|
||||
| `Seriennummer` | String | leer | Seriennummer fuer die Pico-Stromvorgabe. |
|
||||
| `Benutzername` | String | leer | Benutzername fuer die Pico-API. |
|
||||
| `Passwort` | String | leer | Passwort fuer die Pico-API; im Formular als Passwortfeld dargestellt. |
|
||||
| `MaximalerLadestrom` | Integer | `16 A` | Obergrenze der angebotenen Ladestufen; zulaessig sind 6 bis 32 A. |
|
||||
| `Abfrageintervall` | Integer | `5 s` | Intervall der Geraetestatusabfrage. |
|
||||
| `Ladefreigabe` | Boolean | `false` | Startwert der lokalen Ladefreigabe. |
|
||||
| `Solarladen` | Boolean | `true` | Startwert der lokalen Solarlogik. |
|
||||
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Blendet gemeinsame und ladestationsspezifische Diagnosevariablen ein. |
|
||||
|
||||
Die internen Properties `Testmodus` und `Testantwort` gehoeren ausschliesslich
|
||||
zum automatisierten Symcon-Funktionstest und erscheinen nicht im
|
||||
Konfigurationsformular.
|
||||
|
||||
## Variablen
|
||||
|
||||
Immer sichtbar:
|
||||
|
||||
| Ident | Typ / Zugriff | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; der Initialwert ist `false`. |
|
||||
| `FahrzeugVerbunden` | Boolean / Anzeige | Zeigt, ob die API ein verbundenes Fahrzeug meldet. |
|
||||
| `FahrzeugGeladen` | Boolean / Anzeige | Ergebnis der kompatiblen Enelix-1-Voll-Erkennung. |
|
||||
| `Fahrzeugstatus` | Integer / Anzeige | Normalisierter Status von `0` bis `4`. |
|
||||
| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Ladestrom in A. |
|
||||
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig oder `3` dreiphasig. |
|
||||
|
||||
Nur mit `EinstellungenInVisu=true` sichtbar und bedienbar:
|
||||
|
||||
- `Ladefreigabe`
|
||||
- `Solarladen`
|
||||
|
||||
Nur mit `DiagnosevariablenAnzeigen=true` sichtbar:
|
||||
|
||||
| Ident | Beschreibung |
|
||||
| --- | --- |
|
||||
| `Istleistung` | Von der Ladestation gemessene Leistung in W. |
|
||||
| `Leistungsquelle` | Immer `2` fuer eine gemessene Leistung. |
|
||||
| `Sollleistung` | Letzte angenommene Manager-Vorgabe in W. |
|
||||
| `SollwertGueltig` | Zeigt, ob die Vorgabe noch gueltig und im aktuellen Angebot enthalten ist. |
|
||||
| `Verfuegbar` | Zeigt, ob die Ladestation grundsaetzlich Leistung aufnehmen kann. |
|
||||
| `AenderungMoeglich` | Zeigt, ob das aktuelle Angebot mehr als eine Leistungsstufe enthaelt. |
|
||||
| `Stoerung` | Sammelstatus fuer Konfigurations- und Kommunikationsfehler. |
|
||||
| `Stoertext` | Letzte verstaendliche Fehlerbeschreibung. |
|
||||
| `LetzterGeraetebefehl` | Letzte HTTP-Methode und URL ohne Zugangsdaten. |
|
||||
| `LeistungsangebotDiagnose` | Aktuelles Leistungsangebot als JSON-Liste in W. |
|
||||
|
||||
Die Regellogik speichert ihren Zustand unabhaengig von der Sichtbarkeit der
|
||||
Diagnosevariablen. Das Ein- oder Ausblenden veraendert daher nicht das
|
||||
Regelverhalten.
|
||||
|
||||
## Managerkommunikation
|
||||
|
||||
Das Modul implementiert
|
||||
`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet
|
||||
[ausschliesslich den Nachrichtenvertrag `4.0`](../../Schnittstelle.md). Eine
|
||||
separate Manager-ID wird nicht konfiguriert. Das Modul akzeptiert Vorgaben nur
|
||||
von einem Manager, in dessen manueller oder automatischer
|
||||
Verbraucherzuordnung die Ladestation aktiv eingetragen ist.
|
||||
|
||||
An den Manager werden neben den gemeinsamen Feldern diese Zustaende gemeldet:
|
||||
|
||||
- `Sollleistung_W`
|
||||
- `FahrzeugVerbunden`
|
||||
- `FahrzeugGeladen`
|
||||
- `Fahrzeugstatus`
|
||||
- `Phasenzahl`
|
||||
- `Ladestrom_A`
|
||||
- `Ladefreigabe`
|
||||
- `Solarladen`
|
||||
- `Ladefehler` mit Stoertext
|
||||
|
||||
Eine Sollleistung wird nur angenommen, wenn sie im zuletzt berechneten
|
||||
Leistungsangebot enthalten ist. Nach `VorgabeTimeout` ohne Erneuerung wird sie
|
||||
ungueltig. Statusaenderungen werden kurz verzoegert und zusaetzlich alle
|
||||
`Meldeintervall` Sekunden an alle zugeordneten Manager gemeldet.
|
||||
|
||||
## Instanzstatus und Fehlerbehandlung
|
||||
|
||||
| Status | Bedeutung |
|
||||
| ---: | --- |
|
||||
| `102` | Konfiguration und letzte Geraetekommunikation sind gueltig. |
|
||||
| `201` | Konfiguration ungueltig, beispielsweise fehlende Adresse oder Pico-Zugangsdaten. |
|
||||
| `202` | Geraetekommunikation oder Antwortauswertung fehlgeschlagen. |
|
||||
|
||||
Bei einem Fehler meldet das Modul `Verfuegbar=false`,
|
||||
`AenderungMoeglich=false` und setzt `Stoerung` sowie `Stoertext`. HTTP-Anfragen
|
||||
verwenden 5 Sekunden Verbindungs- und 10 Sekunden Gesamt-Timeout. Antworten ab
|
||||
HTTP-Status 400 sowie unvollstaendige oder ungueltige JSON-Antworten gelten als
|
||||
Kommunikationsfehler.
|
||||
|
||||
## Installation und Inbetriebnahme
|
||||
|
||||
1. Im IP-Symcon Module Control den Testing-Branch `develop` der Bibliothek
|
||||
`https://git.belevo.ch/ENELIX/Enelix-EMS.git` installieren oder
|
||||
aktualisieren.
|
||||
2. Unter **Instanz hinzufuegen** nach **Ladestation Stand-Alone** suchen und
|
||||
eine Instanz anlegen.
|
||||
3. Den Geraetetyp auswaehlen und die dazugehoerigen Verbindungsdaten eintragen:
|
||||
bei go-e nur IP-Adresse oder Hostname, bei Pico Geraete-ID, Seriennummer,
|
||||
Benutzername und Passwort.
|
||||
4. Den maximal zulaessigen Ladestrom der Installation zwischen 6 und 32 A
|
||||
einstellen. Diese Grenze ersetzt keine elektrische Absicherung.
|
||||
5. `Abfrageintervall`, `Meldeintervall`, `VorgabeTimeout` sowie die beiden
|
||||
Prioritaeten festlegen.
|
||||
6. Die gewuenschten Startwerte fuer `Ladefreigabe` und `Solarladen` setzen.
|
||||
7. Fuer die Erstinbetriebnahme `EinstellungenInVisu`,
|
||||
`DiagnosevariablenAnzeigen` und bei Bedarf `LoggingEin` aktivieren.
|
||||
8. Die Instanz im Manager manuell aktiv zuordnen oder bei automatischer Suche
|
||||
in der gefundenen Liste aktivieren.
|
||||
9. Zuerst ohne Fahrzeug kontrollieren, ob die Statusabfrage fehlerfrei ist.
|
||||
Danach unter Aufsicht ein Fahrzeug verbinden und `Aktiv` einschalten.
|
||||
|
||||
Fuer go-e muss IP-Symcon das Geraet im lokalen Netz per HTTP erreichen koennen.
|
||||
Fuer Pico ist ausgehender HTTPS-Zugriff auf `api.smart-me.com` erforderlich.
|
||||
Passwoerter gehoeren ausschliesslich in das dafuer vorgesehene Passwortfeld und
|
||||
niemals in Repository-Dateien, Skripte oder Screenshots.
|
||||
|
||||
## Abnahmecheckliste
|
||||
|
||||
- Die Instanz erreicht Status `102`, und `Stoerung` bleibt `false`.
|
||||
- Ohne Fahrzeug sind `FahrzeugVerbunden=false`, `Phasenzahl=0` und das
|
||||
Leistungsangebot `[0]`.
|
||||
- Nach dem Anstecken wird das Fahrzeug erkannt und die gemessene Leistung
|
||||
plausibel in `Istleistung` und `Ladestrom` abgebildet.
|
||||
- Bei einer Ladeleistung bis 7500 W wird eine Phase, darueber werden drei
|
||||
Phasen angezeigt.
|
||||
- `Aktiv=false` oder `Ladefreigabe=false` stoppt die Ladestation.
|
||||
- PV mit `Solarladen=true` bietet `0 W` und die Ladestufen an.
|
||||
- PV mit `Solarladen=false` bietet nur die maximale Leistung an.
|
||||
- Peak mit `Solarladen=true` stoppt die Ladestation und bietet nur `[0]` an.
|
||||
- Peak mit `Solarladen=false` bietet `0 W` und die Ladestufen an.
|
||||
- Eine Manager-Vorgabe ausserhalb des gemeldeten Angebots wird abgewiesen.
|
||||
- Nach Ablauf des Vorgabe-Timeouts gilt wieder das lokale Ersatzverhalten.
|
||||
- Im Debug-Protokoll und in `LetzterGeraetebefehl` erscheinen keine
|
||||
Zugangsdaten.
|
||||
|
||||
## Fehlersuche
|
||||
|
||||
- Status `201`: Geraetetyp und Pflichtfelder kontrollieren. Bei go-e darf die
|
||||
Adresse kein Protokoll, keinen Pfad und keine Parameter enthalten. Bei Pico
|
||||
muessen alle vier Zugangsfelder befuellt sein.
|
||||
- Status `202`: Erreichbarkeit, DNS, lokale Firewall und API-Antwort pruefen.
|
||||
`Stoertext` enthaelt den konkreten Kommunikations- oder JSON-Fehler.
|
||||
- Fahrzeug wird nicht erkannt: Rohstatus der Geraete-API kontrollieren. Der
|
||||
Adapter erwartet bei go-e `car` und `nrg[11]`, bei Pico `State` und
|
||||
`ActiveChargingPower`.
|
||||
- Falsche Phasenzahl bei sehr kleiner oder stehender Ladung: Die kompatible
|
||||
Enelix-1-Erkennung basiert auf der momentanen Leistung. Fuer eine sichere
|
||||
Dreiphasenerkennung muss waehrend der Erkennung mehr als 7500 W anliegen.
|
||||
- Manager-Vorgabe wird abgewiesen: aktive Zuordnung im Manager sowie
|
||||
`LeistungsangebotDiagnose` und die Betriebsart kontrollieren.
|
||||
|
||||
## Tests
|
||||
|
||||
Die Unit- und Adaptertests verwenden einen injizierten Fake-HTTP-Transport.
|
||||
Damit werden fuer go-e alt, go-e Gemini und smart-me Pico Statusantworten,
|
||||
URL, HTTP-Methode, Authentisierung und Steueraufrufe ohne reale Hardware
|
||||
geprueft.
|
||||
|
||||
Der Symcon-Funktionstest verwendet den internen `Testmodus` mit simulierten
|
||||
API-Antworten. Er prueft alle drei Geraetevarianten, Fahrzeug- und
|
||||
Phasenerkennung, die Leistungsangebote in PV und Peak sowie die Umschaltung
|
||||
ueber den `Solarladen`-Button.
|
||||
|
||||
PHP-Syntax, Struktur- und Unit-Tests des gesamten Repositorys:
|
||||
|
||||
```bash
|
||||
composer check
|
||||
```
|
||||
|
||||
Funktionstest nur fuer die Ladestation gegen IP-Symcon 8:
|
||||
|
||||
```bash
|
||||
tests/Symcon/bin/run-symcon-tests.sh single LadestationStandAlone
|
||||
```
|
||||
|
||||
Vollstaendiger Symcon-Modultestlauf:
|
||||
|
||||
```bash
|
||||
tests/Symcon/bin/run-symcon-tests.sh all
|
||||
```
|
||||
|
||||
Aufbau, Testvertrag und Ergebnisdateien sind unter
|
||||
[`docs/testing/README.md`](../../testing/README.md) beschrieben.
|
||||
@@ -0,0 +1,359 @@
|
||||
# Manager
|
||||
|
||||
> Status: Implementiert. Führt Hauptmanager und Peakshaving zusammen.
|
||||
|
||||
Der Manager liest die Netzleistung, verwaltet ausschliesslich die ausgewählten
|
||||
Verbraucher und verteilt Leistung im PV- oder Peak-Betrieb. Die Prioritäten
|
||||
kommen aus den Verbrauchermeldungen.
|
||||
|
||||
## Variablen
|
||||
|
||||
Die drei Regelungsvariablen `Aktiv`, `Betriebsart` und `Netzleistung` sind immer sichtbar. Die vom Manager gefuehrten Mess- und Energievariablen werden immer angelegt und archiviert, koennen aber gemeinsam ausgeblendet werden.
|
||||
|
||||
| Ident | Typ / Zugriff | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `Aktiv` | Boolean / bedienbar | Regelung ein/aus; Start `false`. |
|
||||
| `Betriebsart` | String / Anzeige | `Inaktiv`, `PV` oder `Peak`. |
|
||||
| `Netzleistung` | Float / Anzeige | Aktuelle Netzleistung in W; positiv Bezug, negativ Einspeisung. |
|
||||
| `PVLeistungArchiv` | Float / Logging | Normalisierte PV-Leistung in W. |
|
||||
| `HausverbrauchLeistungArchiv` | Float / Logging | Normalisierter Hausverbrauch in W. |
|
||||
| `NetzleistungArchiv` | Float / Logging | Normalisierte Netzleistung in W; positiv Bezug, negativ Einspeisung. |
|
||||
| `BatterieleistungArchiv` | Float / Logging | Batterieleistung in W; positiv Laden, negativ Entladen. |
|
||||
| `PVEnergie` | Float / Zaehler | Integrierte PV-Erzeugung in kWh. |
|
||||
| `Hausenergie` | Float / Zaehler | Integrierter Hausverbrauch in kWh. |
|
||||
| `NetzbezugEnergie` | Float / Zaehler | Integrierter Netzbezug in kWh. |
|
||||
| `EinspeisungEnergie` | Float / Zaehler | Integrierte Einspeisung in kWh. |
|
||||
| `BatterieLadenEnergie` | Float / Zaehler | Integrierte Batterieladung in kWh. |
|
||||
| `BatterieEntladenEnergie` | Float / Zaehler | Integrierte Batterieentladung in kWh. |
|
||||
| `NetzleistungGueltig` | Boolean / Logging | Messquelle vorhanden und aktuell. |
|
||||
| `WirksameLastspitzengrenze` | Float / Logging | Aktuelle feste oder monatliche Bezugsgrenze in W. |
|
||||
| `WirksameEinspeisegrenze` | Float / Logging | Anlagenweite Einspeisegrenze in W. |
|
||||
| `Abregelbedarf` | Float / Logging | Aktuell erforderliche PV-Leistungsreduktion in W. |
|
||||
| `Wechselrichterstatus` | String/JSON / Logging | Verteilte Grenzen und Zustand der PV-Regelung. |
|
||||
| `Verteilbudget` | Float / Logging | Aktuell verfügbares Budget in W. |
|
||||
| `VerbraucherAnzahl` | Integer / Logging | Anzahl zugeordneter Verbraucher. |
|
||||
| `VerbraucherVerfuegbar` | Integer / Logging | Anzahl aktuell verfügbarer Verbraucher. |
|
||||
| `Verbraucherstatus` | String/JSON / Logging | Letzte vollständige Meldungen. |
|
||||
| `Prognosestatus` | String / Logging | `NichtVerwendet`, `Verbunden` oder `Fehler`. |
|
||||
| `SDLStatus` | String / Logging | Status des SDL/VGT-Anschlusses. |
|
||||
| `Lizenzstatus` | String / Logging | Status der Lizenzprüfung. |
|
||||
| `Stoerueberwachungsstatus` | String / Logging | Status der externen Störüberwachung. |
|
||||
| `Sammelstoerung` | Boolean / Logging | Eigene und weitergeleitete Störungen. |
|
||||
| `Stoertext` | String / Logging | Lesbare Sammelmeldung. |
|
||||
|
||||
## Properties
|
||||
|
||||
| Ident | Typ | Standard / Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `Rolle` | Auswahl | `Alleine`; alternativ `Hauptmanager` oder `Untermanager`. |
|
||||
| `NetzleistungVariableID` | Integer | `0*`; gültige Messquelle vor Regelstart. |
|
||||
| `Netzleistungsfaktor` | Float | `1`; auf W und die vereinbarte Vorzeichenrichtung normieren. |
|
||||
| `MesswertMaxAlter` | Integer | `60` s; auf echte Messaktualisierung bezogen. |
|
||||
| `VerbraucherZuordnung` | String/JSON | `[]`; manuelle Auswahl aus Instanz-ID und Aktiv-Status. |
|
||||
| `AutomatischeVerbraucherZuordnung` | String/JSON | `[]`; Auswahl aus automatisch gefundenen Verbrauchern. |
|
||||
| `AutomatischeSuche` | Boolean | `true`; zeigt gefundene Verbraucher zur gezielten Auswahl. |
|
||||
| `SuchbereichID` | Integer | `0`; optionaler Suchbereich für die automatische Suche. |
|
||||
| `KeepAlive` | Integer | `60` s; erneuert laufende Vorgaben zyklisch. |
|
||||
| `VerbraucherTimeout` | Integer | `60` s; erkennt ausgebliebene Verbrauchermeldungen. |
|
||||
| `Lastspitzenmodus` | Auswahl | `Aus`; alternativ `Konstant` oder `Monatlich`. |
|
||||
| `Lastspitzengrenze` | Float | Feste Grenze in W für Modus `Konstant`. |
|
||||
| `Monatsgrenzen` | String/JSON | Editierbare Liste mit zwölf Monatswerten in W. |
|
||||
| `SollwertSolarladen` | Float | `0` W; gewünschte Netzleistung im Solarladebetrieb. |
|
||||
| `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. |
|
||||
| `EinspeisebegrenzungAktiv` | Boolean | `false`; gemeinsame Exportgrenze am Netzanschlusspunkt aktivieren. |
|
||||
| `Einspeisegrenze` | Float | Maximale Einspeisung der Gesamtanlage in W; `0` bedeutet Nulleinspeisung. |
|
||||
| `PrognoseAktiv` | Boolean | `false`; Prognosetelemetrie und Topologiesynchronisation mit `forecast_pv` plus `forecast_load` oder `grid_schedule` aktivieren. |
|
||||
| `NetzfahrplanAktiv` | Boolean | `false`; lizenzierten, tarif- und prognosebasierten Netzzielwert verwenden. |
|
||||
| `NetzbezugEnergieVariableID` | Integer | Optionaler kumulativer Netzbezugszaehler. |
|
||||
| `NetzeinspeisungEnergieVariableID` | Integer | Optionaler kumulativer Einspeisezaehler. |
|
||||
| `NetzbezugEnergiefaktor`, `NetzeinspeisungEnergiefaktor` | Float | Umrechnung der Netzenergiezaehler nach kWh. |
|
||||
| `AnlagenWechselrichter` | String/JSON | `[]`; Wechselrichter mit Typ, AC-Nennleistung, Leistungs- und Erzeugungsenergiemessung sowie optionalem Begrenzungsregister. |
|
||||
| `AnlagenPVFlaechen` | String/JSON | `[]`; PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichter-ID. |
|
||||
| `AnlagenBatterien` | String/JSON | `[]`; Batteriespeicher mit Kapazitaeten, Kopplung, Leistung, SOC sowie Lade- und Entladeenergiezaehlern. |
|
||||
| `PrognoseSendeintervall` | Integer | `60` s; Intervall fuer den Upload aktueller Messwerte, zulaessig sind 60 bis 3600 Sekunden. |
|
||||
| `PrognoseAnschluss` | String/JSON | Verdeckte Altproperty fuer bestehende Konfigurationen. |
|
||||
| `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. |
|
||||
| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. |
|
||||
| `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. |
|
||||
| `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. |
|
||||
| `LoggingEin` | Boolean | `false`; laufende Meldungen im Debug-Fenster ausgeben. |
|
||||
| `EnergieaufzeichnungAktiv` | Boolean | `true`; managergefuehrte Leistungs- und Energieaufzeichnung aktivieren. |
|
||||
| `MesswerteAnzeigen` | Boolean | `false`; einzelne Manager-Messwerte in der Objektstruktur anzeigen. |
|
||||
| `EnergyPieAnzeigen` | Boolean | `false`; Energy Pie unter dem Manager anlegen oder entfernen. |
|
||||
| `EnergiediagrammeAnzeigen` | Boolean | `false`; native Leistungs- und Energiediagramme anlegen oder entfernen. |
|
||||
| `FunFactsAnzeigen` | Boolean | `false`; responsive Energy Facts anlegen oder entfernen. |
|
||||
| `EnergieflussAnzeigen` | Boolean | `false`; native Energy Distribution anlegen oder entfernen. |
|
||||
| `LeistungsaufzeichnungMinuten` | Auswahl | `1`; Verdichtung auf 1 Minute, 5 Minuten oder 1 Stunde. |
|
||||
| `LeistungLoeschenMonate` | Integer | `12`; Leistungswerte nach zwoelf Monaten loeschen, `0` deaktiviert die Loeschung. |
|
||||
| `EnergieaufzeichnungMinuten` | Auswahl | `5`; Verdichtung auf 1 Minute, 5 Minuten oder 1 Stunde. |
|
||||
| `EnergieVerdichtenMonate` | Integer | `12`; Energiezaehler nach zwoelf Monaten auf Tageswerte verdichten. |
|
||||
| `EnergieLoeschenMonate` | Integer | `0`; Energiezaehler nicht loeschen. |
|
||||
|
||||
Es gibt keine Sollwertquellenauswahl und keine zweite Prioritätseinstellung im
|
||||
Manager. Im Modus `Aus` bleibt Solarladen aktiv, nur die Peak-Begrenzung entfällt.
|
||||
Die Monatsgrenzen werden in den Grundeinstellungen über eine Schaltfläche
|
||||
ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut
|
||||
ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren.
|
||||
|
||||
## Energieaufzeichnung und Visualisierung
|
||||
|
||||
Sobald Netzleistung und mindestens eine PV-Istleistung in der Anlagentopologie
|
||||
konfiguriert sind, tastet der Manager die Quellen minuetlich ab. PV-Leistung ist
|
||||
die Summe der den PV-Flaechen zugeordneten Wechselrichter. Batterieleistung und
|
||||
der kapazitaetsgewichtete SOC stammen aus den Batteriespeichern. Positive
|
||||
Batterieleistung bedeutet Laden, negative Entladen. Der Hausverbrauch wird als
|
||||
`PV + Netz - Batterie` bilanziert. Sind Netzbezugs-, Einspeise-, PV-Erzeugungs-
|
||||
sowie gegebenenfalls Batterie-Lade- und Entladezaehler vollstaendig hinterlegt,
|
||||
verwendet der Manager deren Deltas fuer die Energieaufzeichnung. Die Hausenergie
|
||||
wird dann als `PV + Netzbezug + Batterieentladung - Einspeisung - Batterieladung`
|
||||
berechnet. Ohne vollstaendigen Zaehlerdatensatz integriert der Manager weiterhin
|
||||
die Leistungswerte. Dieselben Quellen versorgen ohne zweite Eingabe auch die
|
||||
Prognosetelemetrie. Ausfallluecken ueber fuenf Minuten werden bei der
|
||||
Leistungsintegration nicht nachberechnet.
|
||||
|
||||
Batteriespeicher bleiben als physische Komponenten samt Messquellen in der
|
||||
Anlagentopologie. Ein zusaetzliches Batterieverbrauchermodul beschreibt dagegen
|
||||
Regelung, Leistungsangebot und Betriebszustand; beide Rollen sind bewusst
|
||||
getrennt.
|
||||
|
||||
Die eigenen Variablen werden automatisch im ersten Archive Control aktiviert.
|
||||
Leistungen verwenden die Standardaggregation, Energievariablen den Zaehlermodus.
|
||||
Standardmaessig werden Leistungen auf eine Minute verdichtet und nach zwoelf
|
||||
Monaten geloescht. Energie wird auf fuenf Minuten verdichtet, nach zwoelf
|
||||
Monaten auf Tageswerte reduziert und nie geloescht. Aenderungen an diesen
|
||||
Regeln werden idempotent ueber die Archiv-API gesetzt und reaggregiert.
|
||||
|
||||
Vier Schalter im Bereich `Energieaufzeichnung` verwalten die
|
||||
Visualisierungen idempotent. Beim Einschalten werden die Objekte direkt unter
|
||||
dem Manager angelegt oder aktualisiert. Beim Ausschalten werden ausschliesslich
|
||||
die vom Manager anhand ihrer festen Kennung und ihres Typs erkannten Objekte
|
||||
entfernt beziehungsweise die native Energy Distribution ausgeblendet:
|
||||
|
||||
- Energy Pie aus Enelix Utils mit den vier relevanten Energiezaehlern,
|
||||
- zwei native IP-Symcon-Diagrammmedien fuer Leistungen und Energien,
|
||||
- responsive Energy Facts mit Solar-, Netz-, Haus- und Vergleichswerten,
|
||||
- native Energy Distribution fuer PV, Netz, Haus und Batterie.
|
||||
|
||||
## Anlagenweite Einspeisebegrenzung
|
||||
|
||||
Die Einspeisebegrenzung ist Bestandteil von Peak Shaving und arbeitet auf der
|
||||
Messung am Netzanschlusspunkt. Fuer jeden regelbaren PV- oder Hybridwechselrichter
|
||||
werden die Istleistungsvariable und eine bedienbare Begrenzungsvariable
|
||||
konfiguriert. Der Stellwert kann als Prozent der AC-Nennleistung oder als
|
||||
absolute Leistung in Watt ausgegeben werden.
|
||||
|
||||
Der Manager berechnet eine einzige Grenze fuer die Gesamtanlage. Beim Abregeln
|
||||
verteilt er sie nach der tatsaechlichen Erzeugung, beim Freigeben nach der
|
||||
AC-Nennleistung auf alle angebotenen Wechselrichter. Jede gemessene Abweichung
|
||||
wird stufenlos nachgefuehrt; ein zusaetzliches Toleranzfenster wird nicht
|
||||
verwendet. Beim Abschalten des Managers oder der Funktion werden zuvor gesetzte
|
||||
Grenzen kontrolliert auf die jeweilige
|
||||
Nennleistung zurueckgesetzt. Wechselrichter ohne beide Register werden nicht
|
||||
geregelt; sobald eines der beiden Register gesetzt ist, muessen beide gueltig
|
||||
sein und das Begrenzungsregister eine IP-Symcon-Aktion besitzen.
|
||||
|
||||
Der intelligente Netzfahrplan verwendet die PV-, Verbrauchs- und
|
||||
Netzleistungsprognose sowie Tarife und Batteriespeicher. Liegt im
|
||||
Prognosezeitraum mehr PV-Ertrag als zulaessige Einspeisung vor, wird dieses
|
||||
Potenzial als flexibler Verbrauch beziehungsweise Speicherladung eingeplant.
|
||||
Damit werden Verbraucher in ertragreiche Zeitfenster verschoben, bevor der
|
||||
harte Anlagenregler die Wechselrichter reduziert. Ein fehlender oder
|
||||
abgelaufener Fahrplan fuehrt automatisch zum konfigurierten
|
||||
`SollwertSolarladen` zurueck; die harte Einspeisebegrenzung bleibt unabhaengig
|
||||
davon aktiv.
|
||||
|
||||
## Anlagentopologie und Prognoseexport
|
||||
|
||||
Der Manager ist die fuehrende Quelle fuer die technischen Stammdaten. Die
|
||||
Konfiguration trennt Wechselrichter, PV-Flaechen und Batteriespeicher. AC-Leistung
|
||||
wird am Wechselrichter, DC-Leistung an der PV-Flaeche gepflegt. Individuelle
|
||||
AC-Einspeise- und Bezugsgrenzen werden nicht erfasst, weil die Begrenzung
|
||||
anlagenweit am Netzanschlusspunkt erfolgt. Modulanzahl und Modulleistung sind
|
||||
optional und muessen gemeinsam gesetzt werden.
|
||||
|
||||
PV-Flaechen und Batterien referenzieren ihren Wechselrichter ueber dessen stabile
|
||||
ID. Bei `hybrid` koennen beide denselben Wechselrichter verwenden. Der Export
|
||||
`ENELIX_AnlagentopologieExportieren($InstanzID)` weist dann eine gemeinsame
|
||||
AC-Grenze aus, damit PV und Batterie die Nennleistung nicht unabhaengig doppelt
|
||||
beanspruchen.
|
||||
|
||||
Der JSON-Export enthaelt die Vertragsversion `1.0`, die Lizenz-Installations-ID,
|
||||
die drei Komponentenlisten, gemeinsame AC-Nennleistungen und aggregierte Summen
|
||||
fuer die schrittweise Prognoseanbindung. Interne Symcon-Variablen-IDs werden
|
||||
nicht exportiert. Tarif- und Variantenparameter bleiben im Prognoseportal. Bei
|
||||
aktivierter Prognose wird eine nichtleere Topologie beim Speichern automatisch ueber
|
||||
einen separaten, widerrufbaren Installationszugang synchronisiert. Der
|
||||
Lizenzserver liefert diesen Zugang nur auf ausdrueckliche Geraeteanforderung;
|
||||
der Manager entfernt ihn vor dem Speichern aus der Lease und haelt ihn in einem
|
||||
internen Attribut. Der Lizenzcode wird nicht als API-Token verwendet. Ein
|
||||
Synchronisationsfehler erscheint im `Prognosestatus`, blockiert die lokale
|
||||
EMS-Regelung aber nicht.
|
||||
|
||||
Der Forecast-Schalter zeigt direkt an, ob fuer die Manager-ID eine passende
|
||||
Kombination `forecast_pv` plus `forecast_load` oder `grid_schedule` vorhanden ist. Nach der Aktivierung sendet der
|
||||
Manager PV-Leistung, berechneten Hausverbrauch, Netzleistung und Batterie-SOC
|
||||
mit UTC-Zeitstempel im eingestellten Sendeintervall. Dieses Intervall bestimmt
|
||||
also, wie oft aktuelle Messwerte zum Prognosedienst hochgeladen werden; es ist
|
||||
kein Regelintervall. Das Minimum von 60 Sekunden
|
||||
passt zum Rate-Limit des Lizenzportals. Die Uebertragung nutzt denselben
|
||||
widerrufbaren Installationszugang wie die Topologie; Lizenzcode und Geraet Token
|
||||
werden weder als Telemetriefelder noch im Debug-Log ausgegeben. Bei HTTP 401
|
||||
oder 403 verwirft der Manager den Geraetezugang und fordert ihn bei der naechsten
|
||||
Lizenzpruefung neu an.
|
||||
|
||||
Die Entscheidung und ihre Alternativen sind in
|
||||
[ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert.
|
||||
|
||||
## Lizenzierung
|
||||
|
||||
Der Manager arbeitet nur mit einer gueltigen Manager-Lizenz. Das Lizenzfeld steht
|
||||
zuoberst im Konfigurationsformular. Ohne Freigabe bleibt die Instanz mit Status
|
||||
`203` inaktiv und sendet keine Leistungsvorgaben.
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Der Auftrag im Enelix-Lizenzportal ist bezahlt und enthaelt eine aktive
|
||||
Manager-Berechtigung.
|
||||
- IP-Symcon erreicht `https://license.enelix.ch` ueber HTTPS (Port 443).
|
||||
- Der Lizenzcode liegt im Format `ENX-XXXX-XXXX-XXXX-XXXX` vor.
|
||||
|
||||
### Lizenz aktivieren
|
||||
|
||||
1. Manager-Konfiguration in IP-Symcon oeffnen.
|
||||
2. Lizenzcode im Bereich `Lizenzierung` eintragen.
|
||||
3. `Lizenz pruefen und binden` ausloesen.
|
||||
4. Die erfolgreiche Freigabe am angezeigten Lizenzstatus kontrollieren.
|
||||
5. Die Manager-Konfiguration mit `Uebernehmen` beziehungsweise `OK` speichern,
|
||||
damit der eingegebene Lizenzcode als Property erhalten bleibt.
|
||||
|
||||
Beim ersten Anlegen erzeugt die Manager-Instanz eine UUIDv4 als stabile
|
||||
Installations-ID. Die Aktivierung sendet ausschliesslich `code` und
|
||||
`installationId` per
|
||||
`POST https://license.enelix.ch/api/v1/licenses/activate`. Sie benoetigt weder
|
||||
Portal-Cookies noch einen CSRF-Token. Derselbe Code kann von derselben
|
||||
Installation erneut abgerufen werden; die Bindung an eine andere Installation
|
||||
wird vom Lizenzserver abgelehnt.
|
||||
|
||||
### Berechtigungen
|
||||
|
||||
| Berechtigung | Freigegebene Funktion |
|
||||
| --- | --- |
|
||||
| `manager_standard` | Manager-Grundregelung ohne Peak Shaving. |
|
||||
| `manager_peak` | Bezugs- und Einspeisebegrenzung am Netzanschlusspunkt. |
|
||||
| `forecast_pv` | PV-Ertragsprognose; interne Modellvarianten bleiben verborgen. |
|
||||
| `forecast_load` | Verbrauchsprognose; interne Modellvarianten bleiben verborgen. |
|
||||
| `grid_schedule` | Intelligenter Netzfahrplan inklusive PV- und Verbrauchsprognose sowie Vermeidung von Abregelung. |
|
||||
|
||||
Wird mit `manager_standard` ein Lastspitzenmodus aktiviert, bleibt der Manager
|
||||
mit dem Hinweis `Peak Shaving ist nicht lizenziert` gesperrt. `manager_peak`
|
||||
gilt zugleich als Berechtigung fuer die Grundregelung.
|
||||
|
||||
### Erneuerung und Offline-Betrieb
|
||||
|
||||
Die erfolgreiche Serverantwort wird als Lease in der Manager-Instanz gespeichert.
|
||||
Der Manager erneuert sie ab `refreshAfter` automatisch ueber denselben
|
||||
Aktivierungsendpunkt. Schlaegt eine Erneuerung fehl, wird fruehestens nach einer
|
||||
Stunde erneut angefragt. Eine bereits bestaetigte Entwicklungsfreigabe bleibt
|
||||
bis `offlineUntil` verwendbar. Der aktuelle Entwicklungsvertrag setzt diesen
|
||||
Zeitpunkt ungefaehr 14 Tage nach Ausstellung. Danach sperrt der Manager die
|
||||
Regelung, bis der Lizenzserver wieder eine gueltige Antwort liefert.
|
||||
|
||||
Die Installations-ID und die Lease liegen in internen Instanzattributen. Die
|
||||
ID wird erst nach dem Laden bestehender Attribute initialisiert und bleibt bei
|
||||
Modulupdates, Modul-Neuladen und einem normalen Neustart unveraendert. Bei einer
|
||||
Migration muss trotzdem die vollstaendige Manager-Instanz mitsamt ihren
|
||||
Attributen uebernommen werden. Eine neu erzeugte Instanz erhaelt eine andere
|
||||
Installations-ID und kann einen bereits gebundenen Code nicht selbststaendig
|
||||
uebertragen. Wurde die ID mit einer aelteren Manager-Version bereits ungewollt
|
||||
geaendert, muss im Lizenzportal einmalig ein Ersatzcode erzeugt und an die nun
|
||||
stabile ID gebunden werden.
|
||||
|
||||
### Status und Fehlerbehebung
|
||||
|
||||
| Anzeige / Serverstatus | Bedeutung und Massnahme |
|
||||
| --- | --- |
|
||||
| `Lizenzcode fehlt.` | Code eintragen, pruefen und die Konfiguration speichern. |
|
||||
| `Lizenzcode ist ungueltig.` | Format und Zeichen des Codes kontrollieren. |
|
||||
| HTTP `404` | Code unbekannt oder zugehoeriger Auftrag noch nicht bezahlt. |
|
||||
| HTTP `409` | Code ist bereits an eine andere Installation gebunden. |
|
||||
| HTTP `429` | Zu viele Aktivierungsversuche; vor dem naechsten Versuch warten. |
|
||||
| `Lizenzserver nicht erreichbar` | DNS, Internetzugang, HTTPS und Systemzeit des Symcon-Systems pruefen. Eine bestehende Lease gilt nur bis `offlineUntil`. |
|
||||
| `Offline-Freigabe ist abgelaufen.` | Verbindung zum Lizenzserver wiederherstellen und Lizenz erneut pruefen. |
|
||||
| `Keine Prognoseberechtigung vorhanden` | Im Lizenzportal entweder PV-Ertragsprognose zusammen mit Verbrauchsprognose oder den Intelligenten Netzfahrplan ergaenzen. Anschliessend im Manager `Lizenz pruefen und binden` ausloesen, die Prognose aktivieren und die Konfiguration speichern. |
|
||||
|
||||
Fuer eine genauere Diagnose koennen die Diagnosevariablen eingeblendet werden.
|
||||
`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 oder aktuell angebotlose Verbraucher behalten ihre Leistung
|
||||
und erhalten keine neue Vorgabe. Jeder verfügbare, synchronisierte Verbraucher
|
||||
mit einem nicht leeren Angebot erhält dagegen einen Sollwert aus diesem Angebot.
|
||||
Das gilt auch bei `AenderungMoeglich=false`: Ein fixes Angebot wie `[11000]`
|
||||
wird mit genau `11000 W` zugeteilt.
|
||||
|
||||
Die Verbraucher werden nach der gemeldeten PV- beziehungsweise Peak-Priorität
|
||||
und danach stabil nach Instanz-ID sortiert. Zuerst reserviert der Manager für
|
||||
jeden Verbraucher den kleinsten erlaubten Leistungswert. Nur das danach
|
||||
verbleibende Budget wird in Prioritätsreihenfolge aufgefüllt. Einzelwerte und
|
||||
ganzzahlige Bereiche werden direkt verarbeitet; Lücken werden nie durch
|
||||
unzulässige Werte geschlossen. Eine verbleibende Abweichung wird im
|
||||
`Verbraucherstatus` dokumentiert.
|
||||
|
||||
Die Betriebsart wechselt unterhalb des Solar-Sollwerts zu `PV` und oberhalb
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,28 @@
|
||||
# EMS-Module und Modulentwürfe
|
||||
|
||||
> Manager, Warmwassererwaermer, Pufferspeicher, Batterie, Verbraucher 1-Stufig,
|
||||
> Waermepumpe, Ladestation Stand-Alone, Easee Gateway und Ladestation Gateway
|
||||
> sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren Ordner
|
||||
> enthalten Besprechungsgrundlagen.
|
||||
|
||||
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
|
||||
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
|
||||
nur zusätzliche Properties, Variablen, Zustände und offene Punkte.
|
||||
|
||||
| Modul | Rolle |
|
||||
| --- | --- |
|
||||
| [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse (implementiert) |
|
||||
| [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers (implementiert) |
|
||||
| [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung (implementiert) |
|
||||
| [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve |
|
||||
| [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) |
|
||||
| [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready |
|
||||
| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung (implementiert) |
|
||||
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Eventbasierte Ladestation am Easee Gateway (implementiert) |
|
||||
| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation (implementiert) |
|
||||
|
||||
## Review-Regel
|
||||
|
||||
`0*` bezeichnet einen noch nicht eingerichteten Anlagenwert. Offene Punkte
|
||||
werden nicht durch Annahmen ersetzt. Erst nach Freigabe wird aus einem Entwurf
|
||||
ein Ordner mit `module.json`, `form.json`, `module.php` und Unit-Tests.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Standardisierte Tests
|
||||
|
||||
## Ziel
|
||||
|
||||
Dieses Repository verwendet zwei verbindliche Testebenen:
|
||||
|
||||
1. PHPUnit prüft reine PHP-Logik und Struktur bei jedem Push.
|
||||
2. Symcon-Modultests prüfen reale Instanzen, Variablen, Actions und Zusammenspiel
|
||||
in IP-Symcon 8.x.
|
||||
|
||||
Alle implementierten Module müssen in `tests/Symcon/manifest.php` eingetragen
|
||||
sein und ein eigenes Skript in `tests/Symcon/modules/` besitzen. Der
|
||||
PHPUnit-Test `SymconTestContractTest` erzwingt diese Regel auch für künftig
|
||||
hinzugefügte Module.
|
||||
|
||||
## Testvertrag
|
||||
|
||||
Ein Modultest gibt eine aufrufbare Funktion mit dieser Signatur zurück:
|
||||
|
||||
```php
|
||||
use Belevo\EnelixEMS\SymconTest\TestContext;
|
||||
|
||||
return static function (TestContext $test): void {
|
||||
$test->runCase('Beschreibung', static function (TestContext $test): void {
|
||||
// Instanz aufbauen, Eingang simulieren und Ergebnis prüfen.
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Das Framework erzeugt für jeden Modultest eine eindeutige Kategorie unterhalb
|
||||
der Objektwurzel. Instanzen und Hilfsobjekte werden ausschließlich dort
|
||||
angelegt. Der Runner entfernt den vollständigen Baum in einem `finally`-Pfad.
|
||||
Ein fehlgeschlagener Cleanup macht den Gesamtlauf rot.
|
||||
|
||||
Tests dürfen keine vorhandenen Objekte verändern oder anhand ihres Namens
|
||||
löschen. Globale Variablenprofile müssen nur dann registriert und entfernt
|
||||
werden, wenn sie vor dem Lauf nicht existierten.
|
||||
|
||||
## Modi
|
||||
|
||||
- `all`: alle registrierten Module
|
||||
- `single`: genau die als Auswahl übergebenen Module
|
||||
- `affected`: die durch geänderte Pfade ermittelten Module
|
||||
|
||||
Verfuegbare Module: `Batterie`, `EaseeGateway`, `LadestationGateway`,
|
||||
`LadestationStandAlone`, `Manager`, `Pufferspeicher`, `VerbraucherEinStufig`,
|
||||
`Waermepumpe` und `Warmwassererwaermer`.
|
||||
|
||||
Der Manager-Test enthält Manager ohne Verbraucher, jeden Verbrauchertyp einzeln und alle aktuell implementierten Verbrauchertypen gemeinsam.
|
||||
|
||||
## Manuelle Ausführung in IP-Symcon
|
||||
|
||||
Das Repository muss über die Modulverwaltung installiert und auf dem zu
|
||||
prüfenden Stand sein. In der Schnellausführung:
|
||||
|
||||
```php
|
||||
require_once IPS_GetKernelDir() . 'modules/Enelix-EMS/tests/Symcon/bootstrap.php';
|
||||
|
||||
$result = enelixEmsRunSymconTests('all');
|
||||
echo $result['console'];
|
||||
```
|
||||
|
||||
Ein Einzeltest wird beispielsweise mit
|
||||
`enelixEmsRunSymconTests('single', 'Manager')` gestartet.
|
||||
|
||||
Auf dem Agent-Server kann derselbe Lauf über JSON-RPC ausgeführt werden:
|
||||
|
||||
```bash
|
||||
tests/Symcon/bin/run-symcon-tests.sh all
|
||||
tests/Symcon/bin/run-symcon-tests.sh single Manager
|
||||
```
|
||||
|
||||
Die URL kann ausschließlich zur Laufzeit über `ENELIX_SYMCON_URL` gesetzt
|
||||
werden. Zugangsdaten gehören nicht in Repository, Skripte oder Logs.
|
||||
|
||||
## Berichte
|
||||
|
||||
Jeder Lauf erzeugt:
|
||||
|
||||
- eine kurze Konsolenzusammenfassung,
|
||||
- `build/symcon-tests/report.json` für Diagnose und Archivierung,
|
||||
- `build/symcon-tests/junit.xml` für CI-Auswertung.
|
||||
|
||||
Zusätzlich schreibt der Runner die Zusammenfassung in das IP-Symcon-Log.
|
||||
|
||||
## CI-Regeln
|
||||
|
||||
Bei jedem Push laufen sämtliche PHPUnit-Tests und die PHP-Syntaxprüfung. Die
|
||||
Symcon-Modultests werden bis zur Verfügbarkeit eines geschützten Runners auf
|
||||
der isolierten IP-Symcon-8.0-Instanz des Agent-Servers ausgeführt. Vor jeder
|
||||
Übernahme nach `beta` ist ein erfolgreicher Lauf im Modus `all` verbindlich.
|
||||
Der erzeugte JSON- und JUnit-Bericht gehört zum Freigabenachweis.
|
||||
|
||||
Eine spätere CI-Automatisierung benötigt einen geschützten Runner mit dem Label
|
||||
`symcon-8`, lokalem Zugriff auf die isolierte IP-Symcon-Instanz und den exakt
|
||||
zu prüfenden Repository-Stand.
|
||||
|
||||
## Checkliste für neue Module
|
||||
|
||||
1. PHPUnit-Tests für die reine Logik ergänzen.
|
||||
2. `tests/Symcon/modules/<Modul>.php` hinzufügen.
|
||||
3. Modul und betroffene Pfade in `tests/Symcon/manifest.php` registrieren.
|
||||
4. Instanz, Pflichtvariablen, Actions, Normalfall und mindestens einen
|
||||
Fehler- oder Grenzfall prüfen.
|
||||
5. Alle Hilfsobjekte über `TestContext` anlegen.
|
||||
6. `composer check`, Einzeltest und Gesamttest erfolgreich ausführen.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Enelix-2-Demoanlage
|
||||
|
||||
Die Demo erzeugt idempotent eine vollständige, spielbare EMS-Anlage in IP-Symcon 8.x:
|
||||
|
||||
- Enelix Manager mit manueller Zuordnung von fünf steuerbaren Teilnehmern,
|
||||
- zwei mehrstufige Boiler (1,2/2,4 kW und 1,5/3,0 kW),
|
||||
- einen einstufigen Entfeuchter (0,9 kW),
|
||||
- einen zweistufigen Pufferspeicher (1,8/3,6 kW) mit Heizkurve,
|
||||
- einen bidirektionalen Batteriespeicher (10 kWh, +/-3,5 kW),
|
||||
- eine technische Anlagentopologie mit Hybridwechselrichter, PV-Fläche und
|
||||
Batteriespeicher für die Prognoseanbindung,
|
||||
- simulierte Istleistungs- und Leistungsbegrenzungsregister des Wechselrichters,
|
||||
- eine aktive anlagenweite Einspeisebegrenzung auf 3.000 W mit 100 W Toleranz,
|
||||
- Prognosetelemetrie aus PV, Hausverbrauch, Netzleistung und Batterie-SOC im
|
||||
60-Sekunden-Intervall,
|
||||
- simulierte PV-Erzeugung, variable Bewölkung, Tageslastgang, Zusatzlast,
|
||||
Außentemperatur, Wärmeverluste, Netzleistung und Speicherzustände,
|
||||
- native Symcon-Instanz `Energy Distribution` für acht Energieflussknoten,
|
||||
- eigene Kachelansichten mit Laufzeitparametern für Boiler, Puffer und Batterie,
|
||||
- kombinierten Verlauf für PV-Leistung, Hausverbrauch, EMS-Leistung und Netzleistung,
|
||||
- kombinierten Verlauf der kumulierten PV-, Haus-, Bezugs- und Einspeiseenergie,
|
||||
- Archivierung der Leistungs-, Energie- und Temperaturwerte.
|
||||
|
||||
## Installation
|
||||
|
||||
Das Repository muss in der IP-Symcon-Modulverwaltung auf dem gewünschten Stand installiert sein.
|
||||
Danach in der Schnellausführung starten:
|
||||
|
||||
```php
|
||||
require_once IPS_GetKernelDir() . 'modules/Enelix-EMS/examples/Demoanlage/bootstrap.php';
|
||||
|
||||
$result = enelixDemoInstall();
|
||||
print_r($result);
|
||||
```
|
||||
|
||||
Ein weiterer Lauf aktualisiert dieselbe Anlage anhand stabiler Idents und erzeugt keine Duplikate.
|
||||
Bestehende, nicht zur Demo gehörende Objekte werden nicht verändert oder gelöscht.
|
||||
|
||||
Der Bootstrap hinterlegt absichtlich keinen Lizenzcode und überschreibt eine
|
||||
bereits konfigurierte Manager-Lizenz nicht. Bei einer neuen Anlage wird die
|
||||
Struktur vollständig erstellt; der Manager bleibt bis zur regulären
|
||||
Lizenzaktivierung im Konfigurationsformular auf Status 203. Solange der Manager
|
||||
nicht freigegeben ist, bleiben seine Zuordnung und die Verbraucher deaktiviert,
|
||||
damit keine Lizenzfehler protokolliert werden. Nach der Aktivierung den
|
||||
Bootstrap erneut ausführen; er ordnet dann alle fünf Teilnehmer zu und startet
|
||||
die Regelung.
|
||||
|
||||
Die Demo übernimmt die einstellbare PV-Spitzenleistung als AC- und
|
||||
DC-Anlagenleistung. Für die feste Beispieltopologie gelten 30 Grad Neigung und
|
||||
Südausrichtung. Der 10-kWh-Speicher nutzt den gemeinsamen Hybridwechselrichter;
|
||||
seine dynamischen Lade- und Entladegrenzen werden beim Bootstrap in die
|
||||
technischen Stammdaten übernommen. Bei aktiver Manager-Lizenz uebertraegt der
|
||||
Manager zusaetzlich PV-Leistung, Hausverbrauch, Netzleistung und Batterie-SOC
|
||||
alle 60 Sekunden an den Enelix-Prognosedienst. Die Demo verwendet ein absolutes
|
||||
Wechselrichter-Leistungsregister in Watt. Der Manager begrenzt die tatsächliche
|
||||
PV-Produktion so, dass die Gesamtanlage höchstens 3.000 W einspeist; innerhalb
|
||||
einer Toleranz von 100 W bleibt der zuletzt gesetzte Stellwert bestehen. Der
|
||||
intelligente Netzfahrplan bleibt deaktiviert, solange dessen Backendfassung nur
|
||||
als ausstehende Version bereitliegt.
|
||||
|
||||
## Bedienung
|
||||
|
||||
In der Kachelvisualisierung öffnet die Startkategorie `Enelix 2 Demoanlage`.
|
||||
Unter `Simulation` lassen sich Tageslauf, Tageszeit, Geschwindigkeit,
|
||||
dynamische Profile, PV-Spitzenleistung, mittlere Bewölkung, Haus-Grundlast und
|
||||
eine ungeregelte Zusatzlast verändern. Bei aktivierten dynamischen Profilen
|
||||
entstehen reproduzierbare Morgen- und Abendspitzen, ziehende Bewölkung und ein
|
||||
Tagesgang der Außentemperatur. Der Manager verteilt den verfügbaren
|
||||
PV-Überschuss nach Priorität auf beide Boiler, Entfeuchter, Pufferspeicher und
|
||||
Batterie. Bei hoher PV-Erzeugung ist die Abregelung an den technischen Variablen
|
||||
`PV-Wechselrichter Istleistung` und `PV-Wechselrichter Leistungsgrenze` sowie an
|
||||
der auf etwa 3.000 W begrenzten Netzeinspeisung sichtbar.
|
||||
|
||||
`Übersicht` zeigt die native Symcon-Energieverteilung. Die Kacheln `Boiler 1`,
|
||||
`Boiler 2`, `Pufferspeicher` und `Batteriespeicher` öffnen direkt die jeweiligen
|
||||
Bedien-, Status- und Diagnosevariablen, ohne zuerst das
|
||||
Instanz-Konfigurationsformular zu zeigen. Positive Batterieleistung bedeutet
|
||||
Laden, negative Leistung Entladen. Unter `Leistung und Energie` liegen die
|
||||
beiden kombinierten Zeitdiagramme und die Energiezähler. Die Energiesummen,
|
||||
Temperaturmodelle und der Ladezustand verwenden die beschleunigte
|
||||
Simulationszeit.
|
||||
|
||||
## Validierung
|
||||
|
||||
- Die fünf Verbraucher müssen Status 102 besitzen; der Manager nach Lizenzaktivierung ebenfalls (vorher erwarteter Status 203).
|
||||
- Der Manager muss fünf Teilnehmer aus vier unterschiedlichen Modultypen melden.
|
||||
- Bei ausreichendem PV-Überschuss müssen thermische Verbraucher schalten und die Batterie laden.
|
||||
- Bei höherem verbleibendem PV-Überschuss muss das Wechselrichterregister die PV-Leistung so begrenzen, dass ungefähr 3.000 W ins Netz eingespeist werden.
|
||||
- Wechselrichter-Istleistung und Leistungsgrenze müssen in der technischen Kategorie vorhanden und im Manager zugeordnet sein.
|
||||
- Puffertemperatur, Außentemperatur und Batterie-Ladezustand müssen sich über den Tageslauf verändern.
|
||||
- Netzleistung, PV-Leistung, Hausverbrauch, EMS-Leistung, Einzelverbräuche, Speicherleistung, Energiesummen und Temperaturen werden archiviert.
|
||||
- Die native Energieverteilung muss acht Knoten enthalten.
|
||||
- Beide Diagrammkacheln müssen mindestens drei beschriftete Zeitreihen anzeigen.
|
||||
- Ein erneuter Installationslauf muss dieselbe Root-ID zurückgeben.
|
||||
|
||||
## Rollback
|
||||
|
||||
Nur die eindeutig gekennzeichnete Demo wird entfernt:
|
||||
|
||||
```php
|
||||
require_once IPS_GetKernelDir() . 'modules/Enelix-EMS/examples/Demoanlage/bootstrap.php';
|
||||
|
||||
enelixDemoRemove(true);
|
||||
```
|
||||
|
||||
Der boolesche Parameter ist eine bewusste Löschbestätigung. Fremde Objektbäume
|
||||
und Systeminstanzen bleiben unberührt.
|
||||
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"id": "{E0185158-0DA9-4D44-BAA7-B06D3D786276}",
|
||||
"name": "Enelix EMS",
|
||||
"author": "Belevo AG",
|
||||
"author": "Enelix",
|
||||
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS",
|
||||
"compatibility": {
|
||||
"version": "8.0"
|
||||
},
|
||||
"version": "0.1",
|
||||
"build": 0,
|
||||
"date": 0
|
||||
"build": 1,
|
||||
"date": 1790662541
|
||||
}
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use JsonException;
|
||||
|
||||
final class Anlagentopologie
|
||||
{
|
||||
public const VERSION = '1.0';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
* @throws JsonException
|
||||
*/
|
||||
public static function ausJson(
|
||||
string $wechselrichterJson,
|
||||
string $pvFlaechenJson,
|
||||
string $batterienJson,
|
||||
string $installationID
|
||||
): array {
|
||||
$wechselrichter = self::dekodiereListe($wechselrichterJson, 'AnlagenWechselrichter');
|
||||
$pvFlaechen = self::dekodiereListe($pvFlaechenJson, 'AnlagenPVFlaechen');
|
||||
$batterien = self::dekodiereListe($batterienJson, 'AnlagenBatterien');
|
||||
|
||||
$inverters = [];
|
||||
$inverterById = [];
|
||||
foreach ($wechselrichter as $index => $eintrag) {
|
||||
$pfad = 'Wechselrichter ' . ($index + 1);
|
||||
$id = self::kennung($eintrag['ID'] ?? null, $pfad . ': ID');
|
||||
if (isset($inverterById[$id])) {
|
||||
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
|
||||
}
|
||||
$typ = self::auswahl(
|
||||
$eintrag['Typ'] ?? null,
|
||||
['pv', 'battery', 'hybrid'],
|
||||
$pfad . ': Typ'
|
||||
);
|
||||
$acRatedKw = self::positiveZahl(
|
||||
$eintrag['ACNennleistung_kW'] ?? null,
|
||||
$pfad . ': AC-Nennleistung'
|
||||
);
|
||||
$inverter = [
|
||||
'id' => $id,
|
||||
'name' => self::name($eintrag['Name'] ?? null, $id),
|
||||
'type' => $typ,
|
||||
'acRatedKw' => $acRatedKw,
|
||||
];
|
||||
$inverters[] = $inverter;
|
||||
$inverterById[$id] = $inverter;
|
||||
}
|
||||
|
||||
$pvArrays = [];
|
||||
$pvIds = [];
|
||||
foreach ($pvFlaechen as $index => $eintrag) {
|
||||
$pfad = 'PV-Flaeche ' . ($index + 1);
|
||||
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $pvIds, $pfad);
|
||||
$inverterId = self::kennung(
|
||||
$eintrag['WechselrichterID'] ?? null,
|
||||
$pfad . ': Wechselrichter-ID'
|
||||
);
|
||||
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
|
||||
if (!in_array($inverter['type'], ['pv', 'hybrid'], true)) {
|
||||
throw new InvalidArgumentException(
|
||||
$pfad . ': benoetigt einen PV- oder Hybridwechselrichter.'
|
||||
);
|
||||
}
|
||||
$moduleCount = self::optionalePositiveGanzzahl(
|
||||
$eintrag['Modulanzahl'] ?? null,
|
||||
$pfad . ': Modulanzahl'
|
||||
);
|
||||
$modulePowerWp = self::optionalePositiveZahl(
|
||||
$eintrag['Modulleistung_Wp'] ?? null,
|
||||
$pfad . ': Modulleistung'
|
||||
);
|
||||
if (($moduleCount === null) !== ($modulePowerWp === null)) {
|
||||
throw new InvalidArgumentException(
|
||||
$pfad . ': Modulanzahl und Modulleistung muessen gemeinsam gepflegt werden.'
|
||||
);
|
||||
}
|
||||
$pvArrays[] = [
|
||||
'id' => $id,
|
||||
'name' => self::name($eintrag['Name'] ?? null, $id),
|
||||
'dcKwp' => self::positiveZahl(
|
||||
$eintrag['DCLeistung_kWp'] ?? null,
|
||||
$pfad . ': DC-Leistung'
|
||||
),
|
||||
'tiltDeg' => self::zahlImBereich(
|
||||
$eintrag['Neigung_Grad'] ?? null,
|
||||
0.0,
|
||||
90.0,
|
||||
$pfad . ': Neigung'
|
||||
),
|
||||
'azimuthDeg' => self::zahlImBereich(
|
||||
$eintrag['Azimut_Grad'] ?? null,
|
||||
-180.0,
|
||||
180.0,
|
||||
$pfad . ': Azimut'
|
||||
),
|
||||
'inverterId' => $inverterId,
|
||||
'mpptInput' => self::optionalerText($eintrag['MPPT'] ?? null),
|
||||
'moduleCount' => $moduleCount,
|
||||
'modulePowerWp' => $modulePowerWp,
|
||||
];
|
||||
}
|
||||
|
||||
$batteryItems = [];
|
||||
$batteryIds = [];
|
||||
foreach ($batterien as $index => $eintrag) {
|
||||
$pfad = 'Batterie ' . ($index + 1);
|
||||
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $batteryIds, $pfad);
|
||||
$inverterId = self::kennung(
|
||||
$eintrag['WechselrichterID'] ?? null,
|
||||
$pfad . ': Wechselrichter-ID'
|
||||
);
|
||||
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
|
||||
$kopplung = self::auswahl(
|
||||
$eintrag['Kopplung'] ?? null,
|
||||
['ac', 'dc', 'hybrid'],
|
||||
$pfad . ': Kopplung'
|
||||
);
|
||||
if ($kopplung === 'ac' && !in_array($inverter['type'], ['battery', 'hybrid'], true)) {
|
||||
throw new InvalidArgumentException(
|
||||
$pfad . ': AC-Kopplung benoetigt einen Batterie- oder Hybridwechselrichter.'
|
||||
);
|
||||
}
|
||||
if ($kopplung !== 'ac' && $inverter['type'] !== 'hybrid') {
|
||||
throw new InvalidArgumentException(
|
||||
$pfad . ': DC- und Hybridkopplung benoetigen einen Hybridwechselrichter.'
|
||||
);
|
||||
}
|
||||
$nominalCapacityKwh = self::positiveZahl(
|
||||
$eintrag['Nennkapazitaet_kWh'] ?? null,
|
||||
$pfad . ': Nennkapazitaet'
|
||||
);
|
||||
$usableCapacityKwh = self::positiveZahl(
|
||||
$eintrag['Nutzkapazitaet_kWh'] ?? null,
|
||||
$pfad . ': Nutzkapazitaet'
|
||||
);
|
||||
if ($usableCapacityKwh > $nominalCapacityKwh) {
|
||||
throw new InvalidArgumentException(
|
||||
$pfad . ': Nutzkapazitaet darf die Nennkapazitaet nicht uebersteigen.'
|
||||
);
|
||||
}
|
||||
$batteryItems[] = [
|
||||
'id' => $id,
|
||||
'name' => self::name($eintrag['Name'] ?? null, $id),
|
||||
'nominalCapacityKwh' => $nominalCapacityKwh,
|
||||
'usableCapacityKwh' => $usableCapacityKwh,
|
||||
'maxChargeKw' => self::positiveZahl(
|
||||
$eintrag['MaxLadeleistung_kW'] ?? null,
|
||||
$pfad . ': maximale Ladeleistung'
|
||||
),
|
||||
'maxDischargeKw' => self::positiveZahl(
|
||||
$eintrag['MaxEntladeleistung_kW'] ?? null,
|
||||
$pfad . ': maximale Entladeleistung'
|
||||
),
|
||||
'inverterId' => $inverterId,
|
||||
'coupling' => $kopplung,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => self::VERSION,
|
||||
'installationId' => trim($installationID),
|
||||
'inverters' => $inverters,
|
||||
'pvArrays' => $pvArrays,
|
||||
'batteries' => $batteryItems,
|
||||
'sharedAcLimits' => self::gemeinsameAcGrenzen(
|
||||
$inverters,
|
||||
$pvArrays,
|
||||
$batteryItems
|
||||
),
|
||||
'summary' => self::zusammenfassung($inverters, $pvArrays, $batteryItems),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
private static function dekodiereListe(string $json, string $name): array
|
||||
{
|
||||
$daten = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($daten)
|
||||
|| ($daten !== [] && array_keys($daten) !== range(0, count($daten) - 1))
|
||||
) {
|
||||
throw new InvalidArgumentException($name . ' muss eine JSON-Liste sein.');
|
||||
}
|
||||
foreach ($daten as $eintrag) {
|
||||
if (!is_array($eintrag)) {
|
||||
throw new InvalidArgumentException($name . ' enthaelt einen ungueltigen Eintrag.');
|
||||
}
|
||||
}
|
||||
|
||||
return $daten;
|
||||
}
|
||||
|
||||
/** @param array<string, bool> $verwendet */
|
||||
private static function eindeutigeKennung($wert, array &$verwendet, string $pfad): string
|
||||
{
|
||||
$id = self::kennung($wert, $pfad . ': ID');
|
||||
if (isset($verwendet[$id])) {
|
||||
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
|
||||
}
|
||||
$verwendet[$id] = true;
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private static function kennung($wert, string $feld): string
|
||||
{
|
||||
if (!is_string($wert)
|
||||
|| preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', trim($wert)) !== 1
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
$feld . ' muss 1 bis 64 Zeichen aus Buchstaben, Zahlen, Punkt, Minus oder Unterstrich enthalten.'
|
||||
);
|
||||
}
|
||||
|
||||
return trim($wert);
|
||||
}
|
||||
|
||||
private static function name($wert, string $fallback): string
|
||||
{
|
||||
if ($wert === null || $wert === '') {
|
||||
return $fallback;
|
||||
}
|
||||
if (!is_string($wert) || strlen(trim($wert)) > 120) {
|
||||
throw new InvalidArgumentException('Name muss ein Text mit maximal 120 Zeichen sein.');
|
||||
}
|
||||
|
||||
return trim($wert);
|
||||
}
|
||||
|
||||
/** @param list<string> $werte */
|
||||
private static function auswahl($wert, array $werte, string $feld): string
|
||||
{
|
||||
if (!is_string($wert) || !in_array($wert, $werte, true)) {
|
||||
throw new InvalidArgumentException($feld . ' ist ungueltig.');
|
||||
}
|
||||
|
||||
return $wert;
|
||||
}
|
||||
|
||||
private static function positiveZahl($wert, string $feld): float
|
||||
{
|
||||
if (!is_int($wert) && !is_float($wert)) {
|
||||
throw new InvalidArgumentException($feld . ' muss eine Zahl sein.');
|
||||
}
|
||||
if (!is_finite((float) $wert) || (float) $wert <= 0.0) {
|
||||
throw new InvalidArgumentException($feld . ' muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
return (float) $wert;
|
||||
}
|
||||
|
||||
private static function optionalePositiveZahl($wert, string $feld): ?float
|
||||
{
|
||||
if ($wert === null || $wert === '' || $wert === 0 || $wert === 0.0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::positiveZahl($wert, $feld);
|
||||
}
|
||||
|
||||
private static function optionalePositiveGanzzahl($wert, string $feld): ?int
|
||||
{
|
||||
if ($wert === null || $wert === '' || $wert === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!is_int($wert) || $wert <= 0) {
|
||||
throw new InvalidArgumentException($feld . ' muss eine positive Ganzzahl sein.');
|
||||
}
|
||||
|
||||
return $wert;
|
||||
}
|
||||
|
||||
private static function zahlImBereich(
|
||||
$wert,
|
||||
float $minimum,
|
||||
float $maximum,
|
||||
string $feld
|
||||
): float {
|
||||
if ((!is_int($wert) && !is_float($wert))
|
||||
|| !is_finite((float) $wert)
|
||||
|| (float) $wert < $minimum
|
||||
|| (float) $wert > $maximum
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf('%s muss zwischen %g und %g liegen.', $feld, $minimum, $maximum)
|
||||
);
|
||||
}
|
||||
|
||||
return (float) $wert;
|
||||
}
|
||||
|
||||
private static function optionalerText($wert): ?string
|
||||
{
|
||||
if ($wert === null || $wert === '') {
|
||||
return null;
|
||||
}
|
||||
if (!is_string($wert) || strlen(trim($wert)) > 64) {
|
||||
throw new InvalidArgumentException('MPPT muss ein Text mit maximal 64 Zeichen sein.');
|
||||
}
|
||||
|
||||
return trim($wert);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $wechselrichter
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function wechselrichter(array $wechselrichter, string $id, string $pfad): array
|
||||
{
|
||||
if (!isset($wechselrichter[$id])) {
|
||||
throw new InvalidArgumentException(
|
||||
$pfad . ': referenzierter Wechselrichter ist nicht vorhanden.'
|
||||
);
|
||||
}
|
||||
|
||||
return $wechselrichter[$id];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $inverters
|
||||
* @param list<array<string, mixed>> $pvArrays
|
||||
* @param list<array<string, mixed>> $batteries
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function gemeinsameAcGrenzen(
|
||||
array $inverters,
|
||||
array $pvArrays,
|
||||
array $batteries
|
||||
): array {
|
||||
$result = [];
|
||||
foreach ($inverters as $inverter) {
|
||||
if ($inverter['type'] !== 'hybrid') {
|
||||
continue;
|
||||
}
|
||||
$pvIds = [];
|
||||
foreach ($pvArrays as $pvArray) {
|
||||
if ($pvArray['inverterId'] === $inverter['id']) {
|
||||
$pvIds[] = $pvArray['id'];
|
||||
}
|
||||
}
|
||||
$batteryIds = [];
|
||||
foreach ($batteries as $battery) {
|
||||
if ($battery['inverterId'] === $inverter['id']) {
|
||||
$batteryIds[] = $battery['id'];
|
||||
}
|
||||
}
|
||||
if ($pvIds === [] && $batteryIds === []) {
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'inverterId' => $inverter['id'],
|
||||
'acRatedKw' => $inverter['acRatedKw'],
|
||||
'pvArrayIds' => $pvIds,
|
||||
'batteryIds' => $batteryIds,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $inverters
|
||||
* @param list<array<string, mixed>> $pvArrays
|
||||
* @param list<array<string, mixed>> $batteries
|
||||
* @return array<string, float>
|
||||
*/
|
||||
private static function zusammenfassung(
|
||||
array $inverters,
|
||||
array $pvArrays,
|
||||
array $batteries
|
||||
): array {
|
||||
$pvInverterIds = [];
|
||||
$pvDcKwp = 0.0;
|
||||
foreach ($pvArrays as $pvArray) {
|
||||
$pvDcKwp += $pvArray['dcKwp'];
|
||||
$pvInverterIds[$pvArray['inverterId']] = true;
|
||||
}
|
||||
$pvAcKw = 0.0;
|
||||
foreach ($inverters as $inverter) {
|
||||
if (isset($pvInverterIds[$inverter['id']])) {
|
||||
$pvAcKw += $inverter['acRatedKw'];
|
||||
}
|
||||
}
|
||||
$usableBatteryKwh = 0.0;
|
||||
$maxBatteryChargeKw = 0.0;
|
||||
$maxBatteryDischargeKw = 0.0;
|
||||
foreach ($batteries as $battery) {
|
||||
$usableBatteryKwh += $battery['usableCapacityKwh'];
|
||||
$maxBatteryChargeKw += $battery['maxChargeKw'];
|
||||
$maxBatteryDischargeKw += $battery['maxDischargeKw'];
|
||||
}
|
||||
|
||||
return [
|
||||
'pvDcKwp' => $pvDcKwp,
|
||||
'pvAcKw' => $pvAcKw,
|
||||
'usableBatteryKwh' => $usableBatteryKwh,
|
||||
'maxBatteryChargeKw' => $maxBatteryChargeKw,
|
||||
'maxBatteryDischargeKw' => $maxBatteryDischargeKw,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class BatterieRegler
|
||||
{
|
||||
public const TYP_UNKONFIGURIERT = 0;
|
||||
public const TYP_HERSTELLERUNABHAENGIG = 1;
|
||||
public const TYP_GOODWE = 2;
|
||||
public const TYP_SOLAREDGE = 3;
|
||||
public const TYP_SIGENERGY = 4;
|
||||
|
||||
public const STEUERUNG_WECHSELRICHTER = 1;
|
||||
public const STEUERUNG_ENELIX = 2;
|
||||
|
||||
public static function hystereseAktiv(
|
||||
float $ladezustand,
|
||||
float $reserve,
|
||||
float $hysterese,
|
||||
bool $bisherAktiv
|
||||
): bool {
|
||||
self::pruefeLadezustandsgrenzen($reserve, $hysterese);
|
||||
|
||||
if ($ladezustand > $reserve + $hysterese) {
|
||||
return false;
|
||||
}
|
||||
if ($ladezustand <= $reserve) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $bisherAktiv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet das zustands- und betriebsartabhaengige Angebot als
|
||||
* durchgaengige Leistungsbereiche in ganzen Watt ab.
|
||||
*
|
||||
* @return list<int|array{Von_W: int, Bis_W: int}>
|
||||
*/
|
||||
public static function leistungsangebot(
|
||||
bool $aktiv,
|
||||
bool $enelixSteuert,
|
||||
bool $peakbetrieb,
|
||||
float $ladezustand,
|
||||
float $reserve,
|
||||
float $minimum,
|
||||
bool $hystereseAktiv,
|
||||
float $netzleistungW,
|
||||
float $istleistungW,
|
||||
float $maxLadenW,
|
||||
float $maxEntladenW
|
||||
): array {
|
||||
self::pruefeLadezustandsgrenzen($reserve, 0.0);
|
||||
if ($minimum < 0.0 || $minimum > 100.0 || $minimum > $reserve) {
|
||||
throw new InvalidArgumentException(
|
||||
'Mindestladezustand muss zwischen 0 und der Reserve liegen.'
|
||||
);
|
||||
}
|
||||
self::pruefeLeistungsgrenzen($maxLadenW, $maxEntladenW);
|
||||
|
||||
if (!$aktiv || !$enelixSteuert) {
|
||||
return [0];
|
||||
}
|
||||
|
||||
$maxLaden = (int) floor($maxLadenW);
|
||||
$maxEntladen = (int) floor($maxEntladenW);
|
||||
if ($peakbetrieb) {
|
||||
if ($ladezustand > $reserve && !$hystereseAktiv) {
|
||||
$ziel = $netzleistungW > 0.0
|
||||
? -min($netzleistungW, $maxEntladenW) + $istleistungW
|
||||
: $netzleistungW;
|
||||
|
||||
return [self::begrenzeLeistung($ziel, $maxLadenW, $maxEntladenW)];
|
||||
}
|
||||
if ($ladezustand > $reserve && $hystereseAktiv) {
|
||||
return self::leistungsbereich(-$maxEntladen, 0);
|
||||
}
|
||||
if ($ladezustand > $minimum) {
|
||||
return self::leistungsbereich(-$maxEntladen, $maxLaden);
|
||||
}
|
||||
|
||||
return self::leistungsbereich(0, $maxLaden);
|
||||
}
|
||||
|
||||
if ($ladezustand > 99.0) {
|
||||
return self::leistungsbereich(-$maxEntladen, 0);
|
||||
}
|
||||
if ($ladezustand > $reserve && !$hystereseAktiv) {
|
||||
return self::leistungsbereich(-$maxEntladen, $maxLaden);
|
||||
}
|
||||
if ($ladezustand >= $reserve && $hystereseAktiv) {
|
||||
return self::leistungsbereich(0, $maxLaden);
|
||||
}
|
||||
|
||||
return [$maxLaden];
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert nur die Registerwerte, die der gewaehlte Batterietyp benoetigt.
|
||||
*
|
||||
* @return array<string, int|float>
|
||||
*/
|
||||
public static function registerwerte(
|
||||
int $batterietyp,
|
||||
int $steuerung,
|
||||
int $sollleistungW
|
||||
): array {
|
||||
self::pruefeBatterietyp($batterietyp);
|
||||
self::pruefeSteuerung($steuerung);
|
||||
|
||||
$management = self::managementCode($batterietyp, $steuerung);
|
||||
if ($steuerung === self::STEUERUNG_WECHSELRICHTER) {
|
||||
if ($batterietyp === self::TYP_GOODWE) {
|
||||
return ['Leistung' => 0, 'Management' => $management];
|
||||
}
|
||||
|
||||
return [
|
||||
'Ladeleistung' => 0,
|
||||
'Entladeleistung' => 0,
|
||||
'Management' => $management,
|
||||
];
|
||||
}
|
||||
|
||||
$betrag = abs($sollleistungW);
|
||||
if ($batterietyp === self::TYP_HERSTELLERUNABHAENGIG) {
|
||||
return [
|
||||
'Ladeleistung' => $sollleistungW >= 0 ? $betrag : 0,
|
||||
'Entladeleistung' => $sollleistungW < 0 ? $betrag : 0,
|
||||
'Modus' => $sollleistungW >= 0 ? 0 : 1,
|
||||
'Management' => $management,
|
||||
];
|
||||
}
|
||||
if ($batterietyp === self::TYP_GOODWE) {
|
||||
return [
|
||||
'Leistung' => $betrag,
|
||||
'Modus' => $sollleistungW >= 0 ? 11 : 12,
|
||||
'Management' => $management,
|
||||
];
|
||||
}
|
||||
if ($batterietyp === self::TYP_SOLAREDGE) {
|
||||
return [
|
||||
'Ladeleistung' => $sollleistungW >= 0 ? $betrag : 0,
|
||||
'Entladeleistung' => $sollleistungW < 0 ? $betrag : 0,
|
||||
'Modus' => $sollleistungW >= 0 ? 3 : 4,
|
||||
'Management' => $management,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'Ladeleistung' => $sollleistungW >= 0 ? $betrag / 1000.0 : 0.0,
|
||||
'Entladeleistung' => $sollleistungW < 0 ? $betrag / 1000.0 : 0.0,
|
||||
'Modus' => $sollleistungW >= 0 ? 3 : 6,
|
||||
'Management' => $management,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function erforderlicheRegister(int $batterietyp): array
|
||||
{
|
||||
self::pruefeBatterietyp($batterietyp);
|
||||
|
||||
if ($batterietyp === self::TYP_GOODWE) {
|
||||
return ['Management', 'Modus', 'Leistung'];
|
||||
}
|
||||
|
||||
return ['Management', 'Modus', 'Ladeleistung', 'Entladeleistung'];
|
||||
}
|
||||
|
||||
private static function managementCode(int $batterietyp, int $steuerung): int
|
||||
{
|
||||
if ($steuerung === self::STEUERUNG_WECHSELRICHTER) {
|
||||
return in_array(
|
||||
$batterietyp,
|
||||
[self::TYP_GOODWE, self::TYP_SOLAREDGE],
|
||||
true
|
||||
) ? 1 : 0;
|
||||
}
|
||||
|
||||
if ($batterietyp === self::TYP_SOLAREDGE) {
|
||||
return 4;
|
||||
}
|
||||
if ($batterietyp === self::TYP_GOODWE) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** @return list<int|array{Von_W: int, Bis_W: int}> */
|
||||
private static function leistungsbereich(int $vonW, int $bisW): array
|
||||
{
|
||||
if ($vonW === $bisW) {
|
||||
return [$vonW];
|
||||
}
|
||||
|
||||
return [['Von_W' => $vonW, 'Bis_W' => $bisW]];
|
||||
}
|
||||
|
||||
private static function begrenzeLeistung(
|
||||
float $leistungW,
|
||||
float $maxLadenW,
|
||||
float $maxEntladenW
|
||||
): int {
|
||||
$maxLaden = (int) floor($maxLadenW);
|
||||
$maxEntladen = (int) floor($maxEntladenW);
|
||||
|
||||
return (int) round(min($maxLaden, max(-$maxEntladen, $leistungW)));
|
||||
}
|
||||
|
||||
private static function pruefeLeistungsgrenzen(float $maxLadenW, float $maxEntladenW): void
|
||||
{
|
||||
if ($maxLadenW < 0.0 || $maxEntladenW < 0.0) {
|
||||
throw new InvalidArgumentException('Leistungsgrenzen duerfen nicht negativ sein.');
|
||||
}
|
||||
}
|
||||
|
||||
private static function pruefeLadezustandsgrenzen(float $reserve, float $hysterese): void
|
||||
{
|
||||
if ($reserve < 0.0 || $reserve > 100.0) {
|
||||
throw new InvalidArgumentException('Reserve muss zwischen 0 und 100 Prozent liegen.');
|
||||
}
|
||||
if ($hysterese < 0.0 || $reserve + $hysterese > 100.0) {
|
||||
throw new InvalidArgumentException(
|
||||
'Hysterese muss positiv sein und mit der Reserve hoechstens 100 Prozent ergeben.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static function pruefeBatterietyp(int $batterietyp): void
|
||||
{
|
||||
if (!in_array($batterietyp, [
|
||||
self::TYP_HERSTELLERUNABHAENGIG,
|
||||
self::TYP_GOODWE,
|
||||
self::TYP_SOLAREDGE,
|
||||
self::TYP_SIGENERGY,
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Batterietyp ist nicht konfiguriert.');
|
||||
}
|
||||
}
|
||||
|
||||
private static function pruefeSteuerung(int $steuerung): void
|
||||
{
|
||||
if (!in_array($steuerung, [
|
||||
self::STEUERUNG_WECHSELRICHTER,
|
||||
self::STEUERUNG_ENELIX,
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Batteriesteuerung ist ungueltig.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
final class EaseeGatewayProtokoll
|
||||
{
|
||||
/** @var list<int> */
|
||||
public const BEOBACHTUNGEN = [
|
||||
31, 41, 42, 43, 47, 48, 62, 96, 100, 102, 104, 109, 110,
|
||||
111, 112, 113, 114, 119, 120, 182, 183, 184, 185, 250,
|
||||
];
|
||||
|
||||
public static function seriennummer(string $wert): string
|
||||
{
|
||||
return strtoupper(trim($wert));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $knoten
|
||||
* @param list<string> $abonnements
|
||||
* @return list<array{Seriennummer: string, ID: int, Wert: mixed}>
|
||||
*/
|
||||
public static function extrahiereBeobachtungen(
|
||||
$knoten,
|
||||
array $abonnements,
|
||||
string $seriennummer = '',
|
||||
int $tiefe = 0
|
||||
): array {
|
||||
if (!is_array($knoten) || $tiefe > 12) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$abonnements = array_values(array_unique(array_map(
|
||||
[self::class, 'seriennummer'],
|
||||
$abonnements
|
||||
)));
|
||||
foreach (['serialNumber', 'SerialNumber', 'chargerId', 'ChargerId', 'mid', 'Mid'] as $feld) {
|
||||
if (!isset($knoten[$feld]) || !is_scalar($knoten[$feld])) {
|
||||
continue;
|
||||
}
|
||||
$kandidat = self::seriennummer((string) $knoten[$feld]);
|
||||
if (in_array($kandidat, $abonnements, true)) {
|
||||
$seriennummer = $kandidat;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (self::istListe($knoten)) {
|
||||
foreach ($knoten as $wert) {
|
||||
if (!is_string($wert)) {
|
||||
continue;
|
||||
}
|
||||
$kandidat = self::seriennummer($wert);
|
||||
if (in_array($kandidat, $abonnements, true)) {
|
||||
$seriennummer = $kandidat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$id = null;
|
||||
foreach (['id', 'Id', 'observationId', 'ObservationId'] as $feld) {
|
||||
if (array_key_exists($feld, $knoten) && is_numeric($knoten[$feld])) {
|
||||
$id = (int) $knoten[$feld];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$wertVorhanden = false;
|
||||
$wert = null;
|
||||
foreach (['value', 'Value'] as $feld) {
|
||||
if (array_key_exists($feld, $knoten)) {
|
||||
$wertVorhanden = true;
|
||||
$wert = $knoten[$feld];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$ergebnis = [];
|
||||
if (
|
||||
$seriennummer !== ''
|
||||
&& $wertVorhanden
|
||||
&& in_array($id, self::BEOBACHTUNGEN, true)
|
||||
) {
|
||||
$ergebnis[] = [
|
||||
'Seriennummer' => $seriennummer,
|
||||
'ID' => $id,
|
||||
'Wert' => $wert,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($knoten as $kind) {
|
||||
if (is_array($kind)) {
|
||||
$ergebnis = array_merge(
|
||||
$ergebnis,
|
||||
self::extrahiereBeobachtungen(
|
||||
$kind,
|
||||
$abonnements,
|
||||
$seriennummer,
|
||||
$tiefe + 1
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $ergebnis;
|
||||
}
|
||||
|
||||
/** @param array<mixed> $wert */
|
||||
private static function istListe(array $wert): bool
|
||||
{
|
||||
$index = 0;
|
||||
foreach ($wert as $schluessel => $_) {
|
||||
if ($schluessel !== $index++) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?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,
|
||||
* EaseeAktiviert: bool,
|
||||
* AutorisierungErforderlich: bool,
|
||||
* RemoteStartErforderlich: bool,
|
||||
* SmartCharging: bool,
|
||||
* GrundKeinLadestrom: 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));
|
||||
$easeeAktiviert = !array_key_exists('31', $beobachtungen)
|
||||
|| (bool) $beobachtungen['31'];
|
||||
$autorisierungErforderlich = (bool) (
|
||||
$beobachtungen['42'] ?? $beobachtungen['41'] ?? false
|
||||
);
|
||||
$remoteStartErforderlich = (bool) ($beobachtungen['43'] ?? false);
|
||||
$smartCharging = (bool) ($beobachtungen['102'] ?? false);
|
||||
$grundKeinLadestrom = is_numeric($beobachtungen['96'] ?? null)
|
||||
? (int) $beobachtungen['96']
|
||||
: 0;
|
||||
|
||||
$fehlercode = (int) ($beobachtungen['119'] ?? 0);
|
||||
$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,
|
||||
'EaseeAktiviert' => $easeeAktiviert,
|
||||
'AutorisierungErforderlich' => $autorisierungErforderlich,
|
||||
'RemoteStartErforderlich' => $remoteStartErforderlich,
|
||||
'SmartCharging' => $smartCharging,
|
||||
'GrundKeinLadestrom' => $grundKeinLadestrom,
|
||||
'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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Verteilt eine gemeinsame Einspeisegrenze auf regelbare PV-Wechselrichter.
|
||||
*
|
||||
* Netzleistung: positiv Bezug, negativ Einspeisung.
|
||||
*/
|
||||
final class EinspeiseRegler
|
||||
{
|
||||
/**
|
||||
* @param list<array{ID: string, Nennleistung_W: float|int, Istleistung_W: float|int}> $wechselrichter
|
||||
* @param array<string, float|int> $letzteGrenzen
|
||||
* @return array{
|
||||
* Netzabweichung_W: float,
|
||||
* ZielGesamtleistung_W: float,
|
||||
* Abregelbedarf_W: float,
|
||||
* Grenzen_W: array<string, float>
|
||||
* }
|
||||
*/
|
||||
public static function verteile(
|
||||
float $netzleistung,
|
||||
float $einspeisegrenze,
|
||||
array $wechselrichter,
|
||||
array $letzteGrenzen = []
|
||||
): array {
|
||||
if ($einspeisegrenze < 0.0) {
|
||||
throw new InvalidArgumentException('Einspeisegrenze darf nicht negativ sein.');
|
||||
}
|
||||
if ($wechselrichter === []) {
|
||||
throw new InvalidArgumentException('Mindestens ein regelbarer Wechselrichter ist erforderlich.');
|
||||
}
|
||||
|
||||
$nennleistungGesamt = 0.0;
|
||||
$istleistungGesamt = 0.0;
|
||||
$bisherigeGrenzeGesamt = 0.0;
|
||||
$normalisiert = [];
|
||||
$kennungen = [];
|
||||
|
||||
foreach ($wechselrichter as $eintrag) {
|
||||
$id = $eintrag['ID'] ?? null;
|
||||
$nennleistung = $eintrag['Nennleistung_W'] ?? null;
|
||||
$istleistung = $eintrag['Istleistung_W'] ?? null;
|
||||
if (!is_string($id) || trim($id) === '' || isset($kennungen[$id])) {
|
||||
throw new InvalidArgumentException('Wechselrichter-ID fehlt oder ist mehrfach vorhanden.');
|
||||
}
|
||||
if (!is_numeric($nennleistung) || (float) $nennleistung <= 0.0) {
|
||||
throw new InvalidArgumentException('Wechselrichter-Nennleistung muss groesser als 0 sein.');
|
||||
}
|
||||
if (!is_numeric($istleistung) || (float) $istleistung < 0.0) {
|
||||
throw new InvalidArgumentException('Wechselrichter-Istleistung darf nicht negativ sein.');
|
||||
}
|
||||
|
||||
$nennleistung = (float) $nennleistung;
|
||||
$istleistung = min((float) $istleistung, $nennleistung);
|
||||
$bisherigeGrenze = $letzteGrenzen[$id] ?? $nennleistung;
|
||||
if (!is_numeric($bisherigeGrenze)) {
|
||||
throw new InvalidArgumentException('Gespeicherte Wechselrichtergrenze ist ungueltig.');
|
||||
}
|
||||
$bisherigeGrenze = max(0.0, min((float) $bisherigeGrenze, $nennleistung));
|
||||
$normalisiert[] = [
|
||||
'ID' => $id,
|
||||
'Nennleistung_W' => $nennleistung,
|
||||
'Istleistung_W' => $istleistung,
|
||||
];
|
||||
$kennungen[$id] = true;
|
||||
$nennleistungGesamt += $nennleistung;
|
||||
$istleistungGesamt += $istleistung;
|
||||
$bisherigeGrenzeGesamt += $bisherigeGrenze;
|
||||
}
|
||||
|
||||
$netzabweichung = $netzleistung + $einspeisegrenze;
|
||||
$zielGesamt = $bisherigeGrenzeGesamt;
|
||||
$nachIstleistungVerteilen = false;
|
||||
if ($netzabweichung < 0.0) {
|
||||
$nachIstleistungVerteilen = $istleistungGesamt > 0.0;
|
||||
$zielGesamt = min($bisherigeGrenzeGesamt, $istleistungGesamt) + $netzabweichung;
|
||||
} elseif ($netzabweichung > 0.0) {
|
||||
$zielGesamt = $bisherigeGrenzeGesamt + $netzabweichung;
|
||||
}
|
||||
$zielGesamt = max(0.0, min($zielGesamt, $nennleistungGesamt));
|
||||
|
||||
$grenzen = [];
|
||||
foreach ($normalisiert as $eintrag) {
|
||||
$anteil = $nachIstleistungVerteilen
|
||||
? $eintrag['Istleistung_W'] / $istleistungGesamt
|
||||
: $eintrag['Nennleistung_W'] / $nennleistungGesamt;
|
||||
$grenzen[$eintrag['ID']] = round(
|
||||
min($eintrag['Nennleistung_W'], $zielGesamt * $anteil),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'Netzabweichung_W' => $netzabweichung,
|
||||
'ZielGesamtleistung_W' => round($zielGesamt, 3),
|
||||
'Abregelbedarf_W' => round(max(0.0, $istleistungGesamt - $zielGesamt), 3),
|
||||
'Grenzen_W' => $grenzen,
|
||||
];
|
||||
}
|
||||
|
||||
public static function stellwert(float $grenzeW, float $nennleistungW, string $art): float
|
||||
{
|
||||
if ($nennleistungW <= 0.0 || $grenzeW < 0.0) {
|
||||
throw new InvalidArgumentException('Leistungsgrenzen sind ungueltig.');
|
||||
}
|
||||
$grenzeW = min($grenzeW, $nennleistungW);
|
||||
if ($art === 'percent') {
|
||||
return round($grenzeW / $nennleistungW * 100.0, 2);
|
||||
}
|
||||
if ($art === 'watt') {
|
||||
return round($grenzeW, 1);
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Begrenzungsart muss percent oder watt sein.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
final class EnergieMessung
|
||||
{
|
||||
private const WATTSEKUNDEN_PRO_KWH = 3600000.0;
|
||||
|
||||
public static function hausleistung(float $pv, float $netz, float $batterie): float
|
||||
{
|
||||
return max(
|
||||
0.0,
|
||||
self::endlich($pv) + self::endlich($netz) - self::endlich($batterie)
|
||||
);
|
||||
}
|
||||
|
||||
public static function hausenergie(
|
||||
float $pv,
|
||||
float $netzbezug,
|
||||
float $netzeinspeisung,
|
||||
float $batterieLaden,
|
||||
float $batterieEntladen
|
||||
): float {
|
||||
return max(
|
||||
0.0,
|
||||
self::endlich($pv)
|
||||
+ self::endlich($netzbezug)
|
||||
+ self::endlich($batterieEntladen)
|
||||
- self::endlich($netzeinspeisung)
|
||||
- self::endlich($batterieLaden)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{PV: float, Haus: float, Netz: float, Batterie: float} $leistungen
|
||||
* @return array{
|
||||
* PVEnergie: float,
|
||||
* Hausenergie: float,
|
||||
* NetzbezugEnergie: float,
|
||||
* EinspeisungEnergie: float,
|
||||
* BatterieLadenEnergie: float,
|
||||
* BatterieEntladenEnergie: float
|
||||
* }
|
||||
*/
|
||||
public static function energieDeltas(array $leistungen, int $sekunden): array
|
||||
{
|
||||
$faktor = max(0, $sekunden) / self::WATTSEKUNDEN_PRO_KWH;
|
||||
$pv = self::endlich($leistungen['PV']);
|
||||
$haus = self::endlich($leistungen['Haus']);
|
||||
$netz = self::endlich($leistungen['Netz']);
|
||||
$batterie = self::endlich($leistungen['Batterie']);
|
||||
|
||||
return [
|
||||
'PVEnergie' => max(0.0, $pv) * $faktor,
|
||||
'Hausenergie' => max(0.0, $haus) * $faktor,
|
||||
'NetzbezugEnergie' => max(0.0, $netz) * $faktor,
|
||||
'EinspeisungEnergie' => max(0.0, -$netz) * $faktor,
|
||||
'BatterieLadenEnergie' => max(0.0, $batterie) * $faktor,
|
||||
'BatterieEntladenEnergie' => max(0.0, -$batterie) * $faktor,
|
||||
];
|
||||
}
|
||||
|
||||
private static function endlich(float $wert): float
|
||||
{
|
||||
return is_finite($wert) ? $wert : 0.0;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class LadestationRegler
|
||||
{
|
||||
public const PHASEN_UNBEKANNT = 0;
|
||||
public const PHASEN_EIN = 1;
|
||||
public const PHASEN_DREI = 3;
|
||||
|
||||
/** @var list<int> */
|
||||
private const LEISTUNGEN_EINPHASIG = [
|
||||
1450, 1690, 1930, 2170, 2410, 2650, 2890, 3130, 3370,
|
||||
3610, 3850, 4090, 4330, 4570, 4810, 5050, 5290, 5530,
|
||||
5770, 6010, 6250, 6490, 6730, 6970, 7210, 7450, 7690,
|
||||
];
|
||||
|
||||
/** @var list<int> */
|
||||
private const LEISTUNGEN_DREIPHASIG = [
|
||||
4150, 4850, 5550, 6250, 6950, 7600, 8300, 9000, 9700,
|
||||
10300, 11000, 11750, 12450, 13150, 13850, 14550, 15250,
|
||||
15900, 16600, 17300, 18000, 18700, 19400, 20100, 20800,
|
||||
21500, 22000,
|
||||
];
|
||||
|
||||
/**
|
||||
* Die Schwelle, die Voll-Erkennung und der Zuschlag von 2.5 A stammen
|
||||
* aus der Enelix-1-Ladestation und bleiben bewusst kompatibel.
|
||||
*
|
||||
* @param array{Rohstatus: int, Istleistung_W: float} $rohstatus
|
||||
* @return array<string, int|float|bool>
|
||||
*/
|
||||
public static function normalisiereStatus(
|
||||
array $rohstatus,
|
||||
bool $warVerbunden,
|
||||
bool $warGeladen = false,
|
||||
bool $ladungErwartet = true
|
||||
): array {
|
||||
$verbunden = $rohstatus['Rohstatus'] !== 1;
|
||||
$leistung = max(0.0, $rohstatus['Istleistung_W']);
|
||||
$phasen = self::PHASEN_UNBEKANNT;
|
||||
$ladestrom = 0.0;
|
||||
$geladen = false;
|
||||
|
||||
if ($verbunden) {
|
||||
$phasen = $leistung > 7500.0 ? self::PHASEN_DREI : self::PHASEN_EIN;
|
||||
$nenner = $phasen === self::PHASEN_EIN ? 230.0 : 1.71 * 400.0;
|
||||
$ladestrom = $leistung / $nenner;
|
||||
$ermittelterMaximalstrom = 2.5 + $ladestrom;
|
||||
$geladen = $warGeladen
|
||||
|| ($warVerbunden && $ladungErwartet && $ermittelterMaximalstrom < 6.0);
|
||||
}
|
||||
|
||||
if (!$verbunden) {
|
||||
$fahrzeugstatus = 1;
|
||||
} elseif ($geladen) {
|
||||
$fahrzeugstatus = 4;
|
||||
} elseif ($leistung > 0.0) {
|
||||
$fahrzeugstatus = 3;
|
||||
} else {
|
||||
$fahrzeugstatus = 2;
|
||||
}
|
||||
|
||||
return [
|
||||
'FahrzeugVerbunden' => $verbunden,
|
||||
'FahrzeugGeladen' => $geladen,
|
||||
'Fahrzeugstatus' => $fahrzeugstatus,
|
||||
'Phasenzahl' => $phasen,
|
||||
'Istleistung_W' => $leistung,
|
||||
'Ladestrom_A' => $ladestrom,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
public static function leistungsangebot(
|
||||
bool $aktiv,
|
||||
bool $ladefreigabe,
|
||||
bool $verbunden,
|
||||
bool $geladen,
|
||||
int $phasenzahl,
|
||||
int $maximalerLadestrom,
|
||||
bool $solarladen,
|
||||
bool $peakbetrieb
|
||||
): array {
|
||||
if (!$aktiv || !$ladefreigabe || !$verbunden || $geladen) {
|
||||
return [0];
|
||||
}
|
||||
|
||||
$stufen = self::leistungsstufen($phasenzahl, $maximalerLadestrom);
|
||||
if ($stufen === []) {
|
||||
return [0];
|
||||
}
|
||||
if ($solarladen) {
|
||||
return $peakbetrieb ? [0] : array_merge([0], $stufen);
|
||||
}
|
||||
if ($peakbetrieb) {
|
||||
return array_merge([0], $stufen);
|
||||
}
|
||||
|
||||
return [$stufen[count($stufen) - 1]];
|
||||
}
|
||||
|
||||
public static function stromFuerLeistung(int $leistung, int $phasenzahl): int
|
||||
{
|
||||
if ($leistung <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$stufen = self::stufenFuerPhasen($phasenzahl);
|
||||
if ($leistung < $stufen[0]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$strom = 6;
|
||||
foreach ($stufen as $index => $schwelle) {
|
||||
if ($leistung < $schwelle) {
|
||||
break;
|
||||
}
|
||||
$strom = 6 + $index;
|
||||
}
|
||||
|
||||
return min(32, $strom);
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private static function leistungsstufen(int $phasenzahl, int $maximalerLadestrom): array
|
||||
{
|
||||
if ($maximalerLadestrom < 6 || $maximalerLadestrom > 32) {
|
||||
throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.');
|
||||
}
|
||||
|
||||
return array_slice(self::stufenFuerPhasen($phasenzahl), 0, $maximalerLadestrom - 5);
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private static function stufenFuerPhasen(int $phasenzahl): array
|
||||
{
|
||||
if ($phasenzahl === self::PHASEN_EIN) {
|
||||
return self::LEISTUNGEN_EINPHASIG;
|
||||
}
|
||||
if ($phasenzahl === self::PHASEN_DREI) {
|
||||
return self::LEISTUNGEN_DREIPHASIG;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Phasenzahl muss 1 oder 3 sein.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
final class Lizenzpruefung
|
||||
{
|
||||
public const MANAGER_STANDARD = 'manager_standard';
|
||||
public const MANAGER_PEAK = 'manager_peak';
|
||||
public const PROGNOSE_PV = 'forecast_pv';
|
||||
public const PROGNOSE_VERBRAUCH = 'forecast_load';
|
||||
public const NETZFAHRPLAN = 'grid_schedule';
|
||||
|
||||
public static function normalisiereCode(string $code): string
|
||||
{
|
||||
return strtoupper((string) preg_replace('/[^A-Z0-9]/i', '', $code));
|
||||
}
|
||||
|
||||
public static function istLizenzcodeGueltig(string $code): bool
|
||||
{
|
||||
return preg_match(
|
||||
'/^ENX[A-HJ-NP-Z2-9]{16}$/',
|
||||
self::normalisiereCode($code)
|
||||
) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lease
|
||||
*/
|
||||
public static function pruefeLease(
|
||||
array $lease,
|
||||
string $installationID,
|
||||
int $lastspitzenmodus,
|
||||
?int $zeitpunkt = null
|
||||
): void {
|
||||
if (($lease['status'] ?? null) !== 'active') {
|
||||
throw new InvalidArgumentException('Die Lizenz ist nicht aktiv.');
|
||||
}
|
||||
if (($lease['installationId'] ?? null) !== $installationID) {
|
||||
throw new InvalidArgumentException('Die Lizenz gehoert zu einer anderen Installation.');
|
||||
}
|
||||
if (isset($lease['development']) && !is_bool($lease['development'])) {
|
||||
throw new InvalidArgumentException('Der Entwicklungsstatus der Lizenz ist ungueltig.');
|
||||
}
|
||||
|
||||
$ausgestelltAm = self::zeitstempel($lease, 'issuedAt');
|
||||
$erneuernAb = self::zeitstempel($lease, 'refreshAfter');
|
||||
$offlineBis = self::zeitstempel($lease, 'offlineUntil');
|
||||
if ($ausgestelltAm > $erneuernAb || $erneuernAb > $offlineBis) {
|
||||
throw new InvalidArgumentException('Die Lizenzzeitpunkte sind widerspruechlich.');
|
||||
}
|
||||
if (($zeitpunkt ?? time()) >= $offlineBis) {
|
||||
throw new InvalidArgumentException('Die Offline-Freigabe ist abgelaufen.');
|
||||
}
|
||||
|
||||
$berechtigungen = $lease['entitlements'] ?? null;
|
||||
if (!is_array($berechtigungen)) {
|
||||
throw new InvalidArgumentException('Die Lizenz enthaelt keine Berechtigungen.');
|
||||
}
|
||||
$standard = false;
|
||||
$peak = false;
|
||||
foreach ($berechtigungen as $berechtigung) {
|
||||
if (!is_array($berechtigung)
|
||||
|| ($berechtigung['status'] ?? null) !== 'active'
|
||||
|| !is_int($berechtigung['quantity'] ?? null)
|
||||
|| $berechtigung['quantity'] < 1
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
$kennung = $berechtigung['catalog_key'] ?? $berechtigung['sku'] ?? null;
|
||||
$standard = $standard || $kennung === self::MANAGER_STANDARD;
|
||||
$peak = $peak || $kennung === self::MANAGER_PEAK;
|
||||
}
|
||||
|
||||
if (!$standard && !$peak) {
|
||||
throw new InvalidArgumentException('Die Manager-Berechtigung fehlt.');
|
||||
}
|
||||
if ($lastspitzenmodus !== 0 && !$peak) {
|
||||
throw new InvalidArgumentException('Peak Shaving ist nicht lizenziert.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die aufsummierte aktive Berechtigung fuer einen Katalogeintrag.
|
||||
*
|
||||
* @param array<string, mixed> $lease
|
||||
*/
|
||||
public static function berechtigungsMenge(array $lease, string $kennung): int
|
||||
{
|
||||
$berechtigungen = $lease['entitlements'] ?? null;
|
||||
if (!is_array($berechtigungen)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$menge = 0;
|
||||
foreach ($berechtigungen as $berechtigung) {
|
||||
if (!is_array($berechtigung)
|
||||
|| ($berechtigung['status'] ?? null) !== 'active'
|
||||
|| !is_int($berechtigung['quantity'] ?? null)
|
||||
|| $berechtigung['quantity'] < 1
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
$berechtigungsKennung = $berechtigung['catalog_key']
|
||||
?? $berechtigung['sku']
|
||||
?? null;
|
||||
if ($berechtigungsKennung === $kennung) {
|
||||
$menge += $berechtigung['quantity'];
|
||||
}
|
||||
}
|
||||
|
||||
return $menge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Prognose ist entweder durch den Netzfahrplan oder durch beide
|
||||
* Einzelberechtigungen fuer PV und Verbrauch freigeschaltet.
|
||||
*
|
||||
* @param array<string, mixed> $lease
|
||||
*/
|
||||
public static function hatPrognoseBerechtigung(array $lease): bool
|
||||
{
|
||||
return self::berechtigungsMenge($lease, self::NETZFAHRPLAN) > 0
|
||||
|| (self::berechtigungsMenge($lease, self::PROGNOSE_PV) > 0
|
||||
&& self::berechtigungsMenge($lease, self::PROGNOSE_VERBRAUCH) > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnet freie, bestehende Einzellizenzen auf nicht separat lizenzierte Waermepumpen an.
|
||||
*
|
||||
* @param array<string, mixed> $lease
|
||||
* @param array<string, int> $verwendeteMengen
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public static function beruecksichtigeBestandslizenzFuerWaermepumpen(
|
||||
array $lease,
|
||||
array $verwendeteMengen
|
||||
): array {
|
||||
$waermepumpen = $verwendeteMengen['heat_pump'] ?? 0;
|
||||
if ($waermepumpen <= 0) {
|
||||
return $verwendeteMengen;
|
||||
}
|
||||
|
||||
$nichtSeparatLizenziert = max(
|
||||
0,
|
||||
$waermepumpen - self::berechtigungsMenge($lease, 'heat_pump')
|
||||
);
|
||||
$verbraucher = $verwendeteMengen['consumer_single'] ?? 0;
|
||||
$freieBestandslizenzen = max(
|
||||
0,
|
||||
self::berechtigungsMenge($lease, 'consumer_single') - $verbraucher
|
||||
);
|
||||
$angerechnet = min($nichtSeparatLizenziert, $freieBestandslizenzen);
|
||||
if ($angerechnet === 0) {
|
||||
return $verwendeteMengen;
|
||||
}
|
||||
|
||||
$verwendeteMengen['heat_pump'] = $waermepumpen - $angerechnet;
|
||||
$verwendeteMengen['consumer_single'] = $verbraucher + $angerechnet;
|
||||
|
||||
return $verwendeteMengen;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lease
|
||||
* @param array<string, int> $verwendeteMengen
|
||||
*/
|
||||
public static function pruefeVerbrauchermengen(array $lease, array $verwendeteMengen): void
|
||||
{
|
||||
foreach ($verwendeteMengen as $kennung => $verwendet) {
|
||||
if ($verwendet < 0) {
|
||||
throw new InvalidArgumentException(
|
||||
'Die verwendete Menge fuer ' . $kennung . ' darf nicht negativ sein.'
|
||||
);
|
||||
}
|
||||
$lizenziert = self::berechtigungsMenge($lease, $kennung);
|
||||
if ($verwendet > $lizenziert) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Lizenzkontingent fuer %s ueberschritten: %d verwendet, %d lizenziert.',
|
||||
$kennung,
|
||||
$verwendet,
|
||||
$lizenziert
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lease
|
||||
*/
|
||||
public static function istLeaseGueltig(
|
||||
array $lease,
|
||||
string $installationID,
|
||||
int $lastspitzenmodus,
|
||||
?int $zeitpunkt = null
|
||||
): bool {
|
||||
try {
|
||||
self::pruefeLease($lease, $installationID, $lastspitzenmodus, $zeitpunkt);
|
||||
|
||||
return true;
|
||||
} catch (Throwable $fehler) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lease
|
||||
*/
|
||||
public static function erneuerungFaellig(array $lease, ?int $zeitpunkt = null): bool
|
||||
{
|
||||
try {
|
||||
return ($zeitpunkt ?? time()) >= self::zeitstempel($lease, 'refreshAfter');
|
||||
} catch (Throwable $fehler) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lease
|
||||
*/
|
||||
public static function offlineBis(array $lease): int
|
||||
{
|
||||
return self::zeitstempel($lease, 'offlineUntil');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lease
|
||||
*/
|
||||
public static function erneuernAb(array $lease): int
|
||||
{
|
||||
return self::zeitstempel($lease, 'refreshAfter');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lease
|
||||
*/
|
||||
private static function zeitstempel(array $lease, string $feld): int
|
||||
{
|
||||
$wert = $lease[$feld] ?? null;
|
||||
if (!is_string($wert) || trim($wert) === '') {
|
||||
throw new InvalidArgumentException($feld . ' fehlt in der Lizenz.');
|
||||
}
|
||||
|
||||
try {
|
||||
return (new DateTimeImmutable($wert))->getTimestamp();
|
||||
} catch (Throwable $fehler) {
|
||||
throw new InvalidArgumentException($feld . ' ist kein gueltiger ISO-8601-Zeitpunkt.', 0, $fehler);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
trait ManagerEnergieTrait
|
||||
{
|
||||
private function konfiguriereEnergiearchiv(): void
|
||||
{
|
||||
$archivID = $this->archivID();
|
||||
if ($archivID === 0) {
|
||||
$this->protokolliere('Energiearchiv', 'Kein Archiv Control vorhanden.');
|
||||
return;
|
||||
}
|
||||
$aktiv = $this->ReadPropertyBoolean('EnergieaufzeichnungAktiv');
|
||||
$geaendert = [];
|
||||
$leistungstyp = $this->verdichtungstyp(
|
||||
$this->ReadPropertyInteger('LeistungsaufzeichnungMinuten')
|
||||
);
|
||||
$energietyp = $this->verdichtungstyp(
|
||||
$this->ReadPropertyInteger('EnergieaufzeichnungMinuten')
|
||||
);
|
||||
foreach (self::LEISTUNGS_VARIABLEN as $ident) {
|
||||
$variableID = $this->GetIDForIdent($ident);
|
||||
AC_SetLoggingStatus($archivID, $variableID, $aktiv);
|
||||
AC_SetAggregationType($archivID, $variableID, 0);
|
||||
AC_SetGraphStatus($archivID, $variableID, $aktiv);
|
||||
$regeln = [-1 => $leistungstyp];
|
||||
$loeschen = $this->ReadPropertyInteger('LeistungLoeschenMonate');
|
||||
if ($loeschen > 0) {
|
||||
$regeln[$loeschen] = 7;
|
||||
}
|
||||
if ($aktiv && $this->setzeVerdichtung($archivID, $variableID, $regeln)) {
|
||||
$geaendert[] = $variableID;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::ENERGIE_VARIABLEN as $ident) {
|
||||
$variableID = $this->GetIDForIdent($ident);
|
||||
AC_SetLoggingStatus($archivID, $variableID, $aktiv);
|
||||
AC_SetAggregationType($archivID, $variableID, 1);
|
||||
AC_SetGraphStatus($archivID, $variableID, $aktiv);
|
||||
$regeln = [-1 => $energietyp];
|
||||
$verdichten = $this->ReadPropertyInteger('EnergieVerdichtenMonate');
|
||||
if ($verdichten > 0) {
|
||||
$regeln[$verdichten] = 3;
|
||||
}
|
||||
$loeschen = $this->ReadPropertyInteger('EnergieLoeschenMonate');
|
||||
if ($loeschen > 0) {
|
||||
$regeln[$loeschen] = 7;
|
||||
}
|
||||
if ($aktiv && $this->setzeVerdichtung($archivID, $variableID, $regeln)) {
|
||||
$geaendert[] = $variableID;
|
||||
}
|
||||
}
|
||||
|
||||
IPS_ApplyChanges($archivID);
|
||||
foreach (array_unique($geaendert) as $variableID) {
|
||||
AC_ReAggregateVariable($archivID, $variableID);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int, int> $regeln */
|
||||
private function setzeVerdichtung(int $archivID, int $variableID, array $regeln): bool
|
||||
{
|
||||
ksort($regeln);
|
||||
$aktuell = [];
|
||||
$eintraege = AC_GetCompaction($archivID, $variableID);
|
||||
if (is_array($eintraege)) {
|
||||
foreach ($eintraege as $eintrag) {
|
||||
$monat = (int) ($eintrag['MonthOffset'] ?? -999);
|
||||
$typ = (int) ($eintrag['CompactionType'] ?? -1);
|
||||
if ($monat >= -1 && $typ >= 0) {
|
||||
$aktuell[$monat] = $typ;
|
||||
}
|
||||
}
|
||||
}
|
||||
ksort($aktuell);
|
||||
if ($aktuell === $regeln) {
|
||||
return false;
|
||||
}
|
||||
foreach (array_keys($aktuell) as $monat) {
|
||||
AC_SetCompaction($archivID, $variableID, $monat, -1);
|
||||
}
|
||||
foreach ($regeln as $monat => $typ) {
|
||||
AC_SetCompaction($archivID, $variableID, $monat, $typ);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function verdichtungstyp(int $minuten): int
|
||||
{
|
||||
return [1 => 0, 5 => 1, 60 => 2][$minuten] ?? 0;
|
||||
}
|
||||
|
||||
private function archivID(): int
|
||||
{
|
||||
$archive = IPS_GetInstanceListByModuleID(self::ARCHIVE_MODULE_ID);
|
||||
|
||||
return $archive === [] ? 0 : (int) $archive[0];
|
||||
}
|
||||
|
||||
private function synchronisiereEnergievisualisierungen(): void
|
||||
{
|
||||
$this->synchronisiereEnergyPie($this->ReadPropertyBoolean('EnergyPieAnzeigen'));
|
||||
$this->synchronisiereEnergiediagramme(
|
||||
$this->ReadPropertyBoolean('EnergiediagrammeAnzeigen')
|
||||
);
|
||||
$this->synchronisiereFunFacts($this->ReadPropertyBoolean('FunFactsAnzeigen'));
|
||||
$this->synchronisiereEnergiefluss($this->ReadPropertyBoolean('EnergieflussAnzeigen'));
|
||||
}
|
||||
|
||||
private function synchronisiereEnergyPie(bool $anzeigen): void
|
||||
{
|
||||
if ($anzeigen) {
|
||||
$this->erstelleEnergyPie();
|
||||
return;
|
||||
}
|
||||
$this->entferneInstanzUnterobjekt('EnergyPie', self::ENERGY_PIE_MODULE_ID);
|
||||
}
|
||||
|
||||
private function synchronisiereEnergiediagramme(bool $anzeigen): void
|
||||
{
|
||||
if ($anzeigen) {
|
||||
$this->erstelleEnergiediagramme();
|
||||
return;
|
||||
}
|
||||
$this->entferneDiagrammUnterobjekt('Leistungsdiagramm');
|
||||
$this->entferneDiagrammUnterobjekt('Energiediagramm');
|
||||
}
|
||||
|
||||
private function synchronisiereFunFacts(bool $anzeigen): void
|
||||
{
|
||||
if ($anzeigen) {
|
||||
$this->erstelleFunFacts();
|
||||
return;
|
||||
}
|
||||
$variableID = $this->findeUnterobjekt('EnergieFunFacts');
|
||||
if ($variableID === 0) {
|
||||
return;
|
||||
}
|
||||
if ((int) IPS_GetObject($variableID)['ObjectType'] !== 2) {
|
||||
throw new RuntimeException('Unterobjekt EnergieFunFacts ist keine Variable.');
|
||||
}
|
||||
$this->UnregisterVariable('EnergieFunFacts');
|
||||
}
|
||||
|
||||
private function synchronisiereEnergiefluss(bool $anzeigen): void
|
||||
{
|
||||
if ($anzeigen) {
|
||||
$this->erstelleEnergiefluss();
|
||||
return;
|
||||
}
|
||||
$instanzID = $this->findeUnterobjekt('Energiefluss');
|
||||
if ($instanzID === 0) {
|
||||
return;
|
||||
}
|
||||
$objekt = IPS_GetObject($instanzID);
|
||||
$modulID = $objekt['ObjectType'] === 1
|
||||
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
|
||||
: '';
|
||||
if ($modulID !== self::ENERGY_FLOW_MODULE_ID) {
|
||||
throw new RuntimeException('Unterobjekt Energiefluss hat einen unpassenden Typ.');
|
||||
}
|
||||
IPS_SetHidden($instanzID, true);
|
||||
}
|
||||
|
||||
private function entferneInstanzUnterobjekt(string $ident, string $modulID): void
|
||||
{
|
||||
$instanzID = $this->findeUnterobjekt($ident);
|
||||
if ($instanzID === 0) {
|
||||
return;
|
||||
}
|
||||
$objekt = IPS_GetObject($instanzID);
|
||||
$istModulID = $objekt['ObjectType'] === 1
|
||||
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
|
||||
: '';
|
||||
if ($istModulID !== $modulID) {
|
||||
throw new RuntimeException('Unterobjekt ' . $ident . ' hat einen unpassenden Typ.');
|
||||
}
|
||||
IPS_DeleteInstance($instanzID);
|
||||
}
|
||||
|
||||
private function entferneDiagrammUnterobjekt(string $ident): void
|
||||
{
|
||||
$medienID = $this->findeUnterobjekt($ident);
|
||||
if ($medienID === 0) {
|
||||
return;
|
||||
}
|
||||
$objekt = IPS_GetObject($medienID);
|
||||
if ($objekt['ObjectType'] !== 5 || (int) IPS_GetMedia($medienID)['MediaType'] !== 4) {
|
||||
throw new RuntimeException('Unterobjekt ' . $ident . ' ist kein Diagramm.');
|
||||
}
|
||||
IPS_DeleteMedia($medienID, true);
|
||||
}
|
||||
|
||||
private function erstelleEnergyPie(): string
|
||||
{
|
||||
if (!in_array(self::ENERGY_PIE_MODULE_ID, IPS_GetModuleList(), true)) {
|
||||
throw new RuntimeException(
|
||||
'Das Modul Energy Pie aus Enelix Utils ist nicht installiert.'
|
||||
);
|
||||
}
|
||||
$instanzID = $this->findeUnterobjekt('EnergyPie');
|
||||
if ($instanzID === 0) {
|
||||
$instanzID = IPS_CreateInstance(self::ENERGY_PIE_MODULE_ID);
|
||||
IPS_SetParent($instanzID, $this->InstanceID);
|
||||
IPS_SetIdent($instanzID, 'EnergyPie');
|
||||
} else {
|
||||
$objekt = IPS_GetObject($instanzID);
|
||||
$modulID = $objekt['ObjectType'] === 1
|
||||
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
|
||||
: '';
|
||||
if ($modulID !== self::ENERGY_PIE_MODULE_ID) {
|
||||
throw new RuntimeException('Unterobjekt EnergyPie hat einen unpassenden Typ.');
|
||||
}
|
||||
}
|
||||
IPS_SetName($instanzID, 'Energy Pie');
|
||||
IPS_SetPosition($instanzID, 500);
|
||||
IPS_SetHidden($instanzID, false);
|
||||
foreach ([
|
||||
'ProduktionZaehlerID' => $this->GetIDForIdent('PVEnergie'),
|
||||
'EinspeisungZaehlerID' => $this->GetIDForIdent('EinspeisungEnergie'),
|
||||
'NetzbezugZaehlerID' => $this->GetIDForIdent('NetzbezugEnergie'),
|
||||
'VerbrauchZaehlerID' => $this->GetIDForIdent('Hausenergie'),
|
||||
'Zaehlerfaktor' => 1.0,
|
||||
] as $property => $wert) {
|
||||
IPS_SetProperty($instanzID, $property, $wert);
|
||||
}
|
||||
IPS_ApplyChanges($instanzID);
|
||||
|
||||
return 'Energy Pie wurde erstellt oder aktualisiert.';
|
||||
}
|
||||
|
||||
private function erstelleEnergiefluss(): string
|
||||
{
|
||||
if (!in_array(self::ENERGY_FLOW_MODULE_ID, IPS_GetModuleList(), true)) {
|
||||
throw new RuntimeException('Das Symcon-Modul Energy Distribution ist nicht installiert.');
|
||||
}
|
||||
$instanzID = $this->findeUnterobjekt('Energiefluss');
|
||||
if ($instanzID === 0) {
|
||||
$instanzID = IPS_CreateInstance(self::ENERGY_FLOW_MODULE_ID);
|
||||
IPS_SetParent($instanzID, $this->InstanceID);
|
||||
IPS_SetIdent($instanzID, 'Energiefluss');
|
||||
} else {
|
||||
$objekt = IPS_GetObject($instanzID);
|
||||
$modulID = $objekt['ObjectType'] === 1
|
||||
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
|
||||
: '';
|
||||
if ($modulID !== self::ENERGY_FLOW_MODULE_ID) {
|
||||
throw new RuntimeException('Unterobjekt Energiefluss hat einen unpassenden Typ.');
|
||||
}
|
||||
}
|
||||
IPS_SetName($instanzID, 'Energiefluss');
|
||||
IPS_SetPosition($instanzID, 540);
|
||||
IPS_SetHidden($instanzID, false);
|
||||
$knoten = [
|
||||
$this->energieflussKnoten('PVLeistungArchiv', 'PV-Anlage', 'solar'),
|
||||
$this->energieflussKnoten('NetzleistungArchiv', 'Stromnetz', 'grid'),
|
||||
$this->energieflussKnoten('HausverbrauchLeistungArchiv', 'Hausverbrauch', 'consumer'),
|
||||
$this->energieflussKnoten('BatterieleistungArchiv', 'Batteriespeicher', 'battery'),
|
||||
];
|
||||
IPS_SetProperty($instanzID, 'Calculation', true);
|
||||
IPS_SetProperty(
|
||||
$instanzID,
|
||||
'Config',
|
||||
json_encode($knoten, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
IPS_SetProperty($instanzID, 'EnableLimits', false);
|
||||
IPS_SetProperty($instanzID, 'Restrictions', '[]');
|
||||
IPS_ApplyChanges($instanzID);
|
||||
|
||||
return 'Der Energiefluss wurde erstellt oder aktualisiert.';
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function energieflussKnoten(string $ident, string $name, string $typ): array
|
||||
{
|
||||
return [
|
||||
'VariableConfigType' => 0,
|
||||
'VariableID' => $this->GetIDForIdent($ident),
|
||||
'SecondaryVariableID' => 1,
|
||||
'Name' => $name,
|
||||
'Type' => $typ,
|
||||
'Grouped' => false,
|
||||
'CalculationType' => 0,
|
||||
'InfoVariableID' => 1,
|
||||
'Invert' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private function erstelleEnergiediagramme(): string
|
||||
{
|
||||
$leistung = [
|
||||
$this->chartDatensatz('PVLeistungArchiv', 'PV', '#F4C542', '~Power'),
|
||||
$this->chartDatensatz('HausverbrauchLeistungArchiv', 'Haus', '#22A06B', '~Power'),
|
||||
$this->chartDatensatz('NetzleistungArchiv', 'Netz', '#E95D5D', '~Power'),
|
||||
$this->chartDatensatz('BatterieleistungArchiv', 'Batterie', '#8B5CF6', '~Power'),
|
||||
];
|
||||
$energie = [
|
||||
$this->chartDatensatz('PVEnergie', 'PV', '#F4C542', '~Electricity'),
|
||||
$this->chartDatensatz('Hausenergie', 'Haus', '#22A06B', '~Electricity'),
|
||||
$this->chartDatensatz('NetzbezugEnergie', 'Netzbezug', '#E95D5D', '~Electricity'),
|
||||
$this->chartDatensatz('EinspeisungEnergie', 'Einspeisung', '#2F80ED', '~Electricity'),
|
||||
$this->chartDatensatz('BatterieLadenEnergie', 'Batterie laden', '#8B5CF6', '~Electricity'),
|
||||
$this->chartDatensatz('BatterieEntladenEnergie', 'Batterie entladen', '#F59E0B', '~Electricity'),
|
||||
];
|
||||
$this->erstelleDiagramm('Leistungsdiagramm', 'Leistungen', $leistung, 510);
|
||||
$this->erstelleDiagramm('Energiediagramm', 'Energien', $energie, 520);
|
||||
|
||||
return 'Die Diagramme Leistungen und Energien wurden erstellt oder aktualisiert.';
|
||||
}
|
||||
/** @param list<array<string, mixed>> $datensaetze */
|
||||
private function erstelleDiagramm(
|
||||
string $ident,
|
||||
string $name,
|
||||
array $datensaetze,
|
||||
int $position
|
||||
): int {
|
||||
$medienID = $this->findeUnterobjekt($ident);
|
||||
if ($medienID === 0) {
|
||||
$medienID = IPS_CreateMedia(4);
|
||||
IPS_SetParent($medienID, $this->InstanceID);
|
||||
IPS_SetIdent($medienID, $ident);
|
||||
}
|
||||
$objekt = IPS_GetObject($medienID);
|
||||
if ($objekt['ObjectType'] !== 5 || (int) IPS_GetMedia($medienID)['MediaType'] !== 4) {
|
||||
throw new RuntimeException('Unterobjekt ' . $ident . ' ist kein Diagramm.');
|
||||
}
|
||||
IPS_SetName($medienID, $name);
|
||||
IPS_SetPosition($medienID, $position);
|
||||
IPS_SetHidden($medienID, false);
|
||||
IPS_SetMediaFile($medienID, 'media/' . $medienID . '.chart', false);
|
||||
IPS_SetMediaContent(
|
||||
$medienID,
|
||||
base64_encode(json_encode(['datasets' => $datensaetze], JSON_THROW_ON_ERROR))
|
||||
);
|
||||
|
||||
return $medienID;
|
||||
}
|
||||
/** @return array<string, mixed> */
|
||||
private function chartDatensatz(
|
||||
string $ident,
|
||||
string $titel,
|
||||
string $farbe,
|
||||
string $profil
|
||||
): array {
|
||||
return [
|
||||
'variableID' => $this->GetIDForIdent($ident),
|
||||
'fillColor' => 'clear',
|
||||
'strokeColor' => $farbe,
|
||||
'timeOffset' => 0,
|
||||
'visible' => true,
|
||||
'title' => $titel,
|
||||
'type' => 'line',
|
||||
'side' => 'left',
|
||||
'profile' => $profil,
|
||||
];
|
||||
}
|
||||
|
||||
private function findeUnterobjekt(string $ident): int
|
||||
{
|
||||
$objektID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
|
||||
|
||||
return is_int($objektID) && $objektID > 0 ? $objektID : 0;
|
||||
}
|
||||
|
||||
private function erstelleFunFacts(): string
|
||||
{
|
||||
$this->RegisterVariableString('EnergieFunFacts', 'Energy Facts', '~HTMLBox', 530);
|
||||
$variableID = $this->GetIDForIdent('EnergieFunFacts');
|
||||
IPS_SetHidden($variableID, false);
|
||||
$this->aktualisiereFunFacts($variableID);
|
||||
|
||||
return 'Energy Facts wurden erstellt oder aktualisiert.';
|
||||
}
|
||||
|
||||
private function aktualisiereFunFacts(int $variableID): void
|
||||
{
|
||||
$archivID = $this->archivID();
|
||||
if ($archivID === 0) {
|
||||
SetValue($variableID, '<div style="padding:16px">Archiv nicht verfuegbar.</div>');
|
||||
return;
|
||||
}
|
||||
$heute = strtotime('today');
|
||||
$jahr = mktime(0, 0, 0, 1, 1, (int) date('Y'));
|
||||
$jetzt = time();
|
||||
$pvID = $this->GetIDForIdent('PVEnergie');
|
||||
$hausID = $this->GetIDForIdent('Hausenergie');
|
||||
$bezugID = $this->GetIDForIdent('NetzbezugEnergie');
|
||||
$exportID = $this->GetIDForIdent('EinspeisungEnergie');
|
||||
$pvHeute = $this->energieSumme($archivID, $pvID, $heute, $jetzt);
|
||||
$pvJahr = $this->energieSumme($archivID, $pvID, $jahr, $jetzt);
|
||||
$pvTotal = max(0.0, (float) GetValue($pvID));
|
||||
[$besterTag, $besterTagZeit] = $this->maximalerTageswert($archivID, $pvID);
|
||||
[$maxPV, $maxPVZeit] = $this->maximaleLeistung(
|
||||
$archivID,
|
||||
$this->GetIDForIdent('PVLeistungArchiv')
|
||||
);
|
||||
[$maxHausTag, $maxHausTagZeit] = $this->maximalerTageswert($archivID, $hausID);
|
||||
[$maxHaus, $maxHausZeit] = $this->maximaleLeistung(
|
||||
$archivID,
|
||||
$this->GetIDForIdent('HausverbrauchLeistungArchiv')
|
||||
);
|
||||
[$maxBezug, $maxBezugZeit] = $this->maximalerTageswert($archivID, $bezugID);
|
||||
[$maxExport, $maxExportZeit] = $this->maximalerTageswert($archivID, $exportID);
|
||||
|
||||
$werte = [
|
||||
'{{PV_HEUTE}}' => $this->formatiereEnergie($pvHeute),
|
||||
'{{PV_JAHR}}' => $this->formatiereEnergie($pvJahr),
|
||||
'{{PV_TOTAL}}' => $this->formatiereEnergie($pvTotal),
|
||||
'{{BESTER_TAG}}' => $this->formatiereEnergie($besterTag),
|
||||
'{{BESTER_TAG_DATUM}}' => $this->formatiereDatum($besterTagZeit),
|
||||
'{{MAX_PV}}' => $this->formatiereLeistung($maxPV),
|
||||
'{{MAX_PV_DATUM}}' => $this->formatiereDatum($maxPVZeit),
|
||||
'{{MAX_HAUS_TAG}}' => $this->formatiereEnergie($maxHausTag),
|
||||
'{{MAX_HAUS_TAG_DATUM}}' => $this->formatiereDatum($maxHausTagZeit),
|
||||
'{{MAX_HAUS}}' => $this->formatiereLeistung($maxHaus),
|
||||
'{{MAX_HAUS_DATUM}}' => $this->formatiereDatum($maxHausZeit),
|
||||
'{{MAX_BEZUG}}' => $this->formatiereEnergie($maxBezug),
|
||||
'{{MAX_BEZUG_DATUM}}' => $this->formatiereDatum($maxBezugZeit),
|
||||
'{{MAX_EXPORT}}' => $this->formatiereEnergie($maxExport),
|
||||
'{{MAX_EXPORT_DATUM}}' => $this->formatiereDatum($maxExportZeit),
|
||||
'{{HEIZOEL}}' => number_format($pvTotal / 10.0, 0, ',', "'") . ' Liter',
|
||||
'{{FLUGKM}}' => number_format($pvTotal * 2.4, 0, ',', "'") . ' km',
|
||||
];
|
||||
SetValue($variableID, strtr($this->funFactsHtml(), $werte));
|
||||
}
|
||||
|
||||
private function energieSumme(int $archivID, int $variableID, int $start, int $ende): float
|
||||
{
|
||||
$werte = @AC_GetAggregatedValues($archivID, $variableID, 1, $start, $ende, 0);
|
||||
if (!is_array($werte)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return array_sum(array_map(
|
||||
static fn (array $wert): float => max(0.0, (float) ($wert['Avg'] ?? 0.0)),
|
||||
$werte
|
||||
));
|
||||
}
|
||||
|
||||
/** @return array{0: float, 1: int} */
|
||||
private function maximalerTageswert(int $archivID, int $variableID): array
|
||||
{
|
||||
$werte = @AC_GetAggregatedValues($archivID, $variableID, 1, 0, time(), 0);
|
||||
$maximum = 0.0;
|
||||
$zeit = 0;
|
||||
if (is_array($werte)) {
|
||||
foreach ($werte as $wert) {
|
||||
$kandidat = max(0.0, (float) ($wert['Avg'] ?? 0.0));
|
||||
if ($kandidat > $maximum) {
|
||||
$maximum = $kandidat;
|
||||
$zeit = (int) ($wert['TimeStamp'] ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [$maximum, $zeit];
|
||||
}
|
||||
/** @return array{0: float, 1: int} */
|
||||
private function maximaleLeistung(int $archivID, int $variableID): array
|
||||
{
|
||||
$werte = @AC_GetAggregatedValues($archivID, $variableID, 1, 0, time(), 0);
|
||||
$maximum = 0.0;
|
||||
$zeit = 0;
|
||||
if (is_array($werte)) {
|
||||
foreach ($werte as $wert) {
|
||||
$kandidat = max(0.0, (float) ($wert['Max'] ?? 0.0));
|
||||
if ($kandidat > $maximum) {
|
||||
$maximum = $kandidat;
|
||||
$zeit = (int) ($wert['MaxTime'] ?? $wert['TimeStamp'] ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [$maximum, $zeit];
|
||||
}
|
||||
|
||||
private function formatiereEnergie(float $wert): string
|
||||
{
|
||||
if ($wert >= 1000.0) {
|
||||
return number_format($wert / 1000.0, 2, ',', "'") . ' MWh';
|
||||
}
|
||||
|
||||
return number_format($wert, 1, ',', "'") . ' kWh';
|
||||
}
|
||||
private function formatiereLeistung(float $wert): string
|
||||
{
|
||||
if ($wert >= 10000.0) {
|
||||
return number_format($wert / 1000.0, 1, ',', "'") . ' kW';
|
||||
}
|
||||
|
||||
return number_format($wert, 0, ',', "'") . ' W';
|
||||
}
|
||||
|
||||
private function formatiereDatum(int $zeit): string
|
||||
{
|
||||
return $zeit > 0 ? date('d.m.Y', $zeit) : 'Noch keine Daten';
|
||||
}
|
||||
|
||||
private function funFactsHtml(): string
|
||||
{
|
||||
return <<<'HTML'
|
||||
<style>
|
||||
.enx-facts{box-sizing:border-box;font-family:system-ui,-apple-system,sans-serif;color:#182230;padding:16px;background:#f4f7f6}
|
||||
.enx-facts *{box-sizing:border-box}.enx-facts-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
|
||||
.enx-fact-panel{min-width:0;background:#fff;border:1px solid #dfe7e4;border-radius:8px;overflow:hidden;box-shadow:0 4px 18px rgba(17,44,36,.08)}
|
||||
.enx-fact-head{padding:15px 18px 13px;border-bottom:3px solid #22a06b;display:flex;align-items:center;justify-content:space-between}
|
||||
.enx-fact-head b{font-size:18px}.enx-fact-head span{font-size:11px;letter-spacing:.08em;color:#60736c}
|
||||
.enx-fact-body{padding:5px 18px 10px}.enx-fact-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;padding:11px 0;border-bottom:1px solid #edf1ef}
|
||||
.enx-fact-row:last-child{border-bottom:0}.enx-fact-label{min-width:0;color:#52635d;font-size:13px}.enx-fact-value{text-align:right;font-size:15px;font-weight:700;white-space:nowrap}
|
||||
.enx-fact-date{display:block;color:#81908b;font-size:11px;font-weight:500;margin-top:2px}.enx-fact-panel.grid .enx-fact-head{border-color:#2f80ed}
|
||||
.enx-fact-panel.impact .enx-fact-head{border-color:#f4c542}.enx-impact{padding:18px}.enx-impact-value{font-size:30px;font-weight:750;line-height:1.15}
|
||||
.enx-impact-label{font-size:12px;color:#60736c;margin:5px 0 20px}.enx-impact-note{font-size:11px;color:#81908b;line-height:1.45}
|
||||
@media(max-width:820px){.enx-facts-grid{grid-template-columns:1fr}.enx-facts{padding:10px}}
|
||||
</style>
|
||||
<div class="enx-facts"><div class="enx-facts-grid">
|
||||
<section class="enx-fact-panel"><div class="enx-fact-head"><b>Solar</b><span>ERZEUGUNG</span></div><div class="enx-fact-body">
|
||||
<div class="enx-fact-row"><span class="enx-fact-label">Heute</span><span class="enx-fact-value">{{PV_HEUTE}}</span></div>
|
||||
<div class="enx-fact-row"><span class="enx-fact-label">Dieses Jahr</span><span class="enx-fact-value">{{PV_JAHR}}</span></div>
|
||||
<div class="enx-fact-row"><span class="enx-fact-label">Total</span><span class="enx-fact-value">{{PV_TOTAL}}</span></div>
|
||||
<div class="enx-fact-row"><span class="enx-fact-label">Bester Tag</span><span class="enx-fact-value">{{BESTER_TAG}}<small class="enx-fact-date">{{BESTER_TAG_DATUM}}</small></span></div>
|
||||
<div class="enx-fact-row"><span class="enx-fact-label">Max. Leistung</span><span class="enx-fact-value">{{MAX_PV}}<small class="enx-fact-date">{{MAX_PV_DATUM}}</small></span></div>
|
||||
</div></section>
|
||||
<section class="enx-fact-panel grid"><div class="enx-fact-head"><b>Netz & Haus</b><span>SPITZENWERTE</span></div><div class="enx-fact-body">
|
||||
<div class="enx-fact-row"><span class="enx-fact-label">Max. Tagesverbrauch</span><span class="enx-fact-value">{{MAX_HAUS_TAG}}<small class="enx-fact-date">{{MAX_HAUS_TAG_DATUM}}</small></span></div>
|
||||
<div class="enx-fact-row"><span class="enx-fact-label">Max. Hauslast</span><span class="enx-fact-value">{{MAX_HAUS}}<small class="enx-fact-date">{{MAX_HAUS_DATUM}}</small></span></div>
|
||||
<div class="enx-fact-row"><span class="enx-fact-label">Max. Tagesbezug</span><span class="enx-fact-value">{{MAX_BEZUG}}<small class="enx-fact-date">{{MAX_BEZUG_DATUM}}</small></span></div>
|
||||
<div class="enx-fact-row"><span class="enx-fact-label">Max. Einspeisung</span><span class="enx-fact-value">{{MAX_EXPORT}}<small class="enx-fact-date">{{MAX_EXPORT_DATUM}}</small></span></div>
|
||||
</div></section>
|
||||
<section class="enx-fact-panel impact"><div class="enx-fact-head"><b>Wirkung</b><span>VERGLEICHE</span></div><div class="enx-impact">
|
||||
<div class="enx-impact-value">{{HEIZOEL}}</div><div class="enx-impact-label">rechnerisch kompensiertes Heizoel</div>
|
||||
<div class="enx-impact-value">{{FLUGKM}}</div><div class="enx-impact-label">rechnerisch kompensierte Flugkilometer</div>
|
||||
<div class="enx-impact-note">Vergleichswerte auf Basis von 10 kWh pro Liter Heizoel und 2,4 Flugkilometern pro erzeugter kWh.</div>
|
||||
</div></section>
|
||||
</div></div>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
@@ -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['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;
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@ interface ManagerSchnittstelle
|
||||
/**
|
||||
* Empfaengt den vollstaendigen aktuellen Datensatz eines Verbrauchers.
|
||||
*
|
||||
* Dies ist die einzige fachliche Empfangsmethode des Managers.
|
||||
*
|
||||
* @param array<string, mixed> $daten
|
||||
*/
|
||||
public function VerbraucherdatenEmpfangen(array $daten): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ use InvalidArgumentException;
|
||||
|
||||
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_BERECHNET = 1;
|
||||
@@ -19,11 +22,12 @@ final class Nachrichtenvertrag
|
||||
*/
|
||||
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::pruefeBetriebsart($daten['Betriebsart']);
|
||||
|
||||
if (!is_int($daten['Sollleistung_W'])) {
|
||||
throw new InvalidArgumentException('Sollleistung_W muss eine Ganzzahl sein.');
|
||||
if ($daten['Sollleistung_W'] !== null && !is_int($daten['Sollleistung_W'])) {
|
||||
throw new InvalidArgumentException('Sollleistung_W muss eine Ganzzahl oder null sein.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +40,7 @@ final class Nachrichtenvertrag
|
||||
$daten,
|
||||
[
|
||||
'Kopf',
|
||||
'Betriebsart',
|
||||
'PrioritaetPV',
|
||||
'PrioritaetPeak',
|
||||
'Leistungswerte_W',
|
||||
@@ -49,6 +54,7 @@ final class Nachrichtenvertrag
|
||||
);
|
||||
|
||||
self::pruefeKopf($daten['Kopf']);
|
||||
self::pruefeBetriebsart($daten['Betriebsart']);
|
||||
self::pruefePrioritaet($daten['PrioritaetPV'], 'PrioritaetPV');
|
||||
self::pruefePrioritaet($daten['PrioritaetPeak'], 'PrioritaetPeak');
|
||||
self::pruefeLeistungswerte($daten['Leistungswerte_W']);
|
||||
@@ -64,6 +70,33 @@ final class Nachrichtenvertrag
|
||||
self::pruefeZustand($daten['Zustand']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
|
||||
*/
|
||||
public static function enthaeltLeistungswert(array $leistungswerte, int $leistung): bool
|
||||
{
|
||||
foreach ($leistungswerte as $eintrag) {
|
||||
if (is_int($eintrag)) {
|
||||
if ($eintrag === $leistung) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($eintrag)
|
||||
&& isset($eintrag['Von_W'], $eintrag['Bis_W'])
|
||||
&& is_int($eintrag['Von_W'])
|
||||
&& is_int($eintrag['Bis_W'])
|
||||
&& $leistung >= $eintrag['Von_W']
|
||||
&& $leistung <= $eintrag['Bis_W']
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $kopf
|
||||
*/
|
||||
@@ -93,6 +126,18 @@ final class Nachrichtenvertrag
|
||||
}
|
||||
}
|
||||
|
||||
/** @param mixed $betriebsart */
|
||||
private static function pruefeBetriebsart($betriebsart): void
|
||||
{
|
||||
if (!is_string($betriebsart) || !in_array(
|
||||
$betriebsart,
|
||||
[self::BETRIEBSART_PV, self::BETRIEBSART_PEAK],
|
||||
true
|
||||
)) {
|
||||
throw new InvalidArgumentException('Betriebsart muss PV oder Peak sein.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $prioritaet
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class StoerungsSnapshot
|
||||
{
|
||||
public const VERSION = '1.0';
|
||||
|
||||
private const SCHWEREGRADE = [
|
||||
'critical' => 0,
|
||||
'error' => 1,
|
||||
'warning' => 2,
|
||||
'info' => 3,
|
||||
];
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $meldungen
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function erstellen(
|
||||
string $installationID,
|
||||
int $managerID,
|
||||
string $rolle,
|
||||
bool $managerAktiv,
|
||||
array $meldungen,
|
||||
?int $zeitpunkt = null
|
||||
): array {
|
||||
if (preg_match(
|
||||
'/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
|
||||
$installationID
|
||||
) !== 1) {
|
||||
throw new InvalidArgumentException('Die Installations-ID muss eine UUIDv4 sein.');
|
||||
}
|
||||
if ($managerID <= 0) {
|
||||
throw new InvalidArgumentException('Die Manager-ID muss positiv sein.');
|
||||
}
|
||||
if (!in_array($rolle, ['standalone', 'main', 'submanager'], true)) {
|
||||
throw new InvalidArgumentException('Die Managerrolle ist ungueltig.');
|
||||
}
|
||||
|
||||
$eindeutig = [];
|
||||
foreach ($meldungen as $meldung) {
|
||||
$normalisiert = self::normalisiereMeldung($meldung);
|
||||
$schluessel = $normalisiert['sourceId'] . "\0" . $normalisiert['code'];
|
||||
$eindeutig[$schluessel] = $normalisiert;
|
||||
}
|
||||
$meldungen = array_values($eindeutig);
|
||||
usort($meldungen, static function (array $links, array $rechts): int {
|
||||
$schweregrad = self::SCHWEREGRADE[$links['severity']]
|
||||
<=> self::SCHWEREGRADE[$rechts['severity']];
|
||||
if ($schweregrad !== 0) {
|
||||
return $schweregrad;
|
||||
}
|
||||
|
||||
return [$links['sourceName'], $links['code']]
|
||||
<=> [$rechts['sourceName'], $rechts['code']];
|
||||
});
|
||||
// Das Portal akzeptiert hoechstens 100 Meldungen; die hoechste Prioritaet bleibt erhalten.
|
||||
$meldungen = array_slice($meldungen, 0, 100);
|
||||
|
||||
return [
|
||||
'version' => self::VERSION,
|
||||
'installationId' => strtolower($installationID),
|
||||
'capturedAt' => gmdate('Y-m-d\TH:i:s\Z', $zeitpunkt ?? time()),
|
||||
'manager' => [
|
||||
'instanceId' => $managerID,
|
||||
'role' => $rolle,
|
||||
'active' => $managerAktiv,
|
||||
],
|
||||
'faults' => $meldungen,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $snapshot */
|
||||
public static function zustandHash(array $snapshot): string
|
||||
{
|
||||
unset($snapshot['capturedAt']);
|
||||
|
||||
return hash('sha256', json_encode(
|
||||
$snapshot,
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meldung
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function normalisiereMeldung(array $meldung): array
|
||||
{
|
||||
$felder = ['sourceType', 'sourceId', 'sourceName', 'code', 'severity', 'message'];
|
||||
if (array_diff($felder, array_keys($meldung)) !== []
|
||||
|| array_diff(array_keys($meldung), $felder) !== []
|
||||
) {
|
||||
throw new InvalidArgumentException('Eine Stoerungsmeldung hat ungueltige Felder.');
|
||||
}
|
||||
|
||||
$normalisiert = [];
|
||||
foreach ($felder as $feld) {
|
||||
if (!is_string($meldung[$feld])) {
|
||||
throw new InvalidArgumentException('Stoerungsfelder muessen Texte sein.');
|
||||
}
|
||||
$normalisiert[$feld] = trim((string) preg_replace('/\s+/', ' ', $meldung[$feld]));
|
||||
}
|
||||
if ($normalisiert['sourceType'] === ''
|
||||
|| $normalisiert['sourceId'] === ''
|
||||
|| $normalisiert['sourceName'] === ''
|
||||
|| $normalisiert['code'] === ''
|
||||
|| $normalisiert['message'] === ''
|
||||
) {
|
||||
throw new InvalidArgumentException('Stoerungsfelder duerfen nicht leer sein.');
|
||||
}
|
||||
if (!array_key_exists($normalisiert['severity'], self::SCHWEREGRADE)) {
|
||||
throw new InvalidArgumentException('Der Stoerungsschweregrad ist ungueltig.');
|
||||
}
|
||||
if (strlen($normalisiert['sourceType']) > 40
|
||||
|| strlen($normalisiert['sourceId']) > 120
|
||||
|| strlen($normalisiert['sourceName']) > 160
|
||||
|| strlen($normalisiert['code']) > 120
|
||||
|| strlen($normalisiert['message']) > 500
|
||||
) {
|
||||
throw new InvalidArgumentException('Mindestens ein Stoerungsfeld ist zu lang.');
|
||||
}
|
||||
|
||||
return $normalisiert;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,11 @@ namespace Belevo\EnelixEMS;
|
||||
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
|
||||
*/
|
||||
public function ManagerdatenEmpfangen(array $daten): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use Belevo\EnelixEMS\Anlagentopologie;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AnlagentopologieTest extends TestCase
|
||||
{
|
||||
public function testHybridwechselrichterVerknuepftPvUndBatterieMitGemeinsamerAcGrenze(): void
|
||||
{
|
||||
$topologie = Anlagentopologie::ausJson(
|
||||
json_encode([[
|
||||
'ID' => 'hybrid-1',
|
||||
'Name' => 'Hybrid Ost',
|
||||
'Typ' => 'hybrid',
|
||||
'ACNennleistung_kW' => 10.0,
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
json_encode([
|
||||
[
|
||||
'ID' => 'pv-ost',
|
||||
'Name' => 'Ostdach',
|
||||
'DCLeistung_kWp' => 6.4,
|
||||
'Neigung_Grad' => 25.0,
|
||||
'Azimut_Grad' => -90.0,
|
||||
'WechselrichterID' => 'hybrid-1',
|
||||
'MPPT' => 'A',
|
||||
'Modulanzahl' => 16,
|
||||
'Modulleistung_Wp' => 400.0,
|
||||
],
|
||||
[
|
||||
'ID' => 'pv-west',
|
||||
'Name' => 'Westdach',
|
||||
'DCLeistung_kWp' => 6.4,
|
||||
'Neigung_Grad' => 25.0,
|
||||
'Azimut_Grad' => 90.0,
|
||||
'WechselrichterID' => 'hybrid-1',
|
||||
'MPPT' => 'B',
|
||||
'Modulanzahl' => 16,
|
||||
'Modulleistung_Wp' => 400.0,
|
||||
],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
json_encode([[
|
||||
'ID' => 'bat-1',
|
||||
'Name' => 'Hausspeicher',
|
||||
'Nennkapazitaet_kWh' => 13.5,
|
||||
'Nutzkapazitaet_kWh' => 12.8,
|
||||
'MaxLadeleistung_kW' => 5.0,
|
||||
'MaxEntladeleistung_kW' => 5.0,
|
||||
'WechselrichterID' => 'hybrid-1',
|
||||
'Kopplung' => 'hybrid',
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||
);
|
||||
|
||||
self::assertSame(Anlagentopologie::VERSION, $topologie['version']);
|
||||
self::assertSame(12.8, $topologie['summary']['pvDcKwp']);
|
||||
self::assertSame(10.0, $topologie['summary']['pvAcKw']);
|
||||
self::assertSame(12.8, $topologie['summary']['usableBatteryKwh']);
|
||||
self::assertArrayNotHasKey('acExportLimitKw', $topologie['inverters'][0]);
|
||||
self::assertArrayNotHasKey('acImportLimitKw', $topologie['inverters'][0]);
|
||||
self::assertSame([[
|
||||
'inverterId' => 'hybrid-1',
|
||||
'acRatedKw' => 10.0,
|
||||
'pvArrayIds' => ['pv-ost', 'pv-west'],
|
||||
'batteryIds' => ['bat-1'],
|
||||
]], $topologie['sharedAcLimits']);
|
||||
}
|
||||
|
||||
public function testLeereTopologieBleibtFuerBestandsinstallationenGueltig(): void
|
||||
{
|
||||
$topologie = Anlagentopologie::ausJson(
|
||||
'[]',
|
||||
'[]',
|
||||
'[]',
|
||||
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||
);
|
||||
|
||||
self::assertSame([], $topologie['inverters']);
|
||||
self::assertSame([], $topologie['pvArrays']);
|
||||
self::assertSame([], $topologie['batteries']);
|
||||
self::assertSame(0.0, $topologie['summary']['pvDcKwp']);
|
||||
}
|
||||
|
||||
public function testPvFlaecheBenoetigtEinenVorhandenenPassendenWechselrichter(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('referenzierter Wechselrichter ist nicht vorhanden');
|
||||
|
||||
Anlagentopologie::ausJson(
|
||||
'[]',
|
||||
json_encode([[
|
||||
'ID' => 'pv-1',
|
||||
'DCLeistung_kWp' => 5.0,
|
||||
'Neigung_Grad' => 30.0,
|
||||
'Azimut_Grad' => 0.0,
|
||||
'WechselrichterID' => 'wr-fehlt',
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
'[]',
|
||||
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||
);
|
||||
}
|
||||
|
||||
public function testDcBatterieBenoetigtEinenHybridwechselrichter(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('DC- und Hybridkopplung');
|
||||
|
||||
Anlagentopologie::ausJson(
|
||||
json_encode([[
|
||||
'ID' => 'bat-wr',
|
||||
'Typ' => 'battery',
|
||||
'ACNennleistung_kW' => 5.0,
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
'[]',
|
||||
json_encode([[
|
||||
'ID' => 'bat-1',
|
||||
'Nennkapazitaet_kWh' => 10.0,
|
||||
'Nutzkapazitaet_kWh' => 9.0,
|
||||
'MaxLadeleistung_kW' => 4.0,
|
||||
'MaxEntladeleistung_kW' => 4.0,
|
||||
'WechselrichterID' => 'bat-wr',
|
||||
'Kopplung' => 'dc',
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||
);
|
||||
}
|
||||
|
||||
public function testModuldatenMuessenGemeinsamGesetztWerden(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Modulanzahl und Modulleistung');
|
||||
|
||||
Anlagentopologie::ausJson(
|
||||
json_encode([[
|
||||
'ID' => 'pv-wr',
|
||||
'Typ' => 'pv',
|
||||
'ACNennleistung_kW' => 5.0,
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
json_encode([[
|
||||
'ID' => 'pv-1',
|
||||
'DCLeistung_kWp' => 6.0,
|
||||
'Neigung_Grad' => 30.0,
|
||||
'Azimut_Grad' => 0.0,
|
||||
'WechselrichterID' => 'pv-wr',
|
||||
'Modulanzahl' => 15,
|
||||
'Modulleistung_Wp' => 0.0,
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
'[]',
|
||||
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use Belevo\EnelixEMS\BatterieRegler;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class BatterieReglerTest extends TestCase
|
||||
{
|
||||
public function testLeistungsbereichBleibtInnerhalbDynamischerGrenzen(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[['Von_W' => -999, 'Bis_W' => 1000]],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, false, 50.0, 20.0, 10.0, false,
|
||||
0.0, 0.0, 1000.9, 999.9
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testHystereseSchaltetAnDenBisherigenGrenzen(): void
|
||||
{
|
||||
self::assertTrue(BatterieRegler::hystereseAktiv(20.0, 20.0, 2.0, false));
|
||||
self::assertTrue(BatterieRegler::hystereseAktiv(21.0, 20.0, 2.0, true));
|
||||
self::assertFalse(BatterieRegler::hystereseAktiv(22.1, 20.0, 2.0, true));
|
||||
}
|
||||
|
||||
public function testPvAngebotVerwendetStufenloseBereicheMitBisherigerSocLogik(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[['Von_W' => -1000, 'Bis_W' => 1000]],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, false, 50.0, 20.0, 10.0, false,
|
||||
0.0, 0.0, 1000.0, 1000.0
|
||||
)
|
||||
);
|
||||
self::assertSame(
|
||||
[1000],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, false, 19.0, 20.0, 10.0, true,
|
||||
0.0, 0.0, 1000.0, 1000.0
|
||||
)
|
||||
);
|
||||
self::assertSame(
|
||||
[['Von_W' => -1000, 'Bis_W' => 0]],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, false, 100.0, 20.0, 10.0, false,
|
||||
0.0, 0.0, 1000.0, 1000.0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testPeakAngebotBeruecksichtigtNetzUndAktuelleBatterieleistung(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[-500],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, true, 50.0, 20.0, 10.0, false,
|
||||
700.0, 200.0, 1000.0, 1000.0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testPeakEinschraenkungenWerdenAlsBereicheGemeldet(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[['Von_W' => -1000, 'Bis_W' => 0]],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, true, 21.0, 20.0, 10.0, true,
|
||||
0.0, 0.0, 1000.0, 1000.0
|
||||
)
|
||||
);
|
||||
self::assertSame(
|
||||
[['Von_W' => 0, 'Bis_W' => 1000]],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, true, 10.0, 20.0, 10.0, true,
|
||||
0.0, 0.0, 1000.0, 1000.0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testWechselrichtersteuerungBietetNurNull(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[0],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, false, false, 50.0, 20.0, 10.0, false,
|
||||
0.0, 0.0, 1000.0, 1000.0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testHerstellerunabhaengigeRegisterwerte(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[
|
||||
'Ladeleistung' => 1500,
|
||||
'Entladeleistung' => 0,
|
||||
'Modus' => 0,
|
||||
'Management' => 1,
|
||||
],
|
||||
BatterieRegler::registerwerte(
|
||||
BatterieRegler::TYP_HERSTELLERUNABHAENGIG,
|
||||
BatterieRegler::STEUERUNG_ENELIX,
|
||||
1500
|
||||
)
|
||||
);
|
||||
self::assertSame(
|
||||
[
|
||||
'Ladeleistung' => 0,
|
||||
'Entladeleistung' => 1500,
|
||||
'Modus' => 1,
|
||||
'Management' => 1,
|
||||
],
|
||||
BatterieRegler::registerwerte(
|
||||
BatterieRegler::TYP_HERSTELLERUNABHAENGIG,
|
||||
BatterieRegler::STEUERUNG_ENELIX,
|
||||
-1500
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testGoodweRegisterwerte(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['Leistung' => 750, 'Modus' => 11, 'Management' => 2],
|
||||
BatterieRegler::registerwerte(
|
||||
BatterieRegler::TYP_GOODWE,
|
||||
BatterieRegler::STEUERUNG_ENELIX,
|
||||
750
|
||||
)
|
||||
);
|
||||
self::assertSame(
|
||||
['Leistung' => 750, 'Modus' => 12, 'Management' => 2],
|
||||
BatterieRegler::registerwerte(
|
||||
BatterieRegler::TYP_GOODWE,
|
||||
BatterieRegler::STEUERUNG_ENELIX,
|
||||
-750
|
||||
)
|
||||
);
|
||||
self::assertSame(
|
||||
['Leistung' => 0, 'Management' => 1],
|
||||
BatterieRegler::registerwerte(
|
||||
BatterieRegler::TYP_GOODWE,
|
||||
BatterieRegler::STEUERUNG_WECHSELRICHTER,
|
||||
-750
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testSolarEdgeRegisterwerte(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[
|
||||
'Ladeleistung' => 0,
|
||||
'Entladeleistung' => 500,
|
||||
'Modus' => 4,
|
||||
'Management' => 4,
|
||||
],
|
||||
BatterieRegler::registerwerte(
|
||||
BatterieRegler::TYP_SOLAREDGE,
|
||||
BatterieRegler::STEUERUNG_ENELIX,
|
||||
-500
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testSigenergySkaliertLeistungAufKilowatt(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[
|
||||
'Ladeleistung' => 1.5,
|
||||
'Entladeleistung' => 0.0,
|
||||
'Modus' => 3,
|
||||
'Management' => 1,
|
||||
],
|
||||
BatterieRegler::registerwerte(
|
||||
BatterieRegler::TYP_SIGENERGY,
|
||||
BatterieRegler::STEUERUNG_ENELIX,
|
||||
1500
|
||||
)
|
||||
);
|
||||
self::assertSame(
|
||||
[
|
||||
'Ladeleistung' => 0.0,
|
||||
'Entladeleistung' => 1.5,
|
||||
'Modus' => 6,
|
||||
'Management' => 1,
|
||||
],
|
||||
BatterieRegler::registerwerte(
|
||||
BatterieRegler::TYP_SIGENERGY,
|
||||
BatterieRegler::STEUERUNG_ENELIX,
|
||||
-1500
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testUngueltigeGrenzenWerdenAbgewiesen(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, false, 50.0, 20.0, 10.0, false,
|
||||
0.0, 0.0, -1.0, 1000.0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
require_once dirname(__DIR__) . '/examples/Demoanlage/bootstrap.php';
|
||||
|
||||
final class DemoanlageBootstrapTest extends TestCase
|
||||
{
|
||||
public function testEnergieflussEnthaeltAlleAnlagenteile(): void
|
||||
{
|
||||
$html = enelixDemoRenderFlow([
|
||||
'hour' => 12.5,
|
||||
'pv' => 6500.0,
|
||||
'base' => 900.0,
|
||||
'grid' => -800.0,
|
||||
'boiler1' => 2400,
|
||||
'boiler2' => 1500,
|
||||
'dryer' => 900,
|
||||
'buffer' => 1800,
|
||||
'battery' => -1200,
|
||||
'temp1' => 51.2,
|
||||
'temp2' => 48.7,
|
||||
'bufferTemp' => 39.4,
|
||||
'batterySoc' => 63.5,
|
||||
'manager' => 'PV',
|
||||
]);
|
||||
|
||||
self::assertStringContainsString('Enelix 2 Energiefluss', $html);
|
||||
self::assertStringContainsString('Boiler 1', $html);
|
||||
self::assertStringContainsString('Boiler 2', $html);
|
||||
self::assertStringContainsString('Entfeuchter', $html);
|
||||
self::assertStringContainsString('Pufferspeicher', $html);
|
||||
self::assertStringContainsString('Batterie', $html);
|
||||
self::assertStringContainsString('63,5 %', $html);
|
||||
self::assertStringContainsString('Einspeisung', $html);
|
||||
self::assertStringContainsString('12:30', $html);
|
||||
}
|
||||
|
||||
public function testDiagrammStelltMehrereZeitreihenDar(): void
|
||||
{
|
||||
$html = enelixDemoRenderChart(
|
||||
'Leistungsverlauf',
|
||||
[
|
||||
['name' => 'PV', 'color' => '#f4c542', 'values' => [
|
||||
['time' => 100, 'value' => 1000.0],
|
||||
['time' => 200, 'value' => 3000.0],
|
||||
]],
|
||||
['name' => 'Netz', 'color' => '#e95d5d', 'values' => [
|
||||
['time' => 100, 'value' => -500.0],
|
||||
['time' => 200, 'value' => 500.0],
|
||||
]],
|
||||
],
|
||||
'W',
|
||||
100,
|
||||
200
|
||||
);
|
||||
|
||||
self::assertStringContainsString('<svg', $html);
|
||||
self::assertStringContainsString('Leistungsverlauf', $html);
|
||||
self::assertStringContainsString('PV', $html);
|
||||
self::assertStringContainsString('Netz', $html);
|
||||
self::assertSame(2, substr_count($html, '<polyline'));
|
||||
}
|
||||
|
||||
public function testNativeEnergieflussKnotenHatVollstaendigesSchema(): void
|
||||
{
|
||||
self::assertSame([
|
||||
'VariableConfigType' => 0,
|
||||
'VariableID' => 12345,
|
||||
'SecondaryVariableID' => 1,
|
||||
'Name' => 'PV-Anlage',
|
||||
'Type' => 'solar',
|
||||
'Grouped' => false,
|
||||
'CalculationType' => 0,
|
||||
'InfoVariableID' => 54321,
|
||||
'Invert' => false,
|
||||
], enelixDemoEnergyNode(12345, 'PV-Anlage', 'solar', 54321));
|
||||
self::assertSame(
|
||||
1,
|
||||
enelixDemoEnergyNode(12345, 'PV-Anlage', 'solar')['InfoVariableID']
|
||||
);
|
||||
}
|
||||
|
||||
public function testNativeDiagrammDatenreiheHatSymconFormat(): void
|
||||
{
|
||||
self::assertSame([
|
||||
'variableID' => 12345,
|
||||
'fillColor' => 'clear',
|
||||
'strokeColor' => '#f4c542',
|
||||
'timeOffset' => 0,
|
||||
'visible' => true,
|
||||
'title' => 'PV',
|
||||
'type' => 'line',
|
||||
'side' => 'left',
|
||||
'profile' => '~Power',
|
||||
], enelixDemoChartDataset(12345, 'PV', '#f4c542', '~Power'));
|
||||
}
|
||||
|
||||
public function testDashboardVerwendetNativeEnergieverteilungUndNeueBoilerlisten(): void
|
||||
{
|
||||
$source = file_get_contents(dirname(__DIR__) . '/examples/Demoanlage/bootstrap.php');
|
||||
|
||||
self::assertIsString($source);
|
||||
self::assertStringContainsString("IPS_SetHidden(\$legacyFlow, true);", $source);
|
||||
self::assertStringContainsString("'EnergieverteilungNativ'", $source);
|
||||
self::assertStringContainsString(
|
||||
'enelixDemoConfigureVisualization($root, $energyDistribution);',
|
||||
$source
|
||||
);
|
||||
self::assertStringContainsString('ENELIX_DEMO_DUMMY_MODULE', $source);
|
||||
self::assertStringContainsString("'Boiler1Liste'", $source);
|
||||
self::assertStringContainsString("'Boiler2Liste'", $source);
|
||||
self::assertStringContainsString("'PufferListe'", $source);
|
||||
self::assertStringContainsString("'BatterieListe'", $source);
|
||||
self::assertStringContainsString('ENELIX_DEMO_BUFFER_MODULE', $source);
|
||||
self::assertStringContainsString('ENELIX_DEMO_BATTERY_MODULE', $source);
|
||||
self::assertStringContainsString("'AnlagenWechselrichter'", $source);
|
||||
self::assertStringContainsString("'AnlagenPVFlaechen'", $source);
|
||||
self::assertStringContainsString("'AnlagenBatterien'", $source);
|
||||
self::assertStringContainsString("'EnergyPieAnzeigen' => true", $source);
|
||||
self::assertStringContainsString("'EnergiediagrammeAnzeigen' => true", $source);
|
||||
self::assertStringContainsString("'FunFactsAnzeigen' => true", $source);
|
||||
self::assertStringContainsString("'EnergieflussAnzeigen' => true", $source);
|
||||
self::assertStringContainsString("'LeistungVariableID' => \$batteryActual", $source);
|
||||
self::assertStringContainsString("'SOCVariableID' => \$batterySoc", $source);
|
||||
self::assertStringContainsString("'PrognoseSendeintervall' => 60", $source);
|
||||
self::assertStringContainsString("'IstleistungVariableID' => \$inverterActual", $source);
|
||||
self::assertStringContainsString("'BegrenzungVariableID' => \$inverterLimit", $source);
|
||||
self::assertStringContainsString("'Begrenzungsart' => 'watt'", $source);
|
||||
self::assertStringContainsString("'EinspeisebegrenzungAktiv' => true", $source);
|
||||
self::assertStringContainsString("'Einspeisegrenze' => 3000.0", $source);
|
||||
self::assertStringContainsString("'PrognoseAktiv' => false", $source);
|
||||
self::assertStringContainsString("'NetzfahrplanAktiv' => false", $source);
|
||||
self::assertStringNotContainsString("'Einspeisetoleranz'", $source);
|
||||
self::assertStringContainsString("'demo-hybrid-1'", $source);
|
||||
self::assertStringContainsString("'demo-pv-1'", $source);
|
||||
self::assertStringContainsString("'demo-battery-1'", $source);
|
||||
self::assertStringContainsString("'EnergieflussPVkW'", $source);
|
||||
self::assertStringContainsString("'~Power'", $source);
|
||||
self::assertStringContainsString("'~Electricity'", $source);
|
||||
self::assertStringContainsString(
|
||||
"enelixDemoEnergyNode(\$flowPv, 'PV-Anlage', 'solar')",
|
||||
$source
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"enelixDemoEnergyNode(\$flowBase, 'Haus-Grundlast', 'consumer')",
|
||||
$source
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"enelixDemoEnergyNode(\$flowDryer, 'Entfeuchter', 'consumer')",
|
||||
$source
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"enelixDemoEnergyNode(\$flowBuffer, 'Pufferspeicher', 'consumer', \$bufferTemp)",
|
||||
$source
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"enelixDemoEnergyNode(\$flowBattery, 'Batteriespeicher', 'battery', \$batterySoc)",
|
||||
$source
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"\$consumerIds = [\$boiler1, \$boiler2, \$dryer, \$buffer, \$battery]",
|
||||
$source
|
||||
);
|
||||
self::assertStringContainsString("'consumerIds' => \$consumerIds", $source);
|
||||
self::assertStringContainsString("'VerbraucherZuordnung' => '[]'", $source);
|
||||
self::assertStringContainsString(
|
||||
"enelixDemoEnsureChart(\$history, 'LeistungsdiagrammNativ'",
|
||||
$source
|
||||
);
|
||||
self::assertStringContainsString('IPS_SetHidden($powerChart, true);', $source);
|
||||
self::assertStringContainsString(
|
||||
"enelixDemoLinkInstanceVariables(\$boiler1List, \$boiler1, \$temp1);",
|
||||
$source
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"foreach (['Boiler1Link', 'Boiler2Link'] as \$oldLinkIdent)",
|
||||
$source
|
||||
);
|
||||
}
|
||||
|
||||
public function testDynamischeUmgebungBleibtBegrenztUndVeraendertDenLastgang(): void
|
||||
{
|
||||
$static = enelixDemoEnvironment(12.0, 1000.0, 30, false);
|
||||
self::assertSame(['base' => 1000.0, 'cloud' => 30.0, 'outside' => 6.0], $static);
|
||||
|
||||
$morning = enelixDemoEnvironment(7.5, 1000.0, 30, true);
|
||||
$night = enelixDemoEnvironment(2.0, 1000.0, 30, true);
|
||||
self::assertGreaterThan($night['base'], $morning['base']);
|
||||
self::assertGreaterThanOrEqual(0.0, $morning['cloud']);
|
||||
self::assertLessThanOrEqual(100.0, $morning['cloud']);
|
||||
self::assertNotSame($morning['outside'], $night['outside']);
|
||||
}
|
||||
|
||||
public function testBatterieregisterWerdenMitKorrektemVorzeichenAbgebildet(): void
|
||||
{
|
||||
self::assertSame(1500.0, enelixDemoBatteryRegisterPower(2, 11, 1500.0));
|
||||
self::assertSame(-1500.0, enelixDemoBatteryRegisterPower(2, 12, 1500.0));
|
||||
self::assertSame(0.0, enelixDemoBatteryRegisterPower(1, 11, 1500.0));
|
||||
self::assertSame(0.0, enelixDemoBatteryRegisterPower(2, 0, 1500.0));
|
||||
}
|
||||
|
||||
public function testWechselrichtergrenzeBegrenztNurDieTatsaechlichePvLeistung(): void
|
||||
{
|
||||
self::assertSame(3000.0, enelixDemoLimitPvPower(8000.0, 3000.0));
|
||||
self::assertSame(2500.0, enelixDemoLimitPvPower(2500.0, 3000.0));
|
||||
self::assertSame(0.0, enelixDemoLimitPvPower(-100.0, 3000.0));
|
||||
self::assertSame(0.0, enelixDemoLimitPvPower(8000.0, -100.0));
|
||||
}
|
||||
|
||||
public function testRollbackIstExplizitBestaetigungspflichtig(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Rollback nur mit');
|
||||
|
||||
enelixDemoRemove();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EaseeGatewayProtokollTest extends TestCase
|
||||
{
|
||||
public function testVerschachtelteSignalRBeobachtungenWerdenExtrahiert(): void
|
||||
{
|
||||
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
|
||||
'arguments' => [
|
||||
'EH123456',
|
||||
[
|
||||
['id' => 109, 'value' => 3],
|
||||
['ObservationId' => 110, 'Value' => 30],
|
||||
],
|
||||
],
|
||||
], ['EH123456']);
|
||||
|
||||
self::assertSame([
|
||||
['Seriennummer' => 'EH123456', 'ID' => 109, 'Wert' => 3],
|
||||
['Seriennummer' => 'EH123456', 'ID' => 110, 'Wert' => 30],
|
||||
], $beobachtungen);
|
||||
}
|
||||
|
||||
public function testNichtAbonnierteStationenUndUnbekannteIdsWerdenIgnoriert(): void
|
||||
{
|
||||
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
|
||||
[
|
||||
'serialNumber' => 'EH999999',
|
||||
'id' => 109,
|
||||
'value' => 3,
|
||||
],
|
||||
[
|
||||
'serialNumber' => 'EH123456',
|
||||
'id' => 999,
|
||||
'value' => 1,
|
||||
],
|
||||
], ['EH123456']);
|
||||
|
||||
self::assertSame([], $beobachtungen);
|
||||
}
|
||||
|
||||
public function testRestBeobachtungenVerwendenAngefragteSeriennummer(): void
|
||||
{
|
||||
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
|
||||
['id' => 109, 'value' => 1],
|
||||
['observationId' => 110, 'value' => 30],
|
||||
], ['EU2FGKEU'], 'EU2FGKEU');
|
||||
|
||||
self::assertSame([
|
||||
['Seriennummer' => 'EU2FGKEU', 'ID' => 109, 'Wert' => 1],
|
||||
['Seriennummer' => 'EU2FGKEU', 'ID' => 110, 'Wert' => 30],
|
||||
], $beobachtungen);
|
||||
}
|
||||
|
||||
public function testFreigabediagnoseWirdAusBeobachtungenExtrahiert(): void
|
||||
{
|
||||
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
|
||||
'serialNumber' => 'EH123456',
|
||||
'observations' => [
|
||||
['id' => 42, 'value' => true],
|
||||
['id' => 43, 'value' => true],
|
||||
['id' => 96, 'value' => 55],
|
||||
],
|
||||
], ['EH123456']);
|
||||
|
||||
self::assertSame([
|
||||
['Seriennummer' => 'EH123456', 'ID' => 42, 'Wert' => true],
|
||||
['Seriennummer' => 'EH123456', 'ID' => 43, 'Wert' => true],
|
||||
['Seriennummer' => 'EH123456', 'ID' => 96, 'Wert' => 55],
|
||||
], $beobachtungen);
|
||||
}
|
||||
|
||||
public function testSeriennummerWirdNormalisiert(): void
|
||||
{
|
||||
self::assertSame(
|
||||
'EH123456',
|
||||
EaseeGatewayProtokoll::seriennummer(' eh123456 ')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?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 testFreigabeUndGrundOhneLadestromKommenAusEaseeBeobachtungen(): void
|
||||
{
|
||||
$status = EaseeLadestatus::ausBeobachtungen([
|
||||
'31' => true,
|
||||
'42' => true,
|
||||
'43' => true,
|
||||
'96' => 55,
|
||||
'102' => true,
|
||||
'109' => 2,
|
||||
], 16);
|
||||
|
||||
self::assertTrue($status['EaseeAktiviert']);
|
||||
self::assertTrue($status['AutorisierungErforderlich']);
|
||||
self::assertTrue($status['RemoteStartErforderlich']);
|
||||
self::assertTrue($status['SmartCharging']);
|
||||
self::assertSame(55, $status['GrundKeinLadestrom']);
|
||||
}
|
||||
|
||||
public function testNurSolarladenIstFestErzwungen(): void
|
||||
{
|
||||
self::assertFalse(EaseeLadestatus::solarladenErzwungen(0));
|
||||
self::assertTrue(EaseeLadestatus::solarladenErzwungen(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EaseeModuleStrukturTest extends TestCase
|
||||
{
|
||||
public function testGatewayUndLadestationSindVollstaendigeModule(): void
|
||||
{
|
||||
foreach (['EaseeGateway', 'LadestationGateway'] as $modul) {
|
||||
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
|
||||
self::assertFileExists(__DIR__ . '/../' . $modul . '/' . $datei);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testDatenSchnittstellenPassenZusammen(): void
|
||||
{
|
||||
$gateway = $this->module('EaseeGateway');
|
||||
$ladestation = $this->module('LadestationGateway');
|
||||
|
||||
self::assertContains(
|
||||
'{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}',
|
||||
$gateway['implemented']
|
||||
);
|
||||
self::assertContains(
|
||||
'{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}',
|
||||
$gateway['childRequirements']
|
||||
);
|
||||
self::assertContains(
|
||||
'{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}',
|
||||
$ladestation['parentRequirements']
|
||||
);
|
||||
self::assertContains(
|
||||
'{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}',
|
||||
$ladestation['implemented']
|
||||
);
|
||||
}
|
||||
|
||||
public function testZugangsdatenLiegenNurImGateway(): void
|
||||
{
|
||||
$gatewayForm = (string) file_get_contents(
|
||||
__DIR__ . '/../EaseeGateway/form.json'
|
||||
);
|
||||
$ladestationForm = (string) file_get_contents(
|
||||
__DIR__ . '/../LadestationGateway/form.json'
|
||||
);
|
||||
|
||||
self::assertStringContainsString('PasswordTextBox', $gatewayForm);
|
||||
self::assertStringNotContainsString('PasswordTextBox', $ladestationForm);
|
||||
self::assertStringNotContainsString('Username', $ladestationForm);
|
||||
}
|
||||
|
||||
public function testLadestationVerwendetApiStatusUndGemeinsamenVertrag(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(
|
||||
__DIR__ . '/../LadestationGateway/module.php'
|
||||
);
|
||||
|
||||
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
|
||||
self::assertStringContainsString('EaseeLadestatus::ausBeobachtungen', $modul);
|
||||
self::assertStringContainsString(
|
||||
"RegisterPropertyBoolean('Ladefreigabe', true)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString("'FahrzeugVerbunden'", $modul);
|
||||
self::assertStringContainsString("'Phasenzahl'", $modul);
|
||||
self::assertStringNotContainsString('7500', $modul);
|
||||
self::assertStringNotContainsString('Abfrageintervall', $modul);
|
||||
self::assertStringContainsString("'action' => 'StartCharging'", $modul);
|
||||
|
||||
$gateway = (string) file_get_contents(
|
||||
__DIR__ . '/../EaseeGateway/module.php'
|
||||
);
|
||||
self::assertStringContainsString('/commands/start_charging', $gateway);
|
||||
}
|
||||
|
||||
/** @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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use Belevo\EnelixEMS\EinspeiseRegler;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EinspeiseReglerTest extends TestCase
|
||||
{
|
||||
public function testUeberschreitungWirdAufDieGesamtanlageBegrenzt(): void
|
||||
{
|
||||
$ergebnis = EinspeiseRegler::verteile(
|
||||
-15000.0,
|
||||
10000.0,
|
||||
[
|
||||
['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 10000],
|
||||
['ID' => 'wr-2', 'Nennleistung_W' => 5000, 'Istleistung_W' => 5000],
|
||||
]
|
||||
);
|
||||
|
||||
self::assertSame(10000.0, $ergebnis['ZielGesamtleistung_W']);
|
||||
self::assertSame(5000.0, $ergebnis['Abregelbedarf_W']);
|
||||
self::assertSame(6666.667, $ergebnis['Grenzen_W']['wr-1']);
|
||||
self::assertSame(3333.333, $ergebnis['Grenzen_W']['wr-2']);
|
||||
}
|
||||
|
||||
public function testAbregelungFolgtDerTatsaechlichenErzeugung(): void
|
||||
{
|
||||
$ergebnis = EinspeiseRegler::verteile(
|
||||
-10000.0,
|
||||
5000.0,
|
||||
[
|
||||
['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 10000],
|
||||
['ID' => 'wr-2', 'Nennleistung_W' => 10000, 'Istleistung_W' => 0],
|
||||
]
|
||||
);
|
||||
|
||||
self::assertSame(5000.0, $ergebnis['Grenzen_W']['wr-1']);
|
||||
self::assertSame(0.0, $ergebnis['Grenzen_W']['wr-2']);
|
||||
}
|
||||
|
||||
public function testFreierNetzspielraumHebtEineBestehendeBegrenzungAn(): void
|
||||
{
|
||||
$ergebnis = EinspeiseRegler::verteile(
|
||||
-6000.0,
|
||||
10000.0,
|
||||
[
|
||||
['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 4000],
|
||||
['ID' => 'wr-2', 'Nennleistung_W' => 10000, 'Istleistung_W' => 2000],
|
||||
],
|
||||
['wr-1' => 5000, 'wr-2' => 5000]
|
||||
);
|
||||
|
||||
self::assertSame(14000.0, $ergebnis['ZielGesamtleistung_W']);
|
||||
self::assertSame(7000.0, $ergebnis['Grenzen_W']['wr-1']);
|
||||
self::assertSame(7000.0, $ergebnis['Grenzen_W']['wr-2']);
|
||||
}
|
||||
|
||||
public function testRegelungFolgtAuchKleinenAbweichungenStufenlos(): void
|
||||
{
|
||||
$ergebnis = EinspeiseRegler::verteile(
|
||||
-10050.0,
|
||||
10000.0,
|
||||
[['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 9000]],
|
||||
['wr-1' => 8000]
|
||||
);
|
||||
|
||||
self::assertSame(7950.0, $ergebnis['ZielGesamtleistung_W']);
|
||||
}
|
||||
|
||||
public function testStellwerteWerdenInProzentOderWattAusgegeben(): void
|
||||
{
|
||||
self::assertSame(62.5, EinspeiseRegler::stellwert(5000.0, 8000.0, 'percent'));
|
||||
self::assertSame(5000.0, EinspeiseRegler::stellwert(5000.0, 8000.0, 'watt'));
|
||||
}
|
||||
|
||||
public function testUngueltigeBegrenzungsartWirdAbgelehnt(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
EinspeiseRegler::stellwert(5000.0, 8000.0, 'volt');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use Belevo\EnelixEMS\EnergieMessung;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EnergieMessungTest extends TestCase
|
||||
{
|
||||
public function testIntegriertVierLeistungenMitVorzeichenrichtung(): void
|
||||
{
|
||||
$werte = EnergieMessung::energieDeltas([
|
||||
'PV' => 7200.0,
|
||||
'Haus' => 3600.0,
|
||||
'Netz' => -1800.0,
|
||||
'Batterie' => 900.0,
|
||||
], 300);
|
||||
|
||||
self::assertEqualsWithDelta(0.6, $werte['PVEnergie'], 0.000001);
|
||||
self::assertEqualsWithDelta(0.3, $werte['Hausenergie'], 0.000001);
|
||||
self::assertSame(0.0, $werte['NetzbezugEnergie']);
|
||||
self::assertEqualsWithDelta(0.15, $werte['EinspeisungEnergie'], 0.000001);
|
||||
self::assertEqualsWithDelta(0.075, $werte['BatterieLadenEnergie'], 0.000001);
|
||||
self::assertSame(0.0, $werte['BatterieEntladenEnergie']);
|
||||
}
|
||||
|
||||
public function testTrenntBezugUndBatterieentladung(): void
|
||||
{
|
||||
$werte = EnergieMessung::energieDeltas([
|
||||
'PV' => -10.0,
|
||||
'Haus' => -10.0,
|
||||
'Netz' => 1000.0,
|
||||
'Batterie' => -2000.0,
|
||||
], 3600);
|
||||
|
||||
self::assertSame(0.0, $werte['PVEnergie']);
|
||||
self::assertSame(0.0, $werte['Hausenergie']);
|
||||
self::assertSame(1.0, $werte['NetzbezugEnergie']);
|
||||
self::assertSame(0.0, $werte['EinspeisungEnergie']);
|
||||
self::assertSame(0.0, $werte['BatterieLadenEnergie']);
|
||||
self::assertSame(2.0, $werte['BatterieEntladenEnergie']);
|
||||
}
|
||||
|
||||
public function testBerechnetHausverbrauchAusDerAnlagenbilanz(): void
|
||||
{
|
||||
self::assertSame(2700.0, EnergieMessung::hausleistung(4200.0, -800.0, 700.0));
|
||||
self::assertSame(3500.0, EnergieMessung::hausleistung(2000.0, 500.0, -1000.0));
|
||||
self::assertSame(0.0, EnergieMessung::hausleistung(500.0, -1200.0, 0.0));
|
||||
}
|
||||
|
||||
public function testBerechnetHausenergieAusDenZaehlerdeltas(): void
|
||||
{
|
||||
self::assertSame(8.5, EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0));
|
||||
self::assertSame(0.0, EnergieMessung::hausenergie(1.0, 0.0, 5.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
public function testIgnoriertUngueltigeDauerUndNichtEndlicheWerte(): void
|
||||
{
|
||||
$werte = EnergieMessung::energieDeltas([
|
||||
'PV' => INF,
|
||||
'Haus' => NAN,
|
||||
'Netz' => 1000.0,
|
||||
'Batterie' => -1000.0,
|
||||
], -1);
|
||||
|
||||
foreach ($werte as $wert) {
|
||||
self::assertSame(0.0, $wert);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use Belevo\EnelixEMS\LadestationRegler;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LadestationReglerTest extends TestCase
|
||||
{
|
||||
public function testFahrzeugUndDreiPhasenWerdenWieInEnelixEinsErkannt(): void
|
||||
{
|
||||
$status = LadestationRegler::normalisiereStatus([
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 11000.0,
|
||||
], false);
|
||||
|
||||
self::assertTrue($status['FahrzeugVerbunden']);
|
||||
self::assertFalse($status['FahrzeugGeladen']);
|
||||
self::assertSame(3, $status['Fahrzeugstatus']);
|
||||
self::assertSame(LadestationRegler::PHASEN_DREI, $status['Phasenzahl']);
|
||||
}
|
||||
|
||||
public function testEinphasigesFahrzeugWirdUnterhalb7500WErkannt(): void
|
||||
{
|
||||
$status = LadestationRegler::normalisiereStatus([
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 2300.0,
|
||||
], false);
|
||||
|
||||
self::assertSame(LadestationRegler::PHASEN_EIN, $status['Phasenzahl']);
|
||||
self::assertEqualsWithDelta(10.0, $status['Ladestrom_A'], 0.01);
|
||||
}
|
||||
|
||||
public function testGeladenWirdNachVerbindungUnterSechsAmpereErkannt(): void
|
||||
{
|
||||
$status = LadestationRegler::normalisiereStatus([
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 0.0,
|
||||
], true);
|
||||
|
||||
self::assertTrue($status['FahrzeugGeladen']);
|
||||
self::assertSame(4, $status['Fahrzeugstatus']);
|
||||
}
|
||||
|
||||
public function testAbsichtlichGestoppteLadungWirdNichtAlsGeladenErkannt(): void
|
||||
{
|
||||
$status = LadestationRegler::normalisiereStatus([
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 0.0,
|
||||
], true, false, false);
|
||||
|
||||
self::assertFalse($status['FahrzeugGeladen']);
|
||||
self::assertSame(2, $status['Fahrzeugstatus']);
|
||||
}
|
||||
|
||||
public function testErkanntesLadeendeBleibtBisZumAbsteckenErhalten(): void
|
||||
{
|
||||
$status = LadestationRegler::normalisiereStatus([
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 0.0,
|
||||
], true, true, false);
|
||||
|
||||
self::assertTrue($status['FahrzeugGeladen']);
|
||||
self::assertSame(4, $status['Fahrzeugstatus']);
|
||||
}
|
||||
|
||||
public function testBetriebsartUndSolarladenBestimmenDasLeistungsangebot(): void
|
||||
{
|
||||
$solarPV = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, true, false);
|
||||
$normalPV = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, false, false);
|
||||
$solarPeak = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, true, true);
|
||||
$normalPeak = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, false, true);
|
||||
|
||||
self::assertSame(0, $solarPV[0]);
|
||||
self::assertCount(12, $solarPV);
|
||||
self::assertSame([3850], $normalPV);
|
||||
self::assertSame([0], $solarPeak);
|
||||
self::assertSame($solarPV, $normalPeak);
|
||||
}
|
||||
|
||||
public function testPeakKannJedesFahrzeugAufNullSetzen(): void
|
||||
{
|
||||
self::assertSame(0, LadestationRegler::stromFuerLeistung(0, 3));
|
||||
self::assertSame(6, LadestationRegler::stromFuerLeistung(4150, 3));
|
||||
self::assertSame(16, LadestationRegler::stromFuerLeistung(11000, 3));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LadestationStandAloneModulstrukturTest extends TestCase
|
||||
{
|
||||
private const MODULPFAD = __DIR__ . '/../LadestationStandAlone/';
|
||||
|
||||
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
|
||||
{
|
||||
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
|
||||
self::assertFileExists(self::MODULPFAD . $datei);
|
||||
}
|
||||
|
||||
$modul = json_decode(
|
||||
(string) file_get_contents(self::MODULPFAD . 'module.json'),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
self::assertSame('LadestationStandAlone', $modul['name']);
|
||||
self::assertSame('8.0', json_decode(
|
||||
(string) file_get_contents(__DIR__ . '/../library.json'),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
)['compatibility']['version']);
|
||||
}
|
||||
|
||||
public function testGemeinsamerVerbrauchervertragWirdVerwendet(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
|
||||
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
|
||||
self::assertStringContainsString('use VerbraucherBasisTrait;', $modul);
|
||||
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul);
|
||||
self::assertStringContainsString('Nachrichtenvertrag::pruefeVerbraucherdaten', $modul);
|
||||
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul);
|
||||
}
|
||||
|
||||
public function testAlleDreiStandAloneGeraeteUndSichereZugangsdatenSindKonfiguriert(): void
|
||||
{
|
||||
$form = json_decode(
|
||||
(string) file_get_contents(self::MODULPFAD . 'form.json'),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
$json = json_encode($form, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertStringContainsString('go-e Charger (alte API)', $json);
|
||||
self::assertStringContainsString('go-e Charger Gemini', $json);
|
||||
self::assertStringContainsString('Gemini flex', $json);
|
||||
self::assertStringContainsString('smart-me Pico', $json);
|
||||
self::assertStringContainsString('PasswordTextBox', $json);
|
||||
self::assertStringNotContainsString('Testmodus', $json);
|
||||
}
|
||||
|
||||
public function testVisualisierungsbuttonsLoggingUndDiagnoseSindVorhanden(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
|
||||
self::assertStringContainsString(
|
||||
"RegisterPropertyBoolean('Ladefreigabe', true)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString("EnableAction('Ladefreigabe')", $modul);
|
||||
self::assertStringContainsString("EnableAction('Solarladen')", $modul);
|
||||
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul);
|
||||
self::assertStringContainsString("ReadPropertyBoolean('EinstellungenInVisu')", $modul);
|
||||
self::assertStringContainsString('UnregisterVariable($ident)', $modul);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,11 @@ final class LibraryMetadataTest extends TestCase
|
||||
|
||||
$daten = json_decode($inhalt, true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame('Enelix EMS', $daten['name']);
|
||||
self::assertSame('Enelix', $daten['author']);
|
||||
self::assertSame('8.0', $daten['compatibility']['version']);
|
||||
self::assertSame('0.1', $daten['version']);
|
||||
self::assertSame(1, $daten['build']);
|
||||
self::assertGreaterThan(0, $daten['date']);
|
||||
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use Belevo\EnelixEMS\Lizenzpruefung;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LizenzpruefungTest extends TestCase
|
||||
{
|
||||
private const INSTALLATION_ID = '123e4567-e89b-42d3-a456-426614174000';
|
||||
|
||||
public function testStandardLizenzGibtGrundregelungFrei(): void
|
||||
{
|
||||
Lizenzpruefung::pruefeLease(
|
||||
$this->lease('manager_standard'),
|
||||
self::INSTALLATION_ID,
|
||||
0,
|
||||
$this->zeit('2026-09-19T12:00:00Z')
|
||||
);
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testPeakLizenzGibtLastspitzenmodusFrei(): void
|
||||
{
|
||||
Lizenzpruefung::pruefeLease(
|
||||
$this->lease('manager_peak'),
|
||||
self::INSTALLATION_ID,
|
||||
2,
|
||||
$this->zeit('2026-09-19T12:00:00Z')
|
||||
);
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testStandardLizenzSperrtLastspitzenmodus(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Peak Shaving ist nicht lizenziert.');
|
||||
|
||||
Lizenzpruefung::pruefeLease(
|
||||
$this->lease('manager_standard'),
|
||||
self::INSTALLATION_ID,
|
||||
1,
|
||||
$this->zeit('2026-09-19T12:00:00Z')
|
||||
);
|
||||
}
|
||||
|
||||
public function testAbgelaufeneOfflineFreigabeWirdAbgelehnt(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Offline-Freigabe ist abgelaufen.');
|
||||
|
||||
Lizenzpruefung::pruefeLease(
|
||||
$this->lease('manager_peak'),
|
||||
self::INSTALLATION_ID,
|
||||
0,
|
||||
$this->zeit('2026-10-03T12:00:00Z')
|
||||
);
|
||||
}
|
||||
|
||||
public function testAndereInstallationWirdAbgelehnt(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('anderen Installation');
|
||||
|
||||
Lizenzpruefung::pruefeLease(
|
||||
$this->lease('manager_standard'),
|
||||
'223e4567-e89b-42d3-a456-426614174000',
|
||||
0,
|
||||
$this->zeit('2026-09-19T12:00:00Z')
|
||||
);
|
||||
}
|
||||
|
||||
public function testErneuerungWirdAbRefreshAfterFaellig(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
|
||||
self::assertFalse(Lizenzpruefung::erneuerungFaellig(
|
||||
$lease,
|
||||
$this->zeit('2026-09-20T11:59:59Z')
|
||||
));
|
||||
self::assertTrue(Lizenzpruefung::erneuerungFaellig(
|
||||
$lease,
|
||||
$this->zeit('2026-09-20T12:00:00Z')
|
||||
));
|
||||
}
|
||||
|
||||
public function testLeaseOhneManagerBerechtigungWirdAbgelehnt(): void
|
||||
{
|
||||
$lease = $this->lease('consumer_single');
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Manager-Berechtigung fehlt.');
|
||||
Lizenzpruefung::pruefeLease(
|
||||
$lease,
|
||||
self::INSTALLATION_ID,
|
||||
0,
|
||||
$this->zeit('2026-09-19T12:00:00Z')
|
||||
);
|
||||
}
|
||||
|
||||
public function testZweiLizenzierteBoilerWerdenFreigegeben(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'sku' => 'ENX-BOILER-MULTI',
|
||||
'catalog_key' => 'boiler_multi',
|
||||
'quantity' => 2,
|
||||
'status' => 'active',
|
||||
];
|
||||
|
||||
self::assertSame(2, Lizenzpruefung::berechtigungsMenge($lease, 'boiler_multi'));
|
||||
Lizenzpruefung::pruefeVerbrauchermengen($lease, ['boiler_multi' => 2]);
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testDritterBoilerBeiZweiLizenzenWirdAbgelehnt(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'sku' => 'ENX-BOILER-MULTI',
|
||||
'catalog_key' => 'boiler_multi',
|
||||
'quantity' => 2,
|
||||
'status' => 'active',
|
||||
];
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage(
|
||||
'Lizenzkontingent fuer boiler_multi ueberschritten: 3 verwendet, 2 lizenziert.'
|
||||
);
|
||||
Lizenzpruefung::pruefeVerbrauchermengen($lease, ['boiler_multi' => 3]);
|
||||
}
|
||||
|
||||
public function testVerbraucherkontingenteWerdenJeTypSummiertUndGetrenntGeprueft(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'] = array_merge($lease['entitlements'], [
|
||||
[
|
||||
'catalog_key' => 'boiler_multi',
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
],
|
||||
[
|
||||
'catalog_key' => 'boiler_multi',
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
],
|
||||
[
|
||||
'catalog_key' => 'battery',
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
],
|
||||
[
|
||||
'catalog_key' => 'battery',
|
||||
'quantity' => 10,
|
||||
'status' => 'revoked',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(2, Lizenzpruefung::berechtigungsMenge($lease, 'boiler_multi'));
|
||||
self::assertSame(1, Lizenzpruefung::berechtigungsMenge($lease, 'battery'));
|
||||
Lizenzpruefung::pruefeVerbrauchermengen($lease, [
|
||||
'boiler_multi' => 2,
|
||||
'battery' => 1,
|
||||
]);
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testSeparateWaermepumpenlizenzBleibtUnveraendert(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'catalog_key' => 'heat_pump',
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
];
|
||||
$verwendung = ['heat_pump' => 1];
|
||||
|
||||
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
|
||||
$lease,
|
||||
$verwendung
|
||||
);
|
||||
|
||||
self::assertSame($verwendung, $pruefmengen);
|
||||
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
|
||||
}
|
||||
|
||||
public function testFreieBestandslizenzDecktEineWaermepumpeAb(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'catalog_key' => 'consumer_single',
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
];
|
||||
|
||||
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
|
||||
$lease,
|
||||
['heat_pump' => 1]
|
||||
);
|
||||
|
||||
self::assertSame(['heat_pump' => 0, 'consumer_single' => 1], $pruefmengen);
|
||||
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
|
||||
}
|
||||
|
||||
public function testBestandslizenzDarfNichtDoppeltVerwendetWerden(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'catalog_key' => 'consumer_single',
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
];
|
||||
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
|
||||
$lease,
|
||||
['consumer_single' => 1, 'heat_pump' => 1]
|
||||
);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Lizenzkontingent fuer heat_pump ueberschritten');
|
||||
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
|
||||
}
|
||||
|
||||
public function testPvUndVerbrauchGebenPrognoseFrei(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'catalog_key' => Lizenzpruefung::PROGNOSE_PV,
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
];
|
||||
$lease['entitlements'][] = [
|
||||
'catalog_key' => Lizenzpruefung::PROGNOSE_VERBRAUCH,
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
];
|
||||
|
||||
self::assertTrue(Lizenzpruefung::hatPrognoseBerechtigung($lease));
|
||||
}
|
||||
|
||||
public function testEinzelnePrognoseBerechtigungReichtNichtAus(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'catalog_key' => Lizenzpruefung::PROGNOSE_PV,
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
];
|
||||
|
||||
self::assertFalse(Lizenzpruefung::hatPrognoseBerechtigung($lease));
|
||||
}
|
||||
|
||||
public function testNetzfahrplanGibtPrognoseAlleineFrei(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'catalog_key' => Lizenzpruefung::NETZFAHRPLAN,
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
];
|
||||
|
||||
self::assertTrue(Lizenzpruefung::hatPrognoseBerechtigung($lease));
|
||||
}
|
||||
|
||||
public function testLizenzcodeWirdWieAufDemServerNormalisiert(): void
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ManagerEnergieModulstrukturTest extends TestCase
|
||||
{
|
||||
public function testManagerKonfiguriertMessquellenUndArchivregeln(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||
$trait = (string) file_get_contents(__DIR__ . '/../libs/ManagerEnergieTrait.php');
|
||||
$formular = (string) file_get_contents(__DIR__ . '/../Manager/form.json');
|
||||
|
||||
foreach ([
|
||||
"RegisterPropertyBoolean('EnergyPieAnzeigen', false)",
|
||||
"RegisterPropertyBoolean('EnergiediagrammeAnzeigen', false)",
|
||||
"RegisterPropertyBoolean('FunFactsAnzeigen', false)",
|
||||
"RegisterPropertyBoolean('EnergieflussAnzeigen', false)",
|
||||
"RegisterPropertyInteger('LeistungsaufzeichnungMinuten', 1)",
|
||||
"RegisterPropertyInteger('LeistungLoeschenMonate', 12)",
|
||||
"RegisterPropertyInteger('EnergieaufzeichnungMinuten', 5)",
|
||||
"RegisterPropertyInteger('EnergieVerdichtenMonate', 12)",
|
||||
"RegisterPropertyInteger('EnergieLoeschenMonate', 0)",
|
||||
] as $erwartet) {
|
||||
self::assertStringContainsString($erwartet, $modul);
|
||||
}
|
||||
foreach ([
|
||||
'AC_SetLoggingStatus',
|
||||
'AC_SetAggregationType',
|
||||
'AC_SetCompaction',
|
||||
'AC_ReAggregateVariable',
|
||||
'IPS_CreateMedia(4)',
|
||||
'IPS_SetMediaContent',
|
||||
'ENERGY_PIE_MODULE_ID',
|
||||
'ENERGY_FLOW_MODULE_ID',
|
||||
'energieflussKnoten',
|
||||
'IPS_DeleteInstance',
|
||||
'IPS_DeleteMedia',
|
||||
] as $erwartet) {
|
||||
self::assertStringContainsString($erwartet, $modul . $trait);
|
||||
}
|
||||
foreach ([
|
||||
'Energieaufzeichnung',
|
||||
'FormEnergyPieAnzeigen',
|
||||
'FormEnergiediagrammeAnzeigen',
|
||||
'FormFunFactsAnzeigen',
|
||||
'FormEnergieflussAnzeigen',
|
||||
'MesswerteAnzeigen',
|
||||
'IstleistungVariableID',
|
||||
'LeistungVariableID',
|
||||
'SOCVariableID',
|
||||
'NetzbezugEnergieVariableID',
|
||||
'NetzeinspeisungEnergieVariableID',
|
||||
'ErzeugungsenergieVariableID',
|
||||
'LadeenergieVariableID',
|
||||
'EntladeenergieVariableID',
|
||||
] as $erwartet) {
|
||||
self::assertStringContainsString($erwartet, $formular);
|
||||
}
|
||||
}
|
||||
|
||||
public function testManagerFuehrtAlleMessvariablenUndRichtungszaehler(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||
|
||||
foreach ([
|
||||
'PVLeistungArchiv',
|
||||
'HausverbrauchLeistungArchiv',
|
||||
'NetzleistungArchiv',
|
||||
'BatterieleistungArchiv',
|
||||
'PVEnergie',
|
||||
'Hausenergie',
|
||||
'NetzbezugEnergie',
|
||||
'EinspeisungEnergie',
|
||||
'BatterieLadenEnergie',
|
||||
'BatterieEntladenEnergie',
|
||||
] as $ident) {
|
||||
self::assertStringContainsString("'" . $ident . "'", $modul);
|
||||
}
|
||||
self::assertStringContainsString("RegisterTimer(
|
||||
'MesswerteAufzeichnen'", $modul);
|
||||
self::assertStringContainsString('EnergieMessung::energieDeltas', $modul);
|
||||
self::assertStringContainsString('EnergieMessung::hausenergie', $modul);
|
||||
self::assertStringContainsString("RegisterAttributeString('MessungZaehlerstaende'", $modul);
|
||||
}
|
||||
|
||||
public function testFunFactsSindResponsivUndErklaerenUmrechnungen(): void
|
||||
{
|
||||
$trait = (string) file_get_contents(__DIR__ . '/../libs/ManagerEnergieTrait.php');
|
||||
|
||||
self::assertStringContainsString('grid-template-columns:repeat(3,minmax(0,1fr))', $trait);
|
||||
self::assertStringContainsString('@media(max-width:820px)', $trait);
|
||||
self::assertStringContainsString('10 kWh pro Liter Heizoel', $trait);
|
||||
self::assertStringContainsString('2,4 Flugkilometern', $trait);
|
||||
self::assertStringContainsString('AC_GetAggregatedValues', $trait);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user