Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18c34b32e7 | ||
|
|
b111a5c501 | ||
|
|
ef708564d8 | ||
|
|
cf2ce3aff4 | ||
|
|
708a8ecea2 | ||
|
|
40f41a2fb8 | ||
|
|
2d24ce0900 | ||
|
|
f47c908798 | ||
|
|
72d96aeb81 | ||
|
|
dcf51bd8be | ||
|
|
efa8fe698c | ||
|
|
f250dea59a | ||
|
|
1e6481330a | ||
|
|
dbd728dd03 | ||
|
|
a67018e68f | ||
|
|
0f1e0c9fbb | ||
|
|
693beb2263 | ||
|
|
b1cb289922 | ||
|
|
98ee569178 | ||
|
|
2392a9c209 | ||
|
|
7845c66fd8 | ||
|
|
d7308663da | ||
|
|
d03a7fd8fb |
@@ -21,6 +21,12 @@ jobs:
|
|||||||
- name: Abhaengigkeiten installieren
|
- name: Abhaengigkeiten installieren
|
||||||
run: composer install --no-interaction --no-progress --prefer-dist
|
run: composer install --no-interaction --no-progress --prefer-dist
|
||||||
|
|
||||||
|
- name: Composer-Metadaten pruefen
|
||||||
|
run: composer validate --strict
|
||||||
|
|
||||||
|
- name: Abhaengigkeiten auf Sicherheitsmeldungen pruefen
|
||||||
|
run: composer audit --no-interaction
|
||||||
|
|
||||||
- name: Unit-Tests und Syntaxpruefung ausfuehren
|
- name: Unit-Tests und Syntaxpruefung ausfuehren
|
||||||
run: composer check
|
run: composer check
|
||||||
|
|
||||||
|
|||||||
@@ -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,26 @@
|
|||||||
|
# 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.
|
||||||
|
- 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,791 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../libs/EaseeGatewayProtokoll.php';
|
||||||
|
|
||||||
|
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
|
||||||
|
|
||||||
|
class EaseeGateway extends IPSModule
|
||||||
|
{
|
||||||
|
private const WEBSOCKET_MODULE_ID = '{D68FD31F-0E90-7019-F16C-1949BD3079EF}';
|
||||||
|
private const SIMPLE_RX_DATA_ID = '{018EF6B5-AB94-40C6-AA53-46943E824ACF}';
|
||||||
|
private const SIMPLE_TX_DATA_ID = '{79827379-F36E-4ADA-8A95-5F8D1DC92FA9}';
|
||||||
|
private const CHILD_REQUEST_DATA_ID = '{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}';
|
||||||
|
private const CHILD_EVENT_DATA_ID = '{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}';
|
||||||
|
private const SIGNALR_BASE_URL = 'https://streams.easee.com/hubs/chargers';
|
||||||
|
private const API_BASE_URL = 'https://api.easee.com';
|
||||||
|
private const RECORD_SEPARATOR = "\x1e";
|
||||||
|
|
||||||
|
public function Create(): void
|
||||||
|
{
|
||||||
|
parent::Create();
|
||||||
|
|
||||||
|
$this->RegisterPropertyBoolean('Active', true);
|
||||||
|
$this->RegisterPropertyString('Username', '');
|
||||||
|
$this->RegisterPropertyString('Password', '');
|
||||||
|
$this->RegisterPropertyBoolean('VerifyCertificate', true);
|
||||||
|
|
||||||
|
// Nur fuer den automatisierten IP-Symcon-Funktionstest.
|
||||||
|
$this->RegisterPropertyBoolean('Testmodus', false);
|
||||||
|
|
||||||
|
$this->RegisterAttributeString('ObservationCache', '{}');
|
||||||
|
|
||||||
|
$this->RegisterVariableBoolean('Connected', 'Easee verbunden', '~Switch', 10);
|
||||||
|
$this->RegisterVariableInteger(
|
||||||
|
'SubscriptionCount',
|
||||||
|
'Angemeldete Ladestationen',
|
||||||
|
'',
|
||||||
|
20
|
||||||
|
);
|
||||||
|
$this->RegisterVariableString('LastError', 'Letzter Fehler', '', 30);
|
||||||
|
|
||||||
|
$this->RegisterTimer(
|
||||||
|
'MaintainConnectionTimer',
|
||||||
|
0,
|
||||||
|
"IPS_RequestAction(\$_IPS['TARGET'], 'MaintainConnection', false);"
|
||||||
|
);
|
||||||
|
$this->RegisterTimer(
|
||||||
|
'TokenRefreshTimer',
|
||||||
|
0,
|
||||||
|
"IPS_RequestAction(\$_IPS['TARGET'], 'RefreshToken', false);"
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->RequireParent(self::WEBSOCKET_MODULE_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ApplyChanges(): void
|
||||||
|
{
|
||||||
|
parent::ApplyChanges();
|
||||||
|
|
||||||
|
$this->SetTimerInterval('MaintainConnectionTimer', 0);
|
||||||
|
$this->SetTimerInterval('TokenRefreshTimer', 0);
|
||||||
|
$this->SetBuffer('SignalRReady', '0');
|
||||||
|
$this->SetBuffer('ReceiveBuffer', '');
|
||||||
|
$this->SetBuffer('Subscriptions', '{}');
|
||||||
|
$this->SetBuffer('SubscribedThisConnection', '{}');
|
||||||
|
$this->SetBuffer('PendingInvocations', '{}');
|
||||||
|
$this->SetValue('SubscriptionCount', 0);
|
||||||
|
$this->setzeVerbunden(false);
|
||||||
|
|
||||||
|
if (!$this->ReadPropertyBoolean('Active')) {
|
||||||
|
$this->SetStatus(104);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->ReadPropertyBoolean('Testmodus')) {
|
||||||
|
$this->SetStatus(102);
|
||||||
|
$this->setzeLetztenFehler('');
|
||||||
|
$this->setzeVerbunden(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
trim($this->ReadPropertyString('Username')) === ''
|
||||||
|
|| $this->ReadPropertyString('Password') === ''
|
||||||
|
) {
|
||||||
|
$this->SetStatus(201);
|
||||||
|
$this->setzeLetztenFehler('Easee-Benutzername oder Passwort fehlt.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->stelleWebSocketParentSicher()) {
|
||||||
|
$this->SetStatus(203);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->SetTimerInterval('MaintainConnectionTimer', 10000);
|
||||||
|
$this->SetTimerInterval('TokenRefreshTimer', 1800000);
|
||||||
|
if (!$this->erneuereZugangsdaten(false)) {
|
||||||
|
$this->SetStatus(202);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verbindeSignalR();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function RequestAction($ident, $wert): void
|
||||||
|
{
|
||||||
|
switch ($ident) {
|
||||||
|
case 'MaintainConnection':
|
||||||
|
$this->pflegeVerbindung();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'RefreshToken':
|
||||||
|
if ($this->erneuereZugangsdaten(false)) {
|
||||||
|
$this->verbindeSignalR();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'Reconnect':
|
||||||
|
$this->SetBuffer('AccessToken', '');
|
||||||
|
$this->SetBuffer('RefreshToken', '');
|
||||||
|
if (
|
||||||
|
$this->ReadPropertyBoolean('Testmodus')
|
||||||
|
|| $this->erneuereZugangsdaten(false)
|
||||||
|
) {
|
||||||
|
$this->verbindeSignalR();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'TestObservation':
|
||||||
|
if (!$this->ReadPropertyBoolean('Testmodus') || !is_string($wert)) {
|
||||||
|
throw new InvalidArgumentException('TestObservation ist nur im Testmodus zulaessig.');
|
||||||
|
}
|
||||||
|
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
if (!is_array($daten)) {
|
||||||
|
throw new InvalidArgumentException('TestObservation erwartet ein JSON-Objekt.');
|
||||||
|
}
|
||||||
|
$this->veroeffentlicheBeobachtung(
|
||||||
|
EaseeGatewayProtokoll::seriennummer((string) ($daten['serialNumber'] ?? '')),
|
||||||
|
(int) ($daten['id'] ?? 0),
|
||||||
|
$daten['value'] ?? null
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function GetConfigurationForParent(): string
|
||||||
|
{
|
||||||
|
return json_encode([
|
||||||
|
'Active' => $this->ReadPropertyBoolean('Active')
|
||||||
|
&& !$this->ReadPropertyBoolean('Testmodus'),
|
||||||
|
'URL' => $this->GetBuffer('WebSocketURL'),
|
||||||
|
'VerifyCertificate' => $this->ReadPropertyBoolean('VerifyCertificate'),
|
||||||
|
'Headers' => '[]',
|
||||||
|
], JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ReceiveData($jsonString): void
|
||||||
|
{
|
||||||
|
$paket = json_decode((string) $jsonString, true);
|
||||||
|
if (!is_array($paket) || !isset($paket['Buffer'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->SetBuffer('LastReceive', (string) time());
|
||||||
|
$puffer = $this->GetBuffer('ReceiveBuffer') . (string) $paket['Buffer'];
|
||||||
|
$rahmen = explode(self::RECORD_SEPARATOR, $puffer);
|
||||||
|
$this->SetBuffer('ReceiveBuffer', (string) array_pop($rahmen));
|
||||||
|
|
||||||
|
foreach ($rahmen as $eintrag) {
|
||||||
|
if ($eintrag !== '') {
|
||||||
|
$this->verarbeiteSignalRRahmen($eintrag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ForwardData($jsonString): string
|
||||||
|
{
|
||||||
|
$paket = json_decode((string) $jsonString, true);
|
||||||
|
if (!is_array($paket) || !isset($paket['Buffer'])) {
|
||||||
|
return json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Ungueltiges Datenpaket.',
|
||||||
|
], JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->ProcessStationRequest((string) $paket['Buffer']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ProcessStationRequest($jsonString): string
|
||||||
|
{
|
||||||
|
$anfrage = json_decode((string) $jsonString, true);
|
||||||
|
if (!is_array($anfrage) || !isset($anfrage['action'])) {
|
||||||
|
return json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Ungueltige Gateway-Anfrage.',
|
||||||
|
], JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
$seriennummer = EaseeGatewayProtokoll::seriennummer(
|
||||||
|
(string) ($anfrage['serialNumber'] ?? '')
|
||||||
|
);
|
||||||
|
|
||||||
|
switch ($anfrage['action']) {
|
||||||
|
case 'Subscribe':
|
||||||
|
case 'GetState':
|
||||||
|
if ($seriennummer === '') {
|
||||||
|
return json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Seriennummer fehlt.',
|
||||||
|
], JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
$this->registriereAbonnement($seriennummer);
|
||||||
|
return $this->erstelleStatusantwort($seriennummer);
|
||||||
|
|
||||||
|
case 'SetDynamicChargerCurrent':
|
||||||
|
return json_encode($this->setzeDynamischenLadestrom(
|
||||||
|
$seriennummer,
|
||||||
|
(float) ($anfrage['amps'] ?? -1)
|
||||||
|
), JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Unbekannte Gateway-Aktion.',
|
||||||
|
], JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stelleWebSocketParentSicher(): bool
|
||||||
|
{
|
||||||
|
$instanz = IPS_GetInstance($this->InstanceID);
|
||||||
|
$parentID = (int) $instanz['ConnectionID'];
|
||||||
|
|
||||||
|
if ($parentID <= 0 && !$this->RequireParent(self::WEBSOCKET_MODULE_ID)) {
|
||||||
|
$this->setzeLetztenFehler('WebSocket-Client konnte nicht erstellt werden.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instanz = IPS_GetInstance($this->InstanceID);
|
||||||
|
$parentID = (int) $instanz['ConnectionID'];
|
||||||
|
if ($parentID <= 0 || !IPS_InstanceExists($parentID)) {
|
||||||
|
$this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent = IPS_GetInstance($parentID);
|
||||||
|
if ($parent['ModuleInfo']['ModuleID'] !== self::WEBSOCKET_MODULE_ID) {
|
||||||
|
$this->setzeLetztenFehler('Ungueltige WebSocket-Schnittstelle.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pflegeVerbindung(): void
|
||||||
|
{
|
||||||
|
if (!$this->ReadPropertyBoolean('Active') || $this->ReadPropertyBoolean('Testmodus')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jetzt = time();
|
||||||
|
$letzteAushandlung = (int) $this->GetBuffer('LastNegotiation');
|
||||||
|
$letzterEmpfang = (int) $this->GetBuffer('LastReceive');
|
||||||
|
$bereit = $this->GetBuffer('SignalRReady') === '1';
|
||||||
|
|
||||||
|
if ($bereit) {
|
||||||
|
$this->sendeSignalRRahmen(['type' => 6]);
|
||||||
|
if ($letzterEmpfang > 0 && ($jetzt - $letzterEmpfang) <= 90) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->SetBuffer('SignalRReady', '0');
|
||||||
|
$this->setzeVerbunden(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($jetzt - $letzteAushandlung) >= 45) {
|
||||||
|
$this->verbindeSignalR();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendeHandshake();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verbindeSignalR(): bool
|
||||||
|
{
|
||||||
|
if ($this->ReadPropertyBoolean('Testmodus')) {
|
||||||
|
$this->SetStatus(102);
|
||||||
|
$this->setzeVerbunden(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!$this->stelleAccessTokenSicher()) {
|
||||||
|
$this->SetStatus(202);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->SetBuffer('LastNegotiation', (string) time());
|
||||||
|
$token = $this->GetBuffer('AccessToken');
|
||||||
|
$antwort = $this->httpAnfrage(
|
||||||
|
'POST',
|
||||||
|
self::SIGNALR_BASE_URL . '/negotiate?negotiateVersion=1',
|
||||||
|
'',
|
||||||
|
$token
|
||||||
|
);
|
||||||
|
if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) {
|
||||||
|
$token = $this->GetBuffer('AccessToken');
|
||||||
|
$antwort = $this->httpAnfrage(
|
||||||
|
'POST',
|
||||||
|
self::SIGNALR_BASE_URL . '/negotiate?negotiateVersion=1',
|
||||||
|
'',
|
||||||
|
$token
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!$antwort['success']) {
|
||||||
|
$this->SetStatus(203);
|
||||||
|
$this->setzeLetztenFehler(
|
||||||
|
'Easee-Ereignisverbindung fehlgeschlagen: HTTP '
|
||||||
|
. $antwort['httpCode']
|
||||||
|
. ($antwort['error'] !== '' ? ' / ' . $antwort['error'] : '')
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$daten = json_decode($antwort['body'], true);
|
||||||
|
$verbindungstoken = is_array($daten)
|
||||||
|
? (string) ($daten['connectionToken'] ?? $daten['connectionId'] ?? '')
|
||||||
|
: '';
|
||||||
|
if ($verbindungstoken === '') {
|
||||||
|
$this->SetStatus(203);
|
||||||
|
$this->setzeLetztenFehler('Easee liefert kein Verbindungstoken.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = 'wss://streams.easee.com/hubs/chargers?id='
|
||||||
|
. rawurlencode($verbindungstoken)
|
||||||
|
. '&access_token=' . rawurlencode($token);
|
||||||
|
$this->SetBuffer('WebSocketURL', $url);
|
||||||
|
$this->SetBuffer('SignalRReady', '0');
|
||||||
|
$this->SetBuffer('ReceiveBuffer', '');
|
||||||
|
$this->SetBuffer('SubscribedThisConnection', '{}');
|
||||||
|
$this->SetBuffer('PendingInvocations', '{}');
|
||||||
|
$this->WriteAttributeString('ObservationCache', '{}');
|
||||||
|
$this->setzeVerbunden(false);
|
||||||
|
|
||||||
|
if (!$this->konfiguriereWebSocketParent($url)) {
|
||||||
|
$this->SetStatus(203);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendeHandshake();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function konfiguriereWebSocketParent(string $url): bool
|
||||||
|
{
|
||||||
|
$parentID = (int) IPS_GetInstance($this->InstanceID)['ConnectionID'];
|
||||||
|
if ($parentID <= 0) {
|
||||||
|
$this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPS_SetProperty($parentID, 'Active', true);
|
||||||
|
IPS_SetProperty($parentID, 'URL', $url);
|
||||||
|
IPS_SetProperty(
|
||||||
|
$parentID,
|
||||||
|
'VerifyCertificate',
|
||||||
|
$this->ReadPropertyBoolean('VerifyCertificate')
|
||||||
|
);
|
||||||
|
IPS_SetProperty($parentID, 'Headers', '[]');
|
||||||
|
IPS_ApplyChanges($parentID);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendeHandshake(): void
|
||||||
|
{
|
||||||
|
$this->sendeRohdaten(
|
||||||
|
json_encode(['protocol' => 'json', 'version' => 1], JSON_THROW_ON_ERROR)
|
||||||
|
. self::RECORD_SEPARATOR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $rahmen */
|
||||||
|
private function sendeSignalRRahmen(array $rahmen): void
|
||||||
|
{
|
||||||
|
$this->sendeRohdaten(
|
||||||
|
json_encode($rahmen, JSON_THROW_ON_ERROR) . self::RECORD_SEPARATOR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendeRohdaten(string $nutzdaten): void
|
||||||
|
{
|
||||||
|
if ($this->ReadPropertyBoolean('Testmodus')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->SendDataToParent(json_encode([
|
||||||
|
'DataID' => self::SIMPLE_TX_DATA_ID,
|
||||||
|
'Buffer' => $nutzdaten,
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verarbeiteSignalRRahmen(string $rahmen): void
|
||||||
|
{
|
||||||
|
if ($rahmen === '{}') {
|
||||||
|
$this->SetBuffer('SignalRReady', '1');
|
||||||
|
$this->SetStatus(102);
|
||||||
|
$this->setzeLetztenFehler('');
|
||||||
|
$this->setzeVerbunden(true);
|
||||||
|
$this->sendeAlleAbonnements();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nachricht = json_decode($rahmen, true);
|
||||||
|
if (!is_array($nachricht)) {
|
||||||
|
$this->setzeLetztenFehler('Ungueltige Easee-Ereignisnachricht.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isset($nachricht['error'])) {
|
||||||
|
$this->SetStatus(203);
|
||||||
|
$this->setzeLetztenFehler('Easee-Ereignisfehler: ' . $nachricht['error']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$typ = (int) ($nachricht['type'] ?? 0);
|
||||||
|
if ($typ === 6) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($typ === 7) {
|
||||||
|
$this->SetBuffer('SignalRReady', '0');
|
||||||
|
$this->setzeVerbunden(false);
|
||||||
|
$this->setzeLetztenFehler('Easee-Ereignisverbindung wurde beendet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seriennummer = '';
|
||||||
|
if ($typ === 3 && isset($nachricht['invocationId'])) {
|
||||||
|
$offen = $this->lesePufferArray('PendingInvocations');
|
||||||
|
$aufrufID = (string) $nachricht['invocationId'];
|
||||||
|
$seriennummer = (string) ($offen[$aufrufID] ?? '');
|
||||||
|
unset($offen[$aufrufID]);
|
||||||
|
$this->SetBuffer(
|
||||||
|
'PendingInvocations',
|
||||||
|
json_encode($offen, JSON_THROW_ON_ERROR)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['arguments', 'result'] as $feld) {
|
||||||
|
if (!isset($nachricht[$feld])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$abonnements = array_keys($this->lesePufferArray('Subscriptions'));
|
||||||
|
foreach (EaseeGatewayProtokoll::extrahiereBeobachtungen(
|
||||||
|
$nachricht[$feld],
|
||||||
|
$abonnements,
|
||||||
|
$seriennummer
|
||||||
|
) as $beobachtung) {
|
||||||
|
$this->veroeffentlicheBeobachtung(
|
||||||
|
$beobachtung['Seriennummer'],
|
||||||
|
$beobachtung['ID'],
|
||||||
|
$beobachtung['Wert']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registriereAbonnement(string $seriennummer): void
|
||||||
|
{
|
||||||
|
$abonnements = $this->lesePufferArray('Subscriptions');
|
||||||
|
if (!isset($abonnements[$seriennummer])) {
|
||||||
|
$abonnements[$seriennummer] = true;
|
||||||
|
$this->SetBuffer(
|
||||||
|
'Subscriptions',
|
||||||
|
json_encode($abonnements, JSON_THROW_ON_ERROR)
|
||||||
|
);
|
||||||
|
$this->SetValue('SubscriptionCount', count($abonnements));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->GetBuffer('SignalRReady') === '1') {
|
||||||
|
$this->sendeAbonnement($seriennummer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendeAlleAbonnements(): void
|
||||||
|
{
|
||||||
|
foreach (array_keys($this->lesePufferArray('Subscriptions')) as $seriennummer) {
|
||||||
|
$this->sendeAbonnement((string) $seriennummer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendeAbonnement(string $seriennummer): void
|
||||||
|
{
|
||||||
|
$gesendet = $this->lesePufferArray('SubscribedThisConnection');
|
||||||
|
if (isset($gesendet[$seriennummer])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$aufrufID = (string) (((int) $this->GetBuffer('InvocationID')) + 1);
|
||||||
|
$this->SetBuffer('InvocationID', $aufrufID);
|
||||||
|
$offen = $this->lesePufferArray('PendingInvocations');
|
||||||
|
$offen[$aufrufID] = $seriennummer;
|
||||||
|
$this->SetBuffer(
|
||||||
|
'PendingInvocations',
|
||||||
|
json_encode($offen, JSON_THROW_ON_ERROR)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->sendeSignalRRahmen([
|
||||||
|
'type' => 1,
|
||||||
|
'invocationId' => $aufrufID,
|
||||||
|
'target' => 'SubscribeWithCurrentState',
|
||||||
|
'arguments' => [$seriennummer, true],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$gesendet[$seriennummer] = true;
|
||||||
|
$this->SetBuffer(
|
||||||
|
'SubscribedThisConnection',
|
||||||
|
json_encode($gesendet, JSON_THROW_ON_ERROR)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function veroeffentlicheBeobachtung(
|
||||||
|
string $seriennummer,
|
||||||
|
int $id,
|
||||||
|
$wert
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
$seriennummer === ''
|
||||||
|
|| !in_array($id, EaseeGatewayProtokoll::BEOBACHTUNGEN, true)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cache = $this->leseAttributArray('ObservationCache');
|
||||||
|
if (!isset($cache[$seriennummer]) || !is_array($cache[$seriennummer])) {
|
||||||
|
$cache[$seriennummer] = [];
|
||||||
|
}
|
||||||
|
if ($id === 109 && (int) $wert === 1) {
|
||||||
|
foreach ([110, 120, 182, 183, 184, 185] as $sessionID) {
|
||||||
|
$cache[$seriennummer][(string) $sessionID] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$cache[$seriennummer][(string) $id] = $wert;
|
||||||
|
$cache[$seriennummer]['updated'] = time();
|
||||||
|
$this->WriteAttributeString(
|
||||||
|
'ObservationCache',
|
||||||
|
json_encode($cache, JSON_THROW_ON_ERROR)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->SendDataToChildren(json_encode([
|
||||||
|
'DataID' => self::CHILD_EVENT_DATA_ID,
|
||||||
|
'Buffer' => json_encode([
|
||||||
|
'type' => 'Observation',
|
||||||
|
'serialNumber' => $seriennummer,
|
||||||
|
'id' => $id,
|
||||||
|
'value' => $wert,
|
||||||
|
'timestamp' => time(),
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function erstelleStatusantwort(string $seriennummer): string
|
||||||
|
{
|
||||||
|
$cache = $this->leseAttributArray('ObservationCache');
|
||||||
|
return json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'connected' => $this->GetBuffer('SignalRReady') === '1'
|
||||||
|
|| $this->ReadPropertyBoolean('Testmodus'),
|
||||||
|
'state' => $cache[$seriennummer] ?? [],
|
||||||
|
], JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function setzeDynamischenLadestrom(string $seriennummer, float $ampere): array
|
||||||
|
{
|
||||||
|
if ($seriennummer === '') {
|
||||||
|
return ['success' => false, 'error' => 'Seriennummer fehlt.'];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!is_finite($ampere)
|
||||||
|
|| floor($ampere) !== $ampere
|
||||||
|
|| $ampere < 0
|
||||||
|
|| $ampere > 32
|
||||||
|
|| ($ampere > 0 && $ampere < 6)
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Strom muss 0 A oder eine ganze Zahl zwischen 6 und 32 A sein.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($this->ReadPropertyBoolean('Testmodus')) {
|
||||||
|
$this->SetBuffer('LetzterTestbefehl', json_encode([
|
||||||
|
'serialNumber' => $seriennummer,
|
||||||
|
'amps' => $ampere,
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
return ['success' => true, 'httpCode' => 200, 'body' => '{}'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->autorisierteApiAnfrage(
|
||||||
|
'POST',
|
||||||
|
'/api/chargers/' . rawurlencode($seriennummer)
|
||||||
|
. '/commands/set_dynamic_charger_current',
|
||||||
|
json_encode([
|
||||||
|
'amps' => (int) round($ampere),
|
||||||
|
'minutes' => 0,
|
||||||
|
], JSON_THROW_ON_ERROR)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function autorisierteApiAnfrage(
|
||||||
|
string $methode,
|
||||||
|
string $pfad,
|
||||||
|
string $inhalt
|
||||||
|
): array {
|
||||||
|
if (!$this->stelleAccessTokenSicher()) {
|
||||||
|
return ['success' => false, 'error' => 'Kein Easee-Access-Token.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$antwort = $this->httpAnfrage(
|
||||||
|
$methode,
|
||||||
|
self::API_BASE_URL . $pfad,
|
||||||
|
$inhalt,
|
||||||
|
$this->GetBuffer('AccessToken')
|
||||||
|
);
|
||||||
|
if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) {
|
||||||
|
$antwort = $this->httpAnfrage(
|
||||||
|
$methode,
|
||||||
|
self::API_BASE_URL . $pfad,
|
||||||
|
$inhalt,
|
||||||
|
$this->GetBuffer('AccessToken')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!$antwort['success']) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $antwort['error'] !== ''
|
||||||
|
? $antwort['error']
|
||||||
|
: 'Easee-HTTP-Fehler ' . $antwort['httpCode'] . '.',
|
||||||
|
'httpCode' => $antwort['httpCode'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'httpCode' => $antwort['httpCode'],
|
||||||
|
'body' => $antwort['body'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function erneuereZugangsdaten(bool $neuVerbinden = true): bool
|
||||||
|
{
|
||||||
|
$tokenpaar = null;
|
||||||
|
$refreshToken = $this->GetBuffer('RefreshToken');
|
||||||
|
if ($refreshToken !== '') {
|
||||||
|
$tokenpaar = $this->fordereTokenpaarAn(
|
||||||
|
self::API_BASE_URL . '/api/accounts/refresh_token',
|
||||||
|
['refreshToken' => $refreshToken]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($tokenpaar === null) {
|
||||||
|
$tokenpaar = $this->fordereTokenpaarAn(
|
||||||
|
self::API_BASE_URL . '/api/accounts/login',
|
||||||
|
[
|
||||||
|
'userName' => $this->ReadPropertyString('Username'),
|
||||||
|
'password' => $this->ReadPropertyString('Password'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($tokenpaar === null || !isset($tokenpaar['accessToken'])) {
|
||||||
|
$this->setzeLetztenFehler('Easee-Anmeldung oder Token-Erneuerung fehlgeschlagen.');
|
||||||
|
$this->SetStatus(202);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->SetBuffer('AccessToken', (string) $tokenpaar['accessToken']);
|
||||||
|
if (isset($tokenpaar['refreshToken'])) {
|
||||||
|
$this->SetBuffer('RefreshToken', (string) $tokenpaar['refreshToken']);
|
||||||
|
}
|
||||||
|
if ($neuVerbinden) {
|
||||||
|
return $this->verbindeSignalR();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stelleAccessTokenSicher(): bool
|
||||||
|
{
|
||||||
|
return $this->GetBuffer('AccessToken') !== ''
|
||||||
|
|| $this->erneuereZugangsdaten(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, string> $nutzdaten
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function fordereTokenpaarAn(string $url, array $nutzdaten): ?array
|
||||||
|
{
|
||||||
|
$antwort = $this->httpAnfrage(
|
||||||
|
'POST',
|
||||||
|
$url,
|
||||||
|
json_encode($nutzdaten, JSON_THROW_ON_ERROR)
|
||||||
|
);
|
||||||
|
if (!$antwort['success']) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$daten = json_decode($antwort['body'], true);
|
||||||
|
return is_array($daten) ? $daten : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{success: bool, body: string, error: string, httpCode: int}
|
||||||
|
*/
|
||||||
|
private function httpAnfrage(
|
||||||
|
string $methode,
|
||||||
|
string $url,
|
||||||
|
string $inhalt = '',
|
||||||
|
string $bearerToken = ''
|
||||||
|
): array {
|
||||||
|
$kopf = ['Accept: application/json', 'Content-Type: application/json'];
|
||||||
|
if ($bearerToken !== '') {
|
||||||
|
$kopf[] = 'Authorization: Bearer ' . $bearerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
$curl = curl_init($url);
|
||||||
|
if ($curl === false) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'body' => '',
|
||||||
|
'error' => 'HTTP-Anfrage konnte nicht initialisiert werden.',
|
||||||
|
'httpCode' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
curl_setopt_array($curl, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $methode,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_FOLLOWLOCATION => false,
|
||||||
|
CURLOPT_HTTPHEADER => $kopf,
|
||||||
|
CURLOPT_POSTFIELDS => $inhalt,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => $this->ReadPropertyBoolean('VerifyCertificate'),
|
||||||
|
CURLOPT_SSL_VERIFYHOST => $this->ReadPropertyBoolean('VerifyCertificate') ? 2 : 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$antwort = curl_exec($curl);
|
||||||
|
$fehler = curl_error($curl);
|
||||||
|
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($curl);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => $antwort !== false
|
||||||
|
&& $fehler === ''
|
||||||
|
&& $httpStatus >= 200
|
||||||
|
&& $httpStatus < 300,
|
||||||
|
'body' => $antwort === false ? '' : (string) $antwort,
|
||||||
|
'error' => $fehler,
|
||||||
|
'httpCode' => $httpStatus,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setzeVerbunden(bool $verbunden): void
|
||||||
|
{
|
||||||
|
$this->SetValue('Connected', $verbunden);
|
||||||
|
$this->SendDataToChildren(json_encode([
|
||||||
|
'DataID' => self::CHILD_EVENT_DATA_ID,
|
||||||
|
'Buffer' => json_encode([
|
||||||
|
'type' => 'GatewayStatus',
|
||||||
|
'connected' => $verbunden,
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setzeLetztenFehler(string $nachricht): void
|
||||||
|
{
|
||||||
|
$this->SetValue('LastError', $nachricht);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function leseAttributArray(string $name): array
|
||||||
|
{
|
||||||
|
$wert = json_decode($this->ReadAttributeString($name), true);
|
||||||
|
return is_array($wert) ? $wert : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function lesePufferArray(string $name): array
|
||||||
|
{
|
||||||
|
$wert = json_decode($this->GetBuffer($name), true);
|
||||||
|
return is_array($wert) ? $wert : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,985 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
|
||||||
|
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
|
||||||
|
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
|
||||||
|
require_once __DIR__ . '/../libs/LadestationRegler.php';
|
||||||
|
require_once __DIR__ . '/../libs/EaseeLadestatus.php';
|
||||||
|
require_once __DIR__ . '/../libs/EaseeGatewayProtokoll.php';
|
||||||
|
|
||||||
|
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
|
||||||
|
use Belevo\EnelixEMS\EaseeLadestatus;
|
||||||
|
use Belevo\EnelixEMS\LadestationRegler;
|
||||||
|
use Belevo\EnelixEMS\Nachrichtenvertrag;
|
||||||
|
use Belevo\EnelixEMS\VerbraucherBasisTrait;
|
||||||
|
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
|
||||||
|
|
||||||
|
class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||||
|
{
|
||||||
|
use VerbraucherBasisTrait;
|
||||||
|
|
||||||
|
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
|
||||||
|
private const EASEE_GATEWAY_MODULE_ID = '{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}';
|
||||||
|
private const GATEWAY_REQUEST_DATA_ID = '{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}';
|
||||||
|
private const STATUS_AKTIV = 102;
|
||||||
|
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
|
||||||
|
private const STATUS_GATEWAY_GETRENNT = 202;
|
||||||
|
private const STATUS_WARTET_AUF_DATEN = 203;
|
||||||
|
private const STATUS_LADEFEHLER = 204;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private const DIAGNOSE_VARIABLEN = [
|
||||||
|
'Istleistung',
|
||||||
|
'Leistungsquelle',
|
||||||
|
'Sollleistung',
|
||||||
|
'SollwertGueltig',
|
||||||
|
'Verfuegbar',
|
||||||
|
'AenderungMoeglich',
|
||||||
|
'Stoerung',
|
||||||
|
'Stoertext',
|
||||||
|
'GatewayVerbunden',
|
||||||
|
'ApiMaximalstrom',
|
||||||
|
'LetzterGeraetebefehl',
|
||||||
|
'LeistungsangebotDiagnose',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function Create(): void
|
||||||
|
{
|
||||||
|
parent::Create();
|
||||||
|
|
||||||
|
$this->registriereVerbraucherBasis();
|
||||||
|
$this->RegisterPropertyInteger('Betriebsmodus', EaseeLadestatus::BETRIEBSMODUS_EASEE);
|
||||||
|
$this->RegisterPropertyString('Ladestationskennung', '');
|
||||||
|
$this->RegisterPropertyInteger('MaximalerLadestrom', 16);
|
||||||
|
$this->RegisterPropertyBoolean('Ladefreigabe', true);
|
||||||
|
$this->RegisterPropertyBoolean('Solarladen', true);
|
||||||
|
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
|
||||||
|
|
||||||
|
$this->RegisterVariableBoolean(
|
||||||
|
'FahrzeugVerbunden',
|
||||||
|
'Fahrzeug verbunden',
|
||||||
|
'~Switch',
|
||||||
|
100
|
||||||
|
);
|
||||||
|
$this->RegisterVariableBoolean(
|
||||||
|
'FahrzeugGeladen',
|
||||||
|
'Fahrzeug geladen',
|
||||||
|
'~Switch',
|
||||||
|
110
|
||||||
|
);
|
||||||
|
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 120);
|
||||||
|
$this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 130);
|
||||||
|
$this->RegisterVariableInteger('Phasenzahl', 'Phasenzahl', '', 140);
|
||||||
|
|
||||||
|
$this->RegisterAttributeBoolean('InitialwerteGesetzt', false);
|
||||||
|
$this->RegisterAttributeBoolean('KonfigurationLadefreigabe', false);
|
||||||
|
$this->RegisterAttributeBoolean('KonfigurationSolarladen', true);
|
||||||
|
$this->RegisterAttributeBoolean('ZustandLadefreigabe', false);
|
||||||
|
$this->RegisterAttributeBoolean('ZustandSolarladen', true);
|
||||||
|
$this->RegisterAttributeBoolean('GatewayVerbunden', false);
|
||||||
|
$this->RegisterAttributeString('EaseeBeobachtungen', '{}');
|
||||||
|
$this->RegisterAttributeInteger('LetzteBeobachtung', 0);
|
||||||
|
$this->RegisterAttributeInteger('ApiMaximalstrom', 16);
|
||||||
|
$this->RegisterAttributeBoolean('ZustandFahrzeugVerbunden', false);
|
||||||
|
$this->RegisterAttributeBoolean('ZustandFahrzeugGeladen', false);
|
||||||
|
$this->RegisterAttributeInteger('ZustandFahrzeugstatus', 0);
|
||||||
|
$this->RegisterAttributeFloat('ZustandLadestrom', 0.0);
|
||||||
|
$this->RegisterAttributeInteger('ZustandPhasenzahl', 0);
|
||||||
|
$this->RegisterAttributeFloat('ZustandIstleistung', 0.0);
|
||||||
|
$this->RegisterAttributeInteger('ZustandSollleistung', 0);
|
||||||
|
$this->RegisterAttributeBoolean('ZustandSollwertGueltig', false);
|
||||||
|
$this->RegisterAttributeBoolean('ZustandVerfuegbar', false);
|
||||||
|
$this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false);
|
||||||
|
$this->RegisterAttributeBoolean('ZustandStoerung', false);
|
||||||
|
$this->RegisterAttributeString('ZustandStoertext', '');
|
||||||
|
$this->RegisterAttributeString('Leistungsangebot', '[0]');
|
||||||
|
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
|
||||||
|
$this->RegisterAttributeInteger('LetzteManagerID', 0);
|
||||||
|
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
|
||||||
|
$this->RegisterAttributeInteger('LetzterGesetzterStrom', -1);
|
||||||
|
$this->RegisterAttributeString('LetzterGeraetebefehl', '');
|
||||||
|
|
||||||
|
$this->RegisterTimer(
|
||||||
|
'Meldezyklus',
|
||||||
|
0,
|
||||||
|
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
|
||||||
|
);
|
||||||
|
$this->RegisterTimer(
|
||||||
|
'RueckmeldungVerzoegert',
|
||||||
|
0,
|
||||||
|
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
|
||||||
|
);
|
||||||
|
$this->RegisterTimer(
|
||||||
|
'VorgabeTimeout',
|
||||||
|
0,
|
||||||
|
"IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', false);"
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->ConnectParent(self::EASEE_GATEWAY_MODULE_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ApplyChanges(): void
|
||||||
|
{
|
||||||
|
parent::ApplyChanges();
|
||||||
|
|
||||||
|
$this->initialisiereLokaleEinstellungen();
|
||||||
|
$this->aktualisiereVariablen();
|
||||||
|
$this->WriteAttributeBoolean('GatewayVerbunden', false);
|
||||||
|
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->pruefeKonfiguration();
|
||||||
|
} catch (Throwable $fehler) {
|
||||||
|
$this->deaktiviereTimer();
|
||||||
|
$this->setzeZustand('Verfuegbar', false);
|
||||||
|
$this->setzeZustand('AenderungMoeglich', false);
|
||||||
|
$this->setzeStoerung($fehler->getMessage());
|
||||||
|
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
|
||||||
|
$this->protokolliere('Konfiguration', $fehler->getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->SetTimerInterval(
|
||||||
|
'Meldezyklus',
|
||||||
|
$this->ReadPropertyInteger('Meldeintervall') * 1000
|
||||||
|
);
|
||||||
|
$this->statusAnfordern();
|
||||||
|
$this->aktualisiere(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function RequestAction($ident, $wert): void
|
||||||
|
{
|
||||||
|
switch ($ident) {
|
||||||
|
case 'Aktiv':
|
||||||
|
$this->SetValue('Aktiv', (bool) $wert);
|
||||||
|
if (!(bool) $wert) {
|
||||||
|
$this->setzeZustand('SollwertGueltig', false);
|
||||||
|
}
|
||||||
|
$this->aktualisiere(true);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'Ladefreigabe':
|
||||||
|
$this->WriteAttributeBoolean('ZustandLadefreigabe', (bool) $wert);
|
||||||
|
$this->setzeSichtbareVariable('Ladefreigabe', (bool) $wert);
|
||||||
|
$this->setzeZustand('SollwertGueltig', false);
|
||||||
|
$this->aktualisiere(true);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'Solarladen':
|
||||||
|
$solarladen = EaseeLadestatus::solarladenErzwungen(
|
||||||
|
$this->ReadPropertyInteger('Betriebsmodus')
|
||||||
|
) ? true : (bool) $wert;
|
||||||
|
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
|
||||||
|
$this->setzeSichtbareVariable('Solarladen', $solarladen);
|
||||||
|
$this->setzeZustand('SollwertGueltig', false);
|
||||||
|
$this->aktualisiere(true);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'StatusAnfordern':
|
||||||
|
$this->statusAnfordern();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'VorgabeTimeout':
|
||||||
|
$this->SetTimerInterval('VorgabeTimeout', 0);
|
||||||
|
$this->setzeZustand('SollwertGueltig', false);
|
||||||
|
$this->aktualisiere(true);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'Melden':
|
||||||
|
if ((bool) $wert) {
|
||||||
|
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
|
||||||
|
}
|
||||||
|
$this->sendeVerbraucherdaten();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'ManagerdatenEmpfangen':
|
||||||
|
if (!is_string($wert)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'Managerdaten muessen als JSON uebergeben werden.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
if (!is_array($daten)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'Managerdaten muessen ein JSON-Objekt sein.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$this->ManagerdatenEmpfangen($daten);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ReceiveData($jsonString): void
|
||||||
|
{
|
||||||
|
$paket = json_decode((string) $jsonString, true);
|
||||||
|
if (!is_array($paket) || !isset($paket['Buffer'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$ereignis = json_decode((string) $paket['Buffer'], true);
|
||||||
|
if (!is_array($ereignis) || !isset($ereignis['type'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ereignis['type'] === 'GatewayStatus') {
|
||||||
|
$verbunden = (bool) ($ereignis['connected'] ?? false);
|
||||||
|
$this->WriteAttributeBoolean('GatewayVerbunden', $verbunden);
|
||||||
|
$this->setzeSichtbareVariable('GatewayVerbunden', $verbunden);
|
||||||
|
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
|
||||||
|
if ($verbunden) {
|
||||||
|
$this->statusAnfordern();
|
||||||
|
} else {
|
||||||
|
$this->verwerfeBeobachtungen();
|
||||||
|
$this->aktualisiere(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seriennummer = EaseeGatewayProtokoll::seriennummer(
|
||||||
|
(string) ($ereignis['serialNumber'] ?? '')
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
$ereignis['type'] !== 'Observation'
|
||||||
|
|| $seriennummer !== $this->seriennummer()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->speichereBeobachtung(
|
||||||
|
(int) ($ereignis['id'] ?? 0),
|
||||||
|
$ereignis['value'] ?? null,
|
||||||
|
(int) ($ereignis['timestamp'] ?? time())
|
||||||
|
);
|
||||||
|
$this->aktualisiere(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $daten */
|
||||||
|
public function ManagerdatenEmpfangen(array $daten): void
|
||||||
|
{
|
||||||
|
Nachrichtenvertrag::pruefeManagerdaten($daten);
|
||||||
|
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'Managerdaten sind an eine andere Instanz adressiert.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$managerID = $daten['Kopf']['AbsenderID'];
|
||||||
|
if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'Der Manager hat diese Ladestation nicht aktiv zugeordnet.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
|
||||||
|
$this->aktualisiere(false);
|
||||||
|
$sollleistung = $daten['Sollleistung_W'];
|
||||||
|
if ($sollleistung === null) {
|
||||||
|
if (
|
||||||
|
(bool) $this->leseZustand('SollwertGueltig')
|
||||||
|
&& !in_array(
|
||||||
|
(int) $this->leseZustand('Sollleistung'),
|
||||||
|
$this->leseLeistungsangebot(),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
$this->setzeZustand('SollwertGueltig', false);
|
||||||
|
$this->aktualisiere(false);
|
||||||
|
}
|
||||||
|
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
|
||||||
|
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
|
||||||
|
$this->setzeZustand('Sollleistung', $sollleistung);
|
||||||
|
$this->setzeZustand('SollwertGueltig', true);
|
||||||
|
$this->SetTimerInterval(
|
||||||
|
'VorgabeTimeout',
|
||||||
|
$this->ReadPropertyInteger('VorgabeTimeout') * 1000
|
||||||
|
);
|
||||||
|
$this->aktualisiere(false);
|
||||||
|
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function statusAnfordern(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$antwort = $this->gatewayAnfrage([
|
||||||
|
'action' => 'Subscribe',
|
||||||
|
'serialNumber' => $this->seriennummer(),
|
||||||
|
]);
|
||||||
|
if (!($antwort['success'] ?? false)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
(string) ($antwort['error'] ?? 'Easee Gateway nicht erreichbar.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$this->WriteAttributeBoolean(
|
||||||
|
'GatewayVerbunden',
|
||||||
|
(bool) ($antwort['connected'] ?? false)
|
||||||
|
);
|
||||||
|
$this->setzeSichtbareVariable(
|
||||||
|
'GatewayVerbunden',
|
||||||
|
$this->ReadAttributeBoolean('GatewayVerbunden')
|
||||||
|
);
|
||||||
|
$this->verwerfeBeobachtungen();
|
||||||
|
if (is_array($antwort['state'] ?? null)) {
|
||||||
|
$zustand = $antwort['state'];
|
||||||
|
$zeitpunkt = (int) ($zustand['updated'] ?? time());
|
||||||
|
foreach (EaseeGatewayProtokoll::BEOBACHTUNGEN as $id) {
|
||||||
|
if (array_key_exists((string) $id, $zustand)) {
|
||||||
|
$this->speichereBeobachtung(
|
||||||
|
$id,
|
||||||
|
$zustand[(string) $id],
|
||||||
|
$zeitpunkt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->aktualisiere(true);
|
||||||
|
} catch (Throwable $fehler) {
|
||||||
|
$this->WriteAttributeBoolean('GatewayVerbunden', false);
|
||||||
|
$this->aktualisiere(true);
|
||||||
|
$this->protokolliere('Gateway', $fehler->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $anfrage
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function gatewayAnfrage(array $anfrage): array
|
||||||
|
{
|
||||||
|
$antwort = $this->SendDataToParent(json_encode([
|
||||||
|
'DataID' => self::GATEWAY_REQUEST_DATA_ID,
|
||||||
|
'Buffer' => json_encode($anfrage, JSON_THROW_ON_ERROR),
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
if (!is_string($antwort) || $antwort === '') {
|
||||||
|
throw new RuntimeException('Easee Gateway liefert keine Antwort.');
|
||||||
|
}
|
||||||
|
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
if (!is_array($daten)) {
|
||||||
|
throw new RuntimeException('Easee Gateway liefert ungueltige Daten.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $daten;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function speichereBeobachtung(int $id, $wert, int $zeitpunkt): void
|
||||||
|
{
|
||||||
|
if (!in_array($id, EaseeGatewayProtokoll::BEOBACHTUNGEN, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$beobachtungen = $this->leseBeobachtungen();
|
||||||
|
if ($id === 109 && (int) $wert === 1) {
|
||||||
|
foreach ([110, 120, 182, 183, 184, 185] as $sessionID) {
|
||||||
|
$beobachtungen[(string) $sessionID] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$beobachtungen[(string) $id] = $wert;
|
||||||
|
$this->WriteAttributeString(
|
||||||
|
'EaseeBeobachtungen',
|
||||||
|
json_encode($beobachtungen, JSON_THROW_ON_ERROR)
|
||||||
|
);
|
||||||
|
$this->WriteAttributeInteger('LetzteBeobachtung', $zeitpunkt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function aktualisiere(bool $meldungPlanen): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$status = EaseeLadestatus::ausBeobachtungen(
|
||||||
|
$this->leseBeobachtungen(),
|
||||||
|
$this->ReadPropertyInteger('MaximalerLadestrom')
|
||||||
|
);
|
||||||
|
foreach ([
|
||||||
|
'FahrzeugVerbunden',
|
||||||
|
'FahrzeugGeladen',
|
||||||
|
'Fahrzeugstatus',
|
||||||
|
'Phasenzahl',
|
||||||
|
'Istleistung_W',
|
||||||
|
'Ladestrom_A',
|
||||||
|
] as $feld) {
|
||||||
|
$this->setzeZustand($feld, $status[$feld]);
|
||||||
|
}
|
||||||
|
$this->WriteAttributeInteger(
|
||||||
|
'ApiMaximalstrom',
|
||||||
|
$status['ApiMaximalstrom_A']
|
||||||
|
);
|
||||||
|
$this->setzeSichtbareVariable(
|
||||||
|
'ApiMaximalstrom',
|
||||||
|
$status['ApiMaximalstrom_A']
|
||||||
|
);
|
||||||
|
|
||||||
|
$gatewayVerbunden = $this->ReadAttributeBoolean('GatewayVerbunden');
|
||||||
|
$phaseGueltig = in_array($status['Phasenzahl'], [1, 3], true);
|
||||||
|
$steuerbar = $gatewayVerbunden
|
||||||
|
&& $status['StatusGueltig']
|
||||||
|
&& !$status['Stoerung']
|
||||||
|
&& (!$status['FahrzeugVerbunden'] || $phaseGueltig);
|
||||||
|
|
||||||
|
$solarladen = $this->ReadAttributeBoolean('ZustandSolarladen');
|
||||||
|
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
|
||||||
|
=== Nachrichtenvertrag::BETRIEBSART_PEAK;
|
||||||
|
$angebot = LadestationRegler::leistungsangebot(
|
||||||
|
(bool) $this->GetValue('Aktiv'),
|
||||||
|
$this->ReadAttributeBoolean('ZustandLadefreigabe'),
|
||||||
|
$steuerbar && $status['FahrzeugVerbunden'],
|
||||||
|
$status['FahrzeugGeladen'],
|
||||||
|
$phaseGueltig ? $status['Phasenzahl'] : 1,
|
||||||
|
$status['ApiMaximalstrom_A'],
|
||||||
|
$solarladen,
|
||||||
|
$peakbetrieb
|
||||||
|
);
|
||||||
|
$verfuegbar = $steuerbar
|
||||||
|
&& $status['FahrzeugVerbunden']
|
||||||
|
&& !$status['FahrzeugGeladen']
|
||||||
|
&& $angebot !== [0];
|
||||||
|
$angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR);
|
||||||
|
$this->WriteAttributeString('Leistungsangebot', $angebotJson);
|
||||||
|
$this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson);
|
||||||
|
$this->setzeZustand('Verfuegbar', $verfuegbar);
|
||||||
|
$this->setzeZustand(
|
||||||
|
'AenderungMoeglich',
|
||||||
|
$verfuegbar && count($angebot) > 1
|
||||||
|
);
|
||||||
|
|
||||||
|
$stoertext = '';
|
||||||
|
if (!$gatewayVerbunden) {
|
||||||
|
$stoertext = 'Easee Gateway ist nicht verbunden.';
|
||||||
|
$this->SetStatus(self::STATUS_GATEWAY_GETRENNT);
|
||||||
|
} elseif (!$status['StatusGueltig']) {
|
||||||
|
$stoertext = 'Warte auf den aktuellen Easee-Ladestatus.';
|
||||||
|
$this->SetStatus(self::STATUS_WARTET_AUF_DATEN);
|
||||||
|
} elseif ($status['Stoerung']) {
|
||||||
|
$stoertext = $status['Stoertext'];
|
||||||
|
$this->SetStatus(self::STATUS_LADEFEHLER);
|
||||||
|
} elseif ($status['FahrzeugVerbunden'] && !$phaseGueltig) {
|
||||||
|
$stoertext = 'Easee meldet keine unterstuetzte aktive Ausgangsphase.';
|
||||||
|
$this->SetStatus(self::STATUS_WARTET_AUF_DATEN);
|
||||||
|
} else {
|
||||||
|
$this->SetStatus(self::STATUS_AKTIV);
|
||||||
|
}
|
||||||
|
$this->setzeStoerung($stoertext);
|
||||||
|
|
||||||
|
if ($gatewayVerbunden && $status['StatusGueltig']) {
|
||||||
|
$this->setzeLadestrom(
|
||||||
|
$this->bestimmeWirksameSollleistung($angebot),
|
||||||
|
$status['Phasenzahl']
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
|
||||||
|
}
|
||||||
|
} catch (Throwable $fehler) {
|
||||||
|
$this->setzeZustand('Verfuegbar', false);
|
||||||
|
$this->setzeZustand('AenderungMoeglich', false);
|
||||||
|
$this->setzeStoerung($fehler->getMessage());
|
||||||
|
$this->SetStatus(self::STATUS_GATEWAY_GETRENNT);
|
||||||
|
$this->protokolliere('Easee', $fehler->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($meldungPlanen) {
|
||||||
|
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<int> $angebot */
|
||||||
|
private function bestimmeWirksameSollleistung(array $angebot): int
|
||||||
|
{
|
||||||
|
if ((bool) $this->leseZustand('SollwertGueltig')) {
|
||||||
|
$sollleistung = (int) $this->leseZustand('Sollleistung');
|
||||||
|
if (in_array($sollleistung, $angebot, true)) {
|
||||||
|
return $sollleistung;
|
||||||
|
}
|
||||||
|
$this->setzeZustand('SollwertGueltig', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!$this->ReadAttributeBoolean('ZustandSolarladen')
|
||||||
|
&& $angebot !== [0]
|
||||||
|
) {
|
||||||
|
return $angebot[count($angebot) - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setzeLadestrom(int $leistung, int $phasenzahl): void
|
||||||
|
{
|
||||||
|
$strom = $leistung === 0
|
||||||
|
? 0
|
||||||
|
: LadestationRegler::stromFuerLeistung($leistung, $phasenzahl);
|
||||||
|
if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$antwort = $this->gatewayAnfrage([
|
||||||
|
'action' => 'SetDynamicChargerCurrent',
|
||||||
|
'serialNumber' => $this->seriennummer(),
|
||||||
|
'amps' => $strom,
|
||||||
|
]);
|
||||||
|
if (!($antwort['success'] ?? false)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
(string) ($antwort['error'] ?? 'Easee-Stromvorgabe fehlgeschlagen.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$befehl = json_encode([
|
||||||
|
'Aktion' => 'SetDynamicChargerCurrent',
|
||||||
|
'Seriennummer' => $this->seriennummer(),
|
||||||
|
'Ampere' => $strom,
|
||||||
|
], JSON_THROW_ON_ERROR);
|
||||||
|
$this->WriteAttributeString('LetzterGeraetebefehl', $befehl);
|
||||||
|
$this->setzeSichtbareVariable('LetzterGeraetebefehl', $befehl);
|
||||||
|
$this->WriteAttributeInteger('LetzterGesetzterStrom', $strom);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seriennummer(): string
|
||||||
|
{
|
||||||
|
return EaseeGatewayProtokoll::seriennummer(
|
||||||
|
$this->ReadPropertyString('Ladestationskennung')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verwerfeBeobachtungen(): void
|
||||||
|
{
|
||||||
|
$this->WriteAttributeString('EaseeBeobachtungen', '{}');
|
||||||
|
$this->WriteAttributeInteger('LetzteBeobachtung', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function leseBeobachtungen(): array
|
||||||
|
{
|
||||||
|
$daten = json_decode($this->ReadAttributeString('EaseeBeobachtungen'), true);
|
||||||
|
return is_array($daten) ? $daten : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendeVerbraucherdaten(): void
|
||||||
|
{
|
||||||
|
foreach ($this->zugeordneteManagerIDs() as $managerID) {
|
||||||
|
$daten = $this->baueVerbraucherdaten($managerID);
|
||||||
|
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
|
||||||
|
try {
|
||||||
|
IPS_RequestAction(
|
||||||
|
$managerID,
|
||||||
|
'VerbraucherdatenEmpfangen',
|
||||||
|
json_encode($daten, JSON_THROW_ON_ERROR)
|
||||||
|
);
|
||||||
|
} catch (Throwable $fehler) {
|
||||||
|
$this->protokolliere('Managerkommunikation', $fehler->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function baueVerbraucherdaten(int $managerID): array
|
||||||
|
{
|
||||||
|
$stoerung = (bool) $this->leseZustand('Stoerung');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Kopf' => [
|
||||||
|
'Version' => Nachrichtenvertrag::VERSION,
|
||||||
|
'AbsenderID' => $this->InstanceID,
|
||||||
|
'EmpfaengerID' => $managerID,
|
||||||
|
'Zeitpunkt' => time(),
|
||||||
|
],
|
||||||
|
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
|
||||||
|
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
|
||||||
|
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
|
||||||
|
'Leistungswerte_W' => $this->leseLeistungsangebot(),
|
||||||
|
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
|
||||||
|
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
|
||||||
|
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
|
||||||
|
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN,
|
||||||
|
'Zustand' => [
|
||||||
|
[
|
||||||
|
'Kennung' => 'Sollleistung_W',
|
||||||
|
'Art' => 'Sollwert',
|
||||||
|
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
|
||||||
|
? (int) $this->leseZustand('Sollleistung')
|
||||||
|
: null,
|
||||||
|
'Einheit' => 'W',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Kennung' => 'FahrzeugVerbunden',
|
||||||
|
'Art' => 'Status',
|
||||||
|
'Wert' => (bool) $this->leseZustand('FahrzeugVerbunden'),
|
||||||
|
'Einheit' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Kennung' => 'FahrzeugGeladen',
|
||||||
|
'Art' => 'Status',
|
||||||
|
'Wert' => (bool) $this->leseZustand('FahrzeugGeladen'),
|
||||||
|
'Einheit' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Kennung' => 'Fahrzeugstatus',
|
||||||
|
'Art' => 'Status',
|
||||||
|
'Wert' => (int) $this->leseZustand('Fahrzeugstatus'),
|
||||||
|
'Einheit' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Kennung' => 'Phasenzahl',
|
||||||
|
'Art' => 'Status',
|
||||||
|
'Wert' => (int) $this->leseZustand('Phasenzahl'),
|
||||||
|
'Einheit' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Kennung' => 'Ladestrom_A',
|
||||||
|
'Art' => 'Istwert',
|
||||||
|
'Wert' => (float) $this->leseZustand('Ladestrom'),
|
||||||
|
'Einheit' => 'A',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Kennung' => 'Ladefreigabe',
|
||||||
|
'Art' => 'Status',
|
||||||
|
'Wert' => $this->ReadAttributeBoolean('ZustandLadefreigabe'),
|
||||||
|
'Einheit' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Kennung' => 'Solarladen',
|
||||||
|
'Art' => 'Status',
|
||||||
|
'Wert' => $this->ReadAttributeBoolean('ZustandSolarladen'),
|
||||||
|
'Einheit' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Kennung' => 'Ladefehler',
|
||||||
|
'Art' => 'Stoerung',
|
||||||
|
'Wert' => $stoerung,
|
||||||
|
'Einheit' => '',
|
||||||
|
'Text' => (string) $this->leseZustand('Stoertext'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<int> */
|
||||||
|
private function zugeordneteManagerIDs(): array
|
||||||
|
{
|
||||||
|
$ergebnis = [];
|
||||||
|
foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) {
|
||||||
|
try {
|
||||||
|
$automatisch = (bool) IPS_GetProperty(
|
||||||
|
$managerID,
|
||||||
|
'AutomatischeSuche'
|
||||||
|
);
|
||||||
|
$property = $automatisch
|
||||||
|
? 'AutomatischeVerbraucherZuordnung'
|
||||||
|
: 'VerbraucherZuordnung';
|
||||||
|
$zuordnung = $this->leseManagerZuordnung($managerID, $property);
|
||||||
|
if ($automatisch && $zuordnung === []) {
|
||||||
|
$zuordnung = $this->leseManagerZuordnung(
|
||||||
|
$managerID,
|
||||||
|
'VerbraucherZuordnung'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (Throwable $fehler) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($zuordnung as $eintrag) {
|
||||||
|
if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
|
||||||
|
if ($instanzID === $this->InstanceID) {
|
||||||
|
$ergebnis[] = (int) $managerID;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($ergebnis));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<mixed> */
|
||||||
|
private function leseManagerZuordnung(int $managerID, string $property): array
|
||||||
|
{
|
||||||
|
$zuordnung = json_decode(
|
||||||
|
(string) IPS_GetProperty($managerID, $property),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
);
|
||||||
|
|
||||||
|
return is_array($zuordnung) ? $zuordnung : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pruefeKonfiguration(): void
|
||||||
|
{
|
||||||
|
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
|
||||||
|
if ($this->ReadPropertyInteger($property) < 0) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
$property . ' muss mindestens 0 sein.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['Meldeintervall', 'VorgabeTimeout'] as $property) {
|
||||||
|
if ($this->ReadPropertyInteger($property) <= 0) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
$property . ' muss groesser als 0 sein.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
|
||||||
|
if ($maximalstrom < 6 || $maximalstrom > 32) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'MaximalerLadestrom muss zwischen 6 und 32 A liegen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
EaseeLadestatus::solarladenErzwungen(
|
||||||
|
$this->ReadPropertyInteger('Betriebsmodus')
|
||||||
|
);
|
||||||
|
if ($this->seriennummer() === '') {
|
||||||
|
throw new InvalidArgumentException('Easee Seriennummer fehlt.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function initialisiereLokaleEinstellungen(): void
|
||||||
|
{
|
||||||
|
$initialisiert = $this->ReadAttributeBoolean('InitialwerteGesetzt');
|
||||||
|
$ladefreigabe = $this->ReadPropertyBoolean('Ladefreigabe');
|
||||||
|
$solarladen = EaseeLadestatus::solarladenErzwungen(
|
||||||
|
$this->ReadPropertyInteger('Betriebsmodus')
|
||||||
|
) ? true : $this->ReadPropertyBoolean('Solarladen');
|
||||||
|
|
||||||
|
if (
|
||||||
|
!$initialisiert
|
||||||
|
|| $ladefreigabe
|
||||||
|
!== $this->ReadAttributeBoolean('KonfigurationLadefreigabe')
|
||||||
|
) {
|
||||||
|
$this->WriteAttributeBoolean('ZustandLadefreigabe', $ladefreigabe);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!$initialisiert
|
||||||
|
|| $solarladen
|
||||||
|
!== $this->ReadAttributeBoolean('KonfigurationSolarladen')
|
||||||
|
) {
|
||||||
|
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->WriteAttributeBoolean('KonfigurationLadefreigabe', $ladefreigabe);
|
||||||
|
$this->WriteAttributeBoolean('KonfigurationSolarladen', $solarladen);
|
||||||
|
$this->WriteAttributeBoolean('InitialwerteGesetzt', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function aktualisiereVariablen(): void
|
||||||
|
{
|
||||||
|
if ($this->ReadPropertyBoolean('EinstellungenInVisu')) {
|
||||||
|
$this->RegisterVariableBoolean(
|
||||||
|
'Ladefreigabe',
|
||||||
|
'Ladefreigabe',
|
||||||
|
'~Switch',
|
||||||
|
150
|
||||||
|
);
|
||||||
|
$this->RegisterVariableBoolean(
|
||||||
|
'Solarladen',
|
||||||
|
'Solarladen',
|
||||||
|
'~Switch',
|
||||||
|
160
|
||||||
|
);
|
||||||
|
$this->EnableAction('Ladefreigabe');
|
||||||
|
$this->EnableAction('Solarladen');
|
||||||
|
$this->SetValue(
|
||||||
|
'Ladefreigabe',
|
||||||
|
$this->ReadAttributeBoolean('ZustandLadefreigabe')
|
||||||
|
);
|
||||||
|
$this->SetValue(
|
||||||
|
'Solarladen',
|
||||||
|
$this->ReadAttributeBoolean('ZustandSolarladen')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->entferneVariable('Ladefreigabe');
|
||||||
|
$this->entferneVariable('Solarladen');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
|
||||||
|
$this->registriereVerbraucherDiagnose();
|
||||||
|
$this->RegisterVariableBoolean(
|
||||||
|
'GatewayVerbunden',
|
||||||
|
'Easee Gateway verbunden',
|
||||||
|
'~Switch',
|
||||||
|
200
|
||||||
|
);
|
||||||
|
$this->RegisterVariableInteger(
|
||||||
|
'ApiMaximalstrom',
|
||||||
|
'API-Maximalstrom',
|
||||||
|
'',
|
||||||
|
210
|
||||||
|
);
|
||||||
|
$this->RegisterVariableString(
|
||||||
|
'LetzterGeraetebefehl',
|
||||||
|
'Letzter Geraetebefehl',
|
||||||
|
'',
|
||||||
|
220
|
||||||
|
);
|
||||||
|
$this->RegisterVariableString(
|
||||||
|
'LeistungsangebotDiagnose',
|
||||||
|
'Leistungsangebot',
|
||||||
|
'',
|
||||||
|
230
|
||||||
|
);
|
||||||
|
$this->SetValue(
|
||||||
|
'LeistungsangebotDiagnose',
|
||||||
|
$this->ReadAttributeString('Leistungsangebot')
|
||||||
|
);
|
||||||
|
$this->SetValue(
|
||||||
|
'LetzterGeraetebefehl',
|
||||||
|
$this->ReadAttributeString('LetzterGeraetebefehl')
|
||||||
|
);
|
||||||
|
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
|
||||||
|
if (!in_array($ident, [
|
||||||
|
'GatewayVerbunden',
|
||||||
|
'ApiMaximalstrom',
|
||||||
|
'LetzterGeraetebefehl',
|
||||||
|
'LeistungsangebotDiagnose',
|
||||||
|
], true)) {
|
||||||
|
$this->setzeSichtbareVariable($ident, $this->leseZustand($ident));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->setzeSichtbareVariable(
|
||||||
|
'GatewayVerbunden',
|
||||||
|
$this->ReadAttributeBoolean('GatewayVerbunden')
|
||||||
|
);
|
||||||
|
$this->setzeSichtbareVariable(
|
||||||
|
'ApiMaximalstrom',
|
||||||
|
$this->ReadAttributeInteger('ApiMaximalstrom')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
|
||||||
|
$this->entferneVariable($ident);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entferneVariable(string $ident): void
|
||||||
|
{
|
||||||
|
$id = @$this->GetIDForIdent($ident);
|
||||||
|
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
|
||||||
|
$this->UnregisterVariable($ident);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param mixed $wert */
|
||||||
|
private function setzeZustand(string $ident, $wert): void
|
||||||
|
{
|
||||||
|
switch ($ident) {
|
||||||
|
case 'FahrzeugVerbunden':
|
||||||
|
case 'FahrzeugGeladen':
|
||||||
|
case 'SollwertGueltig':
|
||||||
|
case 'Verfuegbar':
|
||||||
|
case 'AenderungMoeglich':
|
||||||
|
case 'Stoerung':
|
||||||
|
$this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert);
|
||||||
|
break;
|
||||||
|
case 'Fahrzeugstatus':
|
||||||
|
case 'Phasenzahl':
|
||||||
|
case 'Sollleistung':
|
||||||
|
$this->WriteAttributeInteger('Zustand' . $ident, (int) $wert);
|
||||||
|
break;
|
||||||
|
case 'Ladestrom_A':
|
||||||
|
$ident = 'Ladestrom';
|
||||||
|
$this->WriteAttributeFloat('ZustandLadestrom', (float) $wert);
|
||||||
|
break;
|
||||||
|
case 'Istleistung_W':
|
||||||
|
$ident = 'Istleistung';
|
||||||
|
$this->WriteAttributeFloat('ZustandIstleistung', (float) $wert);
|
||||||
|
break;
|
||||||
|
case 'Stoertext':
|
||||||
|
$this->WriteAttributeString('ZustandStoertext', (string) $wert);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new LogicException('Unbekannter Zustand: ' . $ident);
|
||||||
|
}
|
||||||
|
$this->setzeSichtbareVariable($ident, $wert);
|
||||||
|
if ($ident === 'Istleistung') {
|
||||||
|
$this->setzeSichtbareVariable(
|
||||||
|
'Leistungsquelle',
|
||||||
|
Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return mixed */
|
||||||
|
private function leseZustand(string $ident)
|
||||||
|
{
|
||||||
|
if ($ident === 'Ladefreigabe' || $ident === 'Solarladen') {
|
||||||
|
return $this->ReadAttributeBoolean('Zustand' . $ident);
|
||||||
|
}
|
||||||
|
if (in_array($ident, [
|
||||||
|
'FahrzeugVerbunden',
|
||||||
|
'FahrzeugGeladen',
|
||||||
|
'SollwertGueltig',
|
||||||
|
'Verfuegbar',
|
||||||
|
'AenderungMoeglich',
|
||||||
|
'Stoerung',
|
||||||
|
], true)) {
|
||||||
|
return $this->ReadAttributeBoolean('Zustand' . $ident);
|
||||||
|
}
|
||||||
|
if (in_array($ident, [
|
||||||
|
'Fahrzeugstatus',
|
||||||
|
'Phasenzahl',
|
||||||
|
'Sollleistung',
|
||||||
|
], true)) {
|
||||||
|
return $this->ReadAttributeInteger('Zustand' . $ident);
|
||||||
|
}
|
||||||
|
if ($ident === 'Ladestrom' || $ident === 'Istleistung') {
|
||||||
|
return $this->ReadAttributeFloat('Zustand' . $ident);
|
||||||
|
}
|
||||||
|
if ($ident === 'Stoertext') {
|
||||||
|
return $this->ReadAttributeString('ZustandStoertext');
|
||||||
|
}
|
||||||
|
if ($ident === 'Leistungsquelle') {
|
||||||
|
return Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN;
|
||||||
|
}
|
||||||
|
throw new LogicException('Unbekannter Zustand: ' . $ident);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param mixed $wert */
|
||||||
|
private function setzeSichtbareVariable(string $ident, $wert): void
|
||||||
|
{
|
||||||
|
$id = @$this->GetIDForIdent($ident);
|
||||||
|
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
|
||||||
|
$this->SetValue($ident, $wert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<int> */
|
||||||
|
private function leseLeistungsangebot(): array
|
||||||
|
{
|
||||||
|
$angebot = json_decode(
|
||||||
|
$this->ReadAttributeString('Leistungsangebot'),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
);
|
||||||
|
|
||||||
|
return is_array($angebot) ? array_map('intval', $angebot) : [0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setzeStoerung(string $text): void
|
||||||
|
{
|
||||||
|
$this->setzeZustand('Stoerung', $text !== '');
|
||||||
|
$this->setzeZustand('Stoertext', $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deaktiviereTimer(): void
|
||||||
|
{
|
||||||
|
foreach (['Meldezyklus', 'RueckmeldungVerzoegert', 'VorgabeTimeout'] as $timer) {
|
||||||
|
$this->SetTimerInterval($timer, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function protokolliere(string $bereich, string $nachricht): void
|
||||||
|
{
|
||||||
|
if ($this->ReadPropertyBoolean('LoggingEin')) {
|
||||||
|
$this->SendDebug($bereich, $nachricht, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
|||||||
$this->RegisterPropertyString('Passwort', '');
|
$this->RegisterPropertyString('Passwort', '');
|
||||||
$this->RegisterPropertyInteger('MaximalerLadestrom', 16);
|
$this->RegisterPropertyInteger('MaximalerLadestrom', 16);
|
||||||
$this->RegisterPropertyInteger('Abfrageintervall', 5);
|
$this->RegisterPropertyInteger('Abfrageintervall', 5);
|
||||||
$this->RegisterPropertyBoolean('Ladefreigabe', false);
|
$this->RegisterPropertyBoolean('Ladefreigabe', true);
|
||||||
$this->RegisterPropertyBoolean('Solarladen', true);
|
$this->RegisterPropertyBoolean('Solarladen', true);
|
||||||
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
|
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
|
||||||
|
|
||||||
|
|||||||
+536
-7
@@ -26,6 +26,13 @@
|
|||||||
"name": "LizenzPruefen",
|
"name": "LizenzPruefen",
|
||||||
"caption": "Lizenz pruefen und binden",
|
"caption": "Lizenz pruefen und binden",
|
||||||
"onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);"
|
"onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Button",
|
||||||
|
"name": "LizenzVerwalten",
|
||||||
|
"caption": "Lizenz verwalten",
|
||||||
|
"link": true,
|
||||||
|
"onClick": "echo 'https://license.enelix.ch';"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -120,6 +127,24 @@
|
|||||||
],
|
],
|
||||||
"values": []
|
"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",
|
"type": "CheckBox",
|
||||||
"name": "AutomatischeSuche",
|
"name": "AutomatischeSuche",
|
||||||
@@ -199,6 +224,379 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"type": "ExpansionPanel",
|
||||||
"caption": "Erweiterte Einstellungen",
|
"caption": "Erweiterte Einstellungen",
|
||||||
@@ -251,14 +649,133 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"type": "ExpansionPanel",
|
||||||
"caption": "Prognose / Forecast",
|
"caption": "Prognose / Forecast",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"type": "ValidationTextBox",
|
"type": "CheckBox",
|
||||||
"name": "PrognoseAnschluss",
|
"name": "PrognoseAktiv",
|
||||||
"caption": "Konfiguration als JSON"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -278,9 +795,21 @@
|
|||||||
"caption": "Stoerueberwachung",
|
"caption": "Stoerueberwachung",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"type": "ValidationTextBox",
|
"type": "CheckBox",
|
||||||
"name": "StoermeldeAnschluss",
|
"name": "StoerueberwachungAktiv",
|
||||||
"caption": "Konfiguration als JSON"
|
"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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -296,6 +825,6 @@
|
|||||||
{"code": 102, "icon": "active", "caption": "Aktiv"},
|
{"code": 102, "icon": "active", "caption": "Aktiv"},
|
||||||
{"code": 201, "icon": "inactive", "caption": "Netzleistungsmessung fehlt oder ist veraltet"},
|
{"code": 201, "icon": "inactive", "caption": "Netzleistungsmessung fehlt oder ist veraltet"},
|
||||||
{"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"},
|
{"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"},
|
||||||
{"code": 203, "icon": "error", "caption": "Lizenz fehlt oder ist abgelaufen"}
|
{"code": 203, "icon": "error", "caption": "Lizenz nicht freigegeben oder Verbraucherkontingent ueberschritten"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+2021
-48
File diff suppressed because it is too large
Load Diff
@@ -4,28 +4,65 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die
|
Version 0.1 Build 1 ist die erste Beta fuer kontrollierte Anlagen- und
|
||||||
Verbraucherbasis, der Manager, der mehrstufige Warmwassererwaermer, der
|
Feldtests. Der gemeinsame Nachrichtenvertrag, die Verbraucherbasis, der Manager
|
||||||
1-stufige Verbraucher und die Ladestation Stand-Alone sind implementiert. Die
|
und alle unten aufgefuehrten Module sind implementiert. Produktiver Einsatz
|
||||||
weiteren Module sind als Diskussionsentwürfe dokumentiert.
|
setzt eine anlagenspezifische Pruefung, Sicherung und Rueckfallplanung voraus.
|
||||||
|
|
||||||
## Geplante Module
|
## Module
|
||||||
|
|
||||||
- Manager (implementiert)
|
- Manager (implementiert)
|
||||||
- Batterie
|
- Batterie (implementiert)
|
||||||
- Wassererwaermer (implementiert)
|
- Wassererwaermer (implementiert)
|
||||||
- Pufferspeicher
|
- Pufferspeicher (implementiert)
|
||||||
- Verbraucher 1-Stufig (implementiert)
|
- Verbraucher 1-Stufig (implementiert)
|
||||||
- Waermepumpe
|
- Waermepumpe (implementiert)
|
||||||
- Ladestation Stand-Alone (implementiert)
|
- Ladestation Stand-Alone (implementiert)
|
||||||
- Ladestation Gateway
|
- Ladestation Gateway (implementiert)
|
||||||
- Easee Gateway
|
- Easee Gateway (implementiert)
|
||||||
|
|
||||||
Die Properties, Variablen, Zustandsdaten und offenen Punkte stehen in der
|
Die Properties, Variablen, Zustandsdaten und offenen Punkte stehen in der
|
||||||
[Modulübersicht](docs/module/README.md). Gemeinsame Felder werden dort nicht
|
[Modulübersicht](docs/module/README.md). Gemeinsame Felder werden dort nicht
|
||||||
abweichend neu definiert, sondern verweisen auf den zentralen Vertrag.
|
abweichend neu definiert, sondern verweisen auf den zentralen Vertrag.
|
||||||
|
|
||||||
Die Verbraucher kommunizieren mit dem Manager ueber den gemeinsamen Vertrag in `libs/`. Der Manager waehlt die Verbraucher aus; in einem Verbraucher gibt es keine Manager-ID-Property.
|
Die Verbraucher kommunizieren mit dem Manager ueber den gemeinsamen Vertrag
|
||||||
|
in `libs/`. Der Manager waehlt die Verbraucher aus; in einem Verbraucher gibt
|
||||||
|
es keine Manager-ID-Property.
|
||||||
|
|
||||||
|
## 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. Erst wenn alle aktiven Verbraucher synchronisiert sind, verteilt der
|
||||||
|
Manager konkrete Sollleistungen.
|
||||||
|
|
||||||
|
Der Manager verwendet damit nie ein PV-Angebot fuer Peak oder umgekehrt. Eine
|
||||||
|
technische Schaltsperre, eine ausstehende Rueckmeldung oder eine Mindestzeit
|
||||||
|
kann das Angebot weiterhin auf die aktuell gehaltene Leistung begrenzen.
|
||||||
|
|
||||||
|
| Verbraucher | PV-Angebot | Peak-Angebot |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Verbraucher 1-Stufig | Normal `[0, Nennleistung]`; bei faelliger Tagesmindestlaufzeit `[Nennleistung]` | Normal `[0]`; bei faelliger Tagesmindestlaufzeit konfigurierbar `[0, Nennleistung]` oder `[Nennleistung]` |
|
||||||
|
| Ladestation Stand-Alone | Mit Solarladen `[0, ...Ladestufen]`, sonst maximale Ladeleistung | Mit Solarladen `[0]`, sonst `[0, ...Ladestufen]` |
|
||||||
|
| Ladestation Gateway | Mit Solarladen `[0, ...Ladestufen]`, sonst maximale Ladeleistung | Mit Solarladen `[0]`, sonst `[0, ...Ladestufen]` |
|
||||||
|
| Warmwassererwaermer | Unter Mindesttemperatur maximale Stufe, sonst zustandsabhaengig | Unter wirksamer Mindesttemperatur `[0, ...Leistungsstufen]`, sonst `[0]` |
|
||||||
|
| Pufferspeicher | Unter Einschaltschwelle `[0, ...Leistungsstufen]`, sonst `[0]` | Unabhaengig vom Zustand `[0]` |
|
||||||
|
| Batterie | SoC-abhaengiges Lade-/Entladeraster | SoC-, Reserve- und netzabhaengiges Angebot |
|
||||||
|
|
||||||
|
Die vollstaendigen Nachrichtenfelder und JSON-Beispiele stehen in der
|
||||||
|
[Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md). Hintergrund,
|
||||||
|
Alternativen und Folgen beschreibt
|
||||||
|
[ADR 0004](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md).
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
@@ -60,13 +97,23 @@ der Uebernahme in einen Freigabebranch erfolgreich sein.
|
|||||||
|
|
||||||
## Dokumentation
|
## Dokumentation
|
||||||
|
|
||||||
|
- [Changelog](CHANGELOG.md)
|
||||||
- [Zentrale Liste offener Teamentscheidungen](docs/Offene-Punkte.md)
|
- [Zentrale Liste offener Teamentscheidungen](docs/Offene-Punkte.md)
|
||||||
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.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)
|
- [Manager-Modul inklusive Lizenzierung](docs/module/Manager/README.md)
|
||||||
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/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)
|
- [Verbraucher-1-Stufig-Modul](docs/module/Verbraucher-1-Stufig/README.md)
|
||||||
- [Ladestation-Stand-Alone-Modul](docs/module/Ladestation-Stand-Alone/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 Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md)
|
||||||
- [Migration Verbraucher 1-Stufig](docs/migration/Verbraucher-1-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)
|
- [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md)
|
||||||
- [Modulübersicht](docs/module/README.md)
|
- [Modulübersicht](docs/module/README.md)
|
||||||
|
|||||||
@@ -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
@@ -2,6 +2,7 @@
|
|||||||
"name": "belevo/enelix-ems",
|
"name": "belevo/enelix-ems",
|
||||||
"description": "Energiemanagement und steuerbare Verbraucher fuer IP-Symcon",
|
"description": "Energiemanagement und steuerbare Verbraucher fuer IP-Symcon",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
|
"license": "proprietary",
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.0"
|
"php": ">=8.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+47
-21
@@ -1,34 +1,60 @@
|
|||||||
# Obere Anschlüsse des Managers
|
# Obere Anschlüsse des Managers
|
||||||
|
|
||||||
> Status: Lizenzierung implementiert; die übrigen Anbieterformate sind noch
|
> Status: Lizenzierung, Prognose und Stoerueberwachung sind an
|
||||||
> nicht festgelegt.
|
> `license.enelix.ch` angebunden. SDL/VGT bleibt als optionaler Anschluss
|
||||||
|
> vorbereitet.
|
||||||
|
|
||||||
Prognose, SDL/VGT und Stoerueberwachung sind optional. Die Lizenzierung ist fuer
|
Die Anschluesse werden ausschliesslich im Manager konfiguriert. Sie erzeugen
|
||||||
den Betrieb des Managers erforderlich. Sie werden im Manager konfiguriert und erzeugen
|
keine Abhaengigkeit zwischen Enelix EMS und Enelix Utils.
|
||||||
keine Abhängigkeit zwischen Enelix EMS und Enelix Utils.
|
|
||||||
|
|
||||||
| Anschluss | Aufgabe | Gegenstelle |
|
| Anschluss | Aufgabe | Gegenstelle |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| SDL/VGT | Zeitlich gültige Leistungsaufträge und Rückmeldungen | Optional das unabhängige Utils-Modul VGT-Schnittstelle |
|
| SDL/VGT | Zeitlich gueltige Leistungsauftraege und Rueckmeldungen | Optional das unabhaengige Utils-Modul VGT-Schnittstelle |
|
||||||
| Prognose / Forecast | Vorhersage für Produktion und bei Bedarf Verbrauch | Externer Anbieter; kein Repo-Modul |
|
| 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` |
|
| Lizenzierung | Pruefung freigeschalteter Manager-Funktionen | `POST https://license.enelix.ch/api/v1/licenses/activate` |
|
||||||
| Störüberwachung | Weitergabe von Zuständen und Störungen | Externer Empfänger; kein Repo-Modul |
|
| Stoerueberwachung | Vollsnapshot aktiver Manager- und Geraetestoerungen | `PUT https://license.enelix.ch/api/v1/installations/{id}/faults` |
|
||||||
|
|
||||||
## Gemeinsame Grundsätze
|
## Stoerungsdatenfluss
|
||||||
|
|
||||||
- Die Auswahl der Gegenstelle erfolgt ausschliesslich im Manager.
|
1. Jedes Geraetemodul meldet seinen Zustand ueber den bestehenden
|
||||||
- Anbieterformate werden an diesem Anschluss übersetzt und gelangen nicht in
|
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.
|
die Verbraucher-Schnittstelle.
|
||||||
- Jeder Anschluss meldet im Manager `NichtVerwendet`, `Verbunden` oder `Fehler`.
|
- Jeder Anschluss meldet `NichtVerwendet`, `Wartet`, `Verbunden` oder
|
||||||
- Fehlende optionale Anschlüsse dürfen die lokale EMS-Grundfunktion nicht
|
`Fehler`.
|
||||||
|
- Fehlende optionale Anschluesse duerfen die lokale EMS-Grundfunktion nicht
|
||||||
blockieren.
|
blockieren.
|
||||||
- Eine fehlende oder abgelaufene Lizenz blockiert die Managerregelung. Eine
|
- Zugangstoken werden weder als Property noch in Diagnosevariablen oder Logs
|
||||||
bestaetigte Entwicklungs-Lease bleibt bei Verbindungsunterbruch bis
|
ausgegeben.
|
||||||
`offlineUntil` gueltig.
|
|
||||||
|
|
||||||
## Noch festzulegen
|
## Noch festzulegen
|
||||||
|
|
||||||
- Anbieter und Datenformat für Prognose und Störüberwachung
|
- Aufbau, Gueltigkeitszeitraum und Rueckmeldung eines SDL-Auftrags
|
||||||
- Aufbau, Gültigkeitszeitraum und Rückmeldung eines SDL-Auftrags
|
- Produkt- und Zahlungsmodell der jaehrlichen Stoerueberwachungslizenz
|
||||||
- Authentisierung der noch optionalen Anschlüsse
|
- Verhalten nach Ablauf einer spaeter aktivierten Stoerueberwachungslizenz
|
||||||
- Ablösung der Entwicklungs-Lease durch den signierten produktiven Lizenzvertrag
|
|
||||||
|
|||||||
+24
-27
@@ -47,25 +47,13 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
|
|||||||
| ID | Status | Bereich | Kurzthema |
|
| ID | Status | Bereich | Kurzthema |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| OP-001 | Entschieden | Lizenzierung | Betriebsort des Lizenzservers |
|
| OP-001 | Entschieden | Lizenzierung | Betriebsort des Lizenzservers |
|
||||||
| OP-002 | Offen | Grundeinrichtung | Generator und kostenpflichtige Ersteinrichtung |
|
| OP-002 | Erledigt | Grundeinrichtung | Generator und kostenpflichtige Ersteinrichtung |
|
||||||
| OP-003 | Offen | Lizenzportal | VGT-Integration in license.enelix.ch |
|
| OP-003 | Offen | Lizenzportal | VGT-Integration in license.enelix.ch |
|
||||||
| OP-004 | Offen | Lizenzierung | Lizenzmodell des Verbrauchskostenreports |
|
| OP-004 | Entschieden | Lizenzierung | Lizenzmodell des Verbrauchskostenreports |
|
||||||
| OP-005 | Offen | Teststrategie | Standort fuer Feldtests |
|
| OP-005 | Offen | Teststrategie | Standort fuer Feldtests |
|
||||||
|
|
||||||
## Offene Punkte
|
## Offene Punkte
|
||||||
|
|
||||||
### OP-002: Generator und kostenpflichtige Ersteinrichtung
|
|
||||||
|
|
||||||
- **Entscheidungsfrage:** Soll der automatische Generator fuer die
|
|
||||||
Grundeinrichtung zugleich die kostenpflichtige Ersteinrichtung abbilden?
|
|
||||||
- **Kontext:** Der vorgesehene automatische Generator fuer die Grundeinrichtung
|
|
||||||
muss gegenueber der kostenpflichtigen Ersteinrichtung abgegrenzt werden.
|
|
||||||
- **Auswirkung:** Die Entscheidung legt fest, ob beide als derselbe Vorgang oder
|
|
||||||
als getrennte Leistungen behandelt werden.
|
|
||||||
- **Verantwortlich:** Offen
|
|
||||||
- **Ziel-Sprint:** Offen
|
|
||||||
- **Ergebnis/Verweis:** Offen
|
|
||||||
|
|
||||||
### OP-003: VGT-Integration in license.enelix.ch
|
### OP-003: VGT-Integration in license.enelix.ch
|
||||||
|
|
||||||
- **Entscheidungsfrage:** Soll die VGT-Anwendung als Bestandteil in die
|
- **Entscheidungsfrage:** Soll die VGT-Anwendung als Bestandteil in die
|
||||||
@@ -78,19 +66,6 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
|
|||||||
- **Ziel-Sprint:** Offen
|
- **Ziel-Sprint:** Offen
|
||||||
- **Ergebnis/Verweis:** Offen
|
- **Ergebnis/Verweis:** Offen
|
||||||
|
|
||||||
### OP-004: Lizenzmodell des Verbrauchskostenreports
|
|
||||||
|
|
||||||
- **Entscheidungsfrage:** Soll der Verbrauchskostenreport nach dem Lizenzmodell
|
|
||||||
des Managers oder mit einer einzigen, von der Anzahl angeschlossener Zaehler
|
|
||||||
unabhaengigen Modullizenz angeboten werden?
|
|
||||||
- **Kontext:** Fuer den Verbrauchskostenreport muss zwischen dem Lizenzmodell des
|
|
||||||
Managers und einer zaehlerunabhaengigen Modullizenz entschieden werden.
|
|
||||||
- **Auswirkung:** Die Entscheidung legt fest, ob die Anzahl angeschlossener
|
|
||||||
Zaehler den Lizenzumfang des Verbrauchskostenreports beeinflusst.
|
|
||||||
- **Verantwortlich:** Offen
|
|
||||||
- **Ziel-Sprint:** Offen
|
|
||||||
- **Ergebnis/Verweis:** Offen
|
|
||||||
|
|
||||||
### OP-005: Standort fuer Feldtests
|
### OP-005: Standort fuer Feldtests
|
||||||
|
|
||||||
- **Entscheidungsfrage:** An welchem Standort koennen die Module aus Enelix EMS
|
- **Entscheidungsfrage:** An welchem Standort koennen die Module aus Enelix EMS
|
||||||
@@ -113,6 +88,28 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
|
|||||||
- **Ergebnis/Verweis:** [Obere Anschluesse](Obere-Anschluesse.md) und
|
- **Ergebnis/Verweis:** [Obere Anschluesse](Obere-Anschluesse.md) und
|
||||||
[Manager](module/Manager/README.md)
|
[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
|
## Vorlage fuer neue Punkte
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -132,6 +132,22 @@ Symcon-Datenpunkte registriert `VerbraucherBasisTrait`.
|
|||||||
`Leistungswerte_W`, `Betriebsart` und `Zustand` werden intern gehalten
|
`Leistungswerte_W`, `Betriebsart` und `Zustand` werden intern gehalten
|
||||||
und direkt in die Nachricht geschrieben.
|
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
|
## Zeitverhalten
|
||||||
|
|
||||||
- Rueckmeldung nach Start, relevanten Aenderungen und alle `Meldeintervall`
|
- Rueckmeldung nach Start, relevanten Aenderungen und alle `Meldeintervall`
|
||||||
|
|||||||
@@ -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.
|
||||||
+113
-47
@@ -1,59 +1,125 @@
|
|||||||
# Batterie
|
# Batterie
|
||||||
|
|
||||||
> Status: Diskussionsentwurf. Positiv bedeutet Laden, negativ Entladen.
|
> Status: implementiert. Zielplattform ist IP-Symcon ab Version 8.0,
|
||||||
|
> Nachrichtenvertrag 4.0.
|
||||||
|
|
||||||
Die Auswahl des Batterietyps bestimmt den Adapter und die sichtbaren
|
## Verantwortung
|
||||||
Anschlussfelder. Ohne Logging bleibt von den zusätzlichen Variablen nur
|
|
||||||
`Ladestatus` sichtbar.
|
|
||||||
|
|
||||||
## Zusätzliche Variablen
|
Das Modul bildet einen Batteriespeicher als bidirektionalen Enelix-Verbraucher
|
||||||
|
ab. Es berechnet das betriebsart- und ladezustandsabhaengige
|
||||||
|
Leistungsangebot, empfaengt Sollleistungen vom Manager und uebersetzt sie in
|
||||||
|
herstellerspezifische Registerwerte.
|
||||||
|
|
||||||
| Ident | Typ / Zugriff | Beschreibung |
|
Positive Leistung bedeutet Laden, negative Leistung Entladen. Die
|
||||||
| --- | --- | --- |
|
Geraeteanbindung erfolgt ausschliesslich ueber vom Benutzer ausgewaehlte
|
||||||
| `Ladestatus` | Integer / Anzeige | `0` unbekannt, `1` ruhend, `2` laden, `3` entladen. |
|
numerische IP-Symcon-Variablen. Schreibziele muessen eine Aktion besitzen.
|
||||||
| `Ladezustand` | Float / Logging | Physischer Ladezustand in %. |
|
|
||||||
| `LadezustandGueltig` | Boolean / Logging | Gültigkeit unabhängig von der Leistungsmessung. |
|
|
||||||
| `Steuerungsmodus` | Integer / lokal bedienbar | `0` Batterie, `1` Manager, `2` Virtuelle Batterie führt. |
|
|
||||||
| `NotnachladungAktiv` | Boolean / Logging | Aus Ladezustand ermittelter lokaler Bedarf. |
|
|
||||||
| `LadeleistungVerfuegbar` | Float / Logging | Aktuell nutzbare Ladeleistung in W. |
|
|
||||||
| `EntladeleistungVerfuegbar` | Float / Logging | Aktuell nutzbare Entladeleistung als positiver Betrag in W. |
|
|
||||||
| `MaxLadeleistung` | Float / lokal bedienbar | Wirksamer Parameter in W. |
|
|
||||||
| `MaxEntladeleistung` | Float / lokal bedienbar | Wirksamer Parameter in W. |
|
|
||||||
| `Mindestladezustand` | Float / lokal bedienbar | Wirksamer Parameter in %. |
|
|
||||||
| `Maximalladezustand` | Float / lokal bedienbar | Wirksamer Parameter in %. |
|
|
||||||
| `Nachladeschwelle` | Float / lokal bedienbar | Wirksamer Parameter in %. |
|
|
||||||
| `Nachladehysterese` | Float / lokal bedienbar | Wirksamer Parameter in %. |
|
|
||||||
| `Notnachladeleistung` | Float / lokal bedienbar | `0` deaktiviert; sonst Leistung in W. |
|
|
||||||
|
|
||||||
## Zusätzliche Properties
|
## Ereignismodell
|
||||||
|
|
||||||
| Ident | Typ | Standard / Beschreibung |
|
Fachliche Neuberechnungen werden ausgeloest durch:
|
||||||
| --- | --- | --- |
|
|
||||||
| `MaxLadeleistung` | Float | `0*` W; einzige einstellbare obere Ladegrenze. |
|
|
||||||
| `MaxEntladeleistung` | Float | `0` W; einzige einstellbare obere Entladegrenze. |
|
|
||||||
| `Mindestladezustand` | Float | `0*` %; Bereich 0–100. |
|
|
||||||
| `Maximalladezustand` | Float | `100` %; grösser als Mindestladezustand. |
|
|
||||||
| `Nachladeschwelle` | Float | `0*` %; zwischen Mindest- und Maximalladezustand. |
|
|
||||||
| `Nachladehysterese` | Float | `2` %; Schwelle plus Hysterese höchstens Maximalladezustand. |
|
|
||||||
| `Notnachladeleistung` | Float | `0` W; sonst höchstens MaxLadeleistung. |
|
|
||||||
| `Batterietyp` | Auswahl | `Unkonfiguriert`, `Herstellerunabhaengig`, `Goodwe`, `SolarEdge`, `SigEnergy`. |
|
|
||||||
| `BatterieInstanzID` | Integer | `0`; für kompatible Herstelleradapter. |
|
|
||||||
| `LadezustandVariableID` | Integer | `0`; nur herstellerunabhängig. |
|
|
||||||
| `IstleistungVariableID` | Integer | `0`; nur herstellerunabhängig. |
|
|
||||||
| `SollleistungVariableID` | Integer | `0`; nur herstellerunabhängig, vorzeichenbehaftet in W. |
|
|
||||||
| `LadezustandMaxAlter` | Integer | `60` s; Aktualität des Ladezustands. |
|
|
||||||
| `VirtuelleBatterieID` | Integer | `0`; Pflicht bei Steuerungsmodus 2. |
|
|
||||||
|
|
||||||
Anschlussart, Batteriespannung, Gerätezuordnung, Leistungsschritt,
|
- Aktualisierung eines der fuenf Messwerte
|
||||||
Sollwertquelle und externe Sollwerte entfallen.
|
- Managerdaten mit Betriebsart oder Sollleistung
|
||||||
|
- lokale Aenderung der Variablen Aktiv
|
||||||
|
- Ablauf von Vorgabe-Timeout oder Aenderungssperre
|
||||||
|
- manuelle Aktualisierung in der Konfigurationsmaske
|
||||||
|
|
||||||
## Zustand
|
Der Meldezyklus verschickt eine Vollmeldung und dient nicht als Regelzyklus.
|
||||||
|
Ein periodischer Berechnungstimer oder Idle-Counter existiert nicht.
|
||||||
|
|
||||||
`Ladezustand_Prozent` als Istwert; `Steuerungsmodus` und
|
## Properties
|
||||||
`NotnachladungAktiv` als Status; `LadezustandFehlt` und `Geraetefehler` als
|
|
||||||
Störungen.
|
|
||||||
|
|
||||||
## Offene Punkte
|
### Gemeinsame Verbraucherproperties
|
||||||
|
|
||||||
- Michis konkrete Fehlerliste nachliefern und gegen den Entwurf prüfen.
|
PrioritaetPV, PrioritaetPeak, Meldeintervall, VorgabeTimeout,
|
||||||
- Datenpunkte und Leistungsgrenzen der drei Herstelleradapter verifizieren.
|
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
|
||||||
|
|||||||
@@ -1,35 +1,77 @@
|
|||||||
# Easee Gateway
|
# Easee Gateway
|
||||||
|
|
||||||
> Status: Diskussionsentwurf. Kommunikationsmodul, kein Verbraucher und keine
|
> Status: implementiert fuer IP-Symcon 8 und die Easee Cloud API.
|
||||||
> Verwendung der Verbraucherbasis.
|
|
||||||
|
|
||||||
Das bestehende Gateway wird übernommen. Anzeigen werden deutsch; technische
|
Das Modul stellt pro Easee-Nutzerkonto genau eine gemeinsame Verbindung bereit.
|
||||||
Alt-Idents bleiben zur Kompatibilität erhalten. Eine passende vorhandene
|
Benutzername, Passwort, Access Token und Refresh Token verbleiben im Gateway.
|
||||||
Verbindung soll bei der Instanziierung wiederverwendet werden.
|
Mehrere Instanzen von **Ladestation Gateway** koennen denselben Elternknoten
|
||||||
|
verwenden.
|
||||||
|
|
||||||
## Variablen
|
## Funktionsumfang
|
||||||
|
|
||||||
| Technischer Ident / Anzeige | Typ / Zugriff | Beschreibung |
|
- Anmeldung mit Easee-Benutzerkonto und automatische Token-Erneuerung,
|
||||||
| --- | --- | --- |
|
- gemeinsame Ereignisverbindung zu `streams.easee.com`,
|
||||||
| `Connected` / Verbunden | Boolean / Anzeige | SignalR-Verbindungsstatus. |
|
- Abonnement mehrerer Ladestationen mit aktuellem Zustand,
|
||||||
| `SubscriptionCount` / Angemeldete Ladestationen | Integer / Anzeige | Anzahl registrierter Geräte. |
|
- Verteilung der Easee-Observations an die passenden Kindinstanzen,
|
||||||
| `LastError` / Letzter Fehler | String / Anzeige | Diagnose ohne Zugangsdaten. |
|
- Stromvorgabe ueber `set_dynamic_charger_current`,
|
||||||
|
- Wiederverbindung und erneute Anmeldung aller Stationen nach Unterbrechungen,
|
||||||
|
- TLS-Zertifikatspruefung standardmaessig aktiv.
|
||||||
|
|
||||||
## Properties
|
## Properties
|
||||||
|
|
||||||
| Technischer Ident / Anzeige | Typ | Standard / Beschreibung |
|
| 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 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `Active` / Aktiv | Boolean | `true`; Verbindungsbetrieb, keine Ladefreigabe. |
|
| `Connected` | Boolean | Ereignisverbindung ist betriebsbereit. |
|
||||||
| `Username` / Benutzername | String | leer; Easee-Konto. |
|
| `SubscriptionCount` | Integer | Anzahl angemeldeter Seriennummern. |
|
||||||
| `Password` / Passwort | String | leer; vertraulich. |
|
| `LastError` | String | Letzter Fehler ohne Zugangsdaten oder Tokens. |
|
||||||
| `VerifyCertificate` / Zertifikat prüfen | Boolean | `true`; TLS-Prüfung. |
|
|
||||||
|
|
||||||
## Verhalten
|
## API und Ereignisse
|
||||||
|
|
||||||
Nach einer Wiederverbindung werden Gerätezustände erst nach neuer gültiger
|
Der Gateway-Transport ist in
|
||||||
Rückmeldung verwendet. Das Gateway sendet keine EMS-Verbrauchermeldung.
|
[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.
|
||||||
|
|
||||||
## Offene Punkte
|
## Fehlerbehandlung
|
||||||
|
|
||||||
- Instanziierung und Wiederverwendung bestehender Verbindungen testen.
|
| Status | Bedeutung |
|
||||||
- Aktuelle Easee-Endpunkte und Ereignisfelder vor Übernahme verifizieren.
|
| ---: | --- |
|
||||||
|
| `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
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,41 +1,112 @@
|
|||||||
# Ladestation Gateway
|
# Ladestation Gateway
|
||||||
|
|
||||||
> Status: Diskussionsentwurf. Eigenständiges Verbrauchermodul mit zugeordnetem
|
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
|
||||||
> Easee Gateway; keine gemeinsame Ladestations-Basisklasse.
|
|
||||||
|
|
||||||
## Zusätzliche Variablen
|
Das Modul bindet genau eine Easee-Ladestation als EMS-Verbraucher an. Es ist
|
||||||
|
Kind eines **Easee Gateway** und besitzt keine Zugangsdaten.
|
||||||
|
|
||||||
| Ident | Typ / Zugriff | Beschreibung |
|
## Varianten
|
||||||
|
|
||||||
|
| Betriebsmodus | Verhalten |
|
||||||
|
| --- | --- |
|
||||||
|
| `Easee` | Solarladen kann als Startwert konfiguriert und lokal umgeschaltet werden. |
|
||||||
|
| `Easee - Nur Solarladen` | Solarladen ist fest aktiv und kann nicht abgeschaltet werden. |
|
||||||
|
|
||||||
|
Die alte eCarUp-Zusatzabhaengigkeit wird nicht uebernommen. Konto,
|
||||||
|
Ladestatus und Steuerung laufen ausschliesslich ueber Easee.
|
||||||
|
|
||||||
|
## Ereignisbasierter Status
|
||||||
|
|
||||||
|
Das Modul pollt die Ladestation nicht. Es verarbeitet unmittelbar die vom
|
||||||
|
Gateway gelieferten Easee-Observations:
|
||||||
|
|
||||||
|
| Observation | Verwendung |
|
||||||
|
| ---: | --- |
|
||||||
|
| `47` | Maximalstrom der Ladestation. |
|
||||||
|
| `104` | Kabelstromgrenze. |
|
||||||
|
| `109` | Fahrzeugerkennung und Ladezustand. |
|
||||||
|
| `110` | Aktive Ausgangsphase: Werte 10 bis 15 ergeben 1 Phase, Wert 30 ergibt 3 Phasen. |
|
||||||
|
| `119` | Easee-Fehlercode. |
|
||||||
|
| `120` | Gemessene Gesamtleistung in kW. |
|
||||||
|
| `182..185` | Gemessene Leiterstroeme; der groesste Betrag ist der angezeigte Ladestrom. |
|
||||||
|
|
||||||
|
Damit entfallen die alte 60-Sekunden-Erkennung, die 7500-W-Schwelle und die
|
||||||
|
Voll-Erkennung aus einem unterschaetzten Strom. Fahrzeug, Ladeende und
|
||||||
|
Phasenzahl stammen direkt aus der Easee API. Zweiphasige oder noch nicht
|
||||||
|
zugewiesene Ausgangsphasen sowie unbekannte Betriebszustaende werden sicher
|
||||||
|
als ungueltig behandelt und ergeben bis zur gueltigen 1-/3-Phasenmeldung nur
|
||||||
|
`[0]`.
|
||||||
|
|
||||||
|
## Properties
|
||||||
|
|
||||||
|
| Ident | Typ | Standard | Beschreibung |
|
||||||
|
| --- | --- | ---: | --- |
|
||||||
|
| `Betriebsmodus` | Integer | `0` | `0` Easee, `1` Easee - Nur Solarladen. |
|
||||||
|
| `Ladestationskennung` | String | leer | Easee-Seriennummer. |
|
||||||
|
| `MaximalerLadestrom` | Integer | `16 A` | Lokale Obergrenze von 6 bis 32 A. API- und Kabelgrenze wirken zusaetzlich. |
|
||||||
|
| `Ladefreigabe` | Boolean | `false` | Startwert der lokalen Ladefreigabe. |
|
||||||
|
| `Solarladen` | Boolean | `true` | Startwert im normalen Easee-Modus. |
|
||||||
|
| `PrioritaetPV` | Integer | `0` | Prioritaet im PV-Betrieb. |
|
||||||
|
| `PrioritaetPeak` | Integer | `0` | Prioritaet im Peakbetrieb. |
|
||||||
|
| `Meldeintervall` | Integer | `10 s` | Periodische Vollmeldung an den Manager. |
|
||||||
|
| `VorgabeTimeout` | Integer | `120 s` | Ablauf einer nicht erneuerten Manager-Vorgabe. |
|
||||||
|
| `EinstellungenInVisu` | Boolean | `false` | Zeigt Ladefreigabe und Solarladen. |
|
||||||
|
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Zeigt technische Diagnosewerte. |
|
||||||
|
| `LoggingEin` | Boolean | `false` | Aktiviert Debugmeldungen ohne Geheimnisse. |
|
||||||
|
|
||||||
|
## Variablen
|
||||||
|
|
||||||
|
Immer sichtbar sind `Aktiv`, `FahrzeugVerbunden`, `FahrzeugGeladen`,
|
||||||
|
`Fahrzeugstatus`, `Ladestrom` und `Phasenzahl`. Der normalisierte
|
||||||
|
Fahrzeugstatus ist `0` unbekannt, `1` getrennt, `2` bereit, `3` laedt,
|
||||||
|
`4` geladen oder `5` Fehler.
|
||||||
|
|
||||||
|
Optional sichtbar sind die gemeinsamen EMS-Diagnosewerte sowie
|
||||||
|
`GatewayVerbunden`, `ApiMaximalstrom`, `LetzterGeraetebefehl` und
|
||||||
|
`LeistungsangebotDiagnose`.
|
||||||
|
|
||||||
|
## Regelung
|
||||||
|
|
||||||
|
Die Leistungsstufen und das PV-/Peak-Verhalten entsprechen der
|
||||||
|
Ladestation Stand-Alone:
|
||||||
|
|
||||||
|
| Betriebsart | Solarladen | Angebot |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `FahrzeugVerbunden` | Boolean / Anzeige | Nur bei gültigem Gatewaystatus aussagekräftig. |
|
| PV | ein | `[0, ...Ladestufen]` |
|
||||||
| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` nicht verbunden, `2` bereit, `3` lädt, `4` voll, `5` Fehler. |
|
| PV | aus | nur maximale Ladestufe |
|
||||||
| `Ladestrom` | Float / Anzeige | Aktueller Ladestrom in A. |
|
| Peak | ein | `[0]` |
|
||||||
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. |
|
| Peak | aus | `[0, ...Ladestufen]` |
|
||||||
| `Ladefreigabe` | Boolean / lokal bedienbar | Lokale Ladeerlaubnis zusätzlich zu `Aktiv`. |
|
|
||||||
| `Solarladen` | Boolean / lokal bedienbar | Überschussorientiertes Laden ein/aus. |
|
|
||||||
|
|
||||||
## Zusätzliche Properties
|
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.
|
||||||
|
|
||||||
| Ident | Typ | Standard / Beschreibung |
|
## Managerkommunikation
|
||||||
| --- | --- | --- |
|
|
||||||
| `Ladefreigabe` | Boolean | `false`. |
|
|
||||||
| `Solarladen` | Boolean | `true`. |
|
|
||||||
| `MaximalerLadestrom` | Float | `0*` A; Installations- und Gerätegrenze. |
|
|
||||||
| `Ladestromschritt` | Float | `1` A; muss von Easee unterstützt sein. |
|
|
||||||
| `MindestEinzeit` | Integer | `0` s. |
|
|
||||||
| `MindestAuszeit` | Integer | `0` s. |
|
|
||||||
| `GatewayID` | Integer | `0`; kompatibles Easee Gateway, erforderlich. |
|
|
||||||
| `Ladestationskennung` | String | leer; eindeutige Station/Seriennummer im Gateway. |
|
|
||||||
|
|
||||||
Es gibt hier keine Zugangsdaten; sie liegen ausschliesslich im Easee Gateway.
|
Das Modul implementiert den
|
||||||
Ladefreigabe und Solarladen werden über `EinstellungenInVisu` eingeblendet.
|
[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.
|
||||||
|
|
||||||
## Zustand
|
## Inbetriebnahme
|
||||||
|
|
||||||
`Fahrzeugstatus`, `FahrzeugVerbunden`, `Ladefreigabe`, `Solarladen`,
|
1. Ein konfiguriertes Easee Gateway mit Status `102` bereitstellen.
|
||||||
`Ladestrom_A`, `Ladefehler` und `Gatewayfehler`.
|
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.
|
||||||
|
|
||||||
## Offene Punkte
|
## Tests
|
||||||
|
|
||||||
- Reaktion aller zugeordneten Stationen bei Gatewayausfall im Praxistest festlegen.
|
```bash
|
||||||
- Lokalen Mindestladebedarf bei deaktiviertem Solarladen genau festlegen.
|
composer check
|
||||||
|
tests/Symcon/bin/run-symcon-tests.sh single LadestationGateway
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,63 +1,296 @@
|
|||||||
# Ladestation Stand-Alone
|
# Ladestation Stand-Alone
|
||||||
|
|
||||||
> Status: Implementiert. Eigenstaendiges Verbrauchermodul mit direkter
|
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
|
||||||
> Geraeteanbindung; keine Beziehung zur Ladestation Gateway.
|
|
||||||
|
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
|
## Unterstuetzte Geraete
|
||||||
|
|
||||||
| Geraetetyp | Status | Steuerung |
|
| 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 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| go-e Charger, alte API | `GET /mqtt?payload=` | `alw` und `amp` |
|
| PV | ein | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum |
|
||||||
| go-e Charger Gemini / Gemini flex | `GET /api/status` | `frc` und `amp` |
|
| PV | aus | ausschliesslich die maximale Ladeleistung |
|
||||||
| smart-me Pico | Pico-Charging-API | Load-Management-Current-API mit Basic Auth |
|
| Peak | ein | ausschliesslich `[0]`; die Ladestation wird gestoppt |
|
||||||
|
| Peak | aus | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum |
|
||||||
|
|
||||||
Die Routen, Leistungsfaktoren und Erkennung wurden gezielt aus Enelix 1
|
Die festen ein- und dreiphasigen Leistungsstufen sowie deren Rundung wurden aus
|
||||||
uebernommen. Zugangsdaten werden weder als Variable noch im Diagnoseprotokoll
|
Enelix 1 uebernommen. Ohne gueltige Manager-Vorgabe faehrt das Modul bei
|
||||||
ausgegeben.
|
aktivem Solarladen mit `0 A`; bei ausgeschaltetem Solarladen verwendet es die
|
||||||
|
maximale angebotene Leistung.
|
||||||
|
|
||||||
## Verhalten
|
## Bedienung
|
||||||
|
|
||||||
- `FahrzeugVerbunden` entspricht bei go-e `car != 1` und bei Pico `State != 1`.
|
| Ident | Wirkung |
|
||||||
- Die Phasenerkennung verwendet wie Enelix 1 die gemessene Ladeleistung:
|
| --- | --- |
|
||||||
ueber 7500 W sind dreiphasig, sonst einphasig.
|
| `Aktiv` | Gemeinsame lokale EMS-Freigabe. `false` setzt das Angebot auf `[0]` und stoppt die Ladestation. |
|
||||||
- Ein bereits verbundenes Fahrzeug gilt bei einem ermittelten Maximalstrom
|
| `Ladefreigabe` | Lokale Freigabe fuer das Laden. Diese Variable ist nur mit `EinstellungenInVisu=true` sichtbar. |
|
||||||
unter 6 A als geladen.
|
| `Solarladen` | Schaltet das oben beschriebene PV-/Peak-Verhalten um. Diese Variable ist nur mit `EinstellungenInVisu=true` sichtbar. |
|
||||||
- PV mit Solarladen bietet `0 W` und alle Ladestufen von 6 A bis zum
|
|
||||||
konfigurierten Maximum an.
|
Die Properties `Ladefreigabe` und `Solarladen` definieren die Startwerte. Eine
|
||||||
- PV ohne Solarladen bietet ausschliesslich die Maximalleistung an.
|
Aenderung der jeweiligen Property wird beim Anwenden der Konfiguration in den
|
||||||
- Peak mit Solarladen bietet nur `[0]` an und stoppt die Ladestation.
|
lokalen Zustand uebernommen. Eine Bedienung der Variablen verwirft eine noch
|
||||||
- Peak ohne Solarladen bietet `[0, ...Ladestufen]` an, damit der Manager die
|
gueltige Manager-Vorgabe und berechnet das Leistungsangebot sofort neu.
|
||||||
Ladeleistung wie in Enelix 1 stufenweise reduzieren kann.
|
|
||||||
- Ohne gueltige Manager-Vorgabe wird bei Solarladen mit 0 A und ohne
|
## Konfiguration
|
||||||
Solarladen mit maximaler Leistung gefahren.
|
|
||||||
|
### 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
|
## Variablen
|
||||||
|
|
||||||
|
Immer sichtbar:
|
||||||
|
|
||||||
| Ident | Typ / Zugriff | Beschreibung |
|
| Ident | Typ / Zugriff | Beschreibung |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `FahrzeugVerbunden` | Boolean / Anzeige | Geraet meldet ein verbundenes Fahrzeug. |
|
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; der Initialwert ist `false`. |
|
||||||
| `FahrzeugGeladen` | Boolean / Anzeige | Enelix-1-Erkennung unter 6 A. |
|
| `FahrzeugVerbunden` | Boolean / Anzeige | Zeigt, ob die API ein verbundenes Fahrzeug meldet. |
|
||||||
| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` getrennt, `2` bereit, `3` laedt, `4` geladen. |
|
| `FahrzeugGeladen` | Boolean / Anzeige | Ergebnis der kompatiblen Enelix-1-Voll-Erkennung. |
|
||||||
| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Strom in A. |
|
| `Fahrzeugstatus` | Integer / Anzeige | Normalisierter Status von `0` bis `4`. |
|
||||||
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. |
|
| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Ladestrom in A. |
|
||||||
| `Ladefreigabe` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; lokale Ladeerlaubnis. |
|
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig oder `3` dreiphasig. |
|
||||||
| `Solarladen` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; variable Ladestufen ein/aus. |
|
|
||||||
|
|
||||||
## Properties
|
Nur mit `EinstellungenInVisu=true` sichtbar und bedienbar:
|
||||||
|
|
||||||
Neben den gemeinsamen Verbraucher-Properties werden Geraetetyp, Adresse,
|
- `Ladefreigabe`
|
||||||
Pico-ID, Seriennummer, Zugangsdaten, maximaler Ladestrom, Abfrageintervall
|
- `Solarladen`
|
||||||
sowie die Startwerte fuer Ladefreigabe und Solarladen konfiguriert.
|
|
||||||
|
|
||||||
Die gemeinsamen Verbraucher- und Diagnosevariablen entsprechen der
|
Nur mit `DiagnosevariablenAnzeigen=true` sichtbar:
|
||||||
EMS-Schnittstelle Version 4.0. `LoggingEin` steuert ausschliesslich das
|
|
||||||
laufende Debug-Protokoll.
|
| 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
|
## Tests
|
||||||
|
|
||||||
Die Adaptertests verwenden einen injizierten Fake-HTTP-Transport. Dadurch
|
Die Unit- und Adaptertests verwenden einen injizierten Fake-HTTP-Transport.
|
||||||
werden fuer alle drei Geraetevarianten Statusantworten, URL, HTTP-Methode,
|
Damit werden fuer go-e alt, go-e Gemini und smart-me Pico Statusantworten,
|
||||||
Authentisierung und Steueraufrufe geprueft, ohne ein reales Geraet anzusprechen.
|
URL, HTTP-Methode, Authentisierung und Steueraufrufe ohne reale Hardware
|
||||||
Der Symcon-Funktionstest nutzt den internen, nicht im Konfigurationsformular
|
geprueft.
|
||||||
sichtbaren `Testmodus` und prueft zusaetzlich Fahrzeug-/Phasenerkennung,
|
|
||||||
die vier PV-/Peak-Angebote sowie die Umschaltung ueber den `Solarladen`-Button.
|
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.
|
||||||
|
|||||||
@@ -8,15 +8,28 @@ kommen aus den Verbrauchermeldungen.
|
|||||||
|
|
||||||
## Variablen
|
## Variablen
|
||||||
|
|
||||||
Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netzleistung`.
|
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 |
|
| Ident | Typ / Zugriff | Beschreibung |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `Aktiv` | Boolean / bedienbar | Regelung ein/aus; Start `false`. |
|
| `Aktiv` | Boolean / bedienbar | Regelung ein/aus; Start `false`. |
|
||||||
| `Betriebsart` | String / Anzeige | `Inaktiv`, `PV` oder `Peak`. |
|
| `Betriebsart` | String / Anzeige | `Inaktiv`, `PV` oder `Peak`. |
|
||||||
| `Netzleistung` | Float / Anzeige | Aktuelle Netzleistung in W; positiv Bezug, negativ Einspeisung. |
|
| `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. |
|
| `NetzleistungGueltig` | Boolean / Logging | Messquelle vorhanden und aktuell. |
|
||||||
| `WirksameLastspitzengrenze` | Float / Logging | Aktuelle feste oder monatliche Grenze in W. |
|
| `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. |
|
| `Verteilbudget` | Float / Logging | Aktuell verfügbares Budget in W. |
|
||||||
| `VerbraucherAnzahl` | Integer / Logging | Anzahl zugeordneter Verbraucher. |
|
| `VerbraucherAnzahl` | Integer / Logging | Anzahl zugeordneter Verbraucher. |
|
||||||
| `VerbraucherVerfuegbar` | Integer / Logging | Anzahl aktuell verfügbarer Verbraucher. |
|
| `VerbraucherVerfuegbar` | Integer / Logging | Anzahl aktuell verfügbarer Verbraucher. |
|
||||||
@@ -47,12 +60,34 @@ Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netz
|
|||||||
| `Monatsgrenzen` | String/JSON | Editierbare Liste mit zwölf Monatswerten in W. |
|
| `Monatsgrenzen` | String/JSON | Editierbare Liste mit zwölf Monatswerten in W. |
|
||||||
| `SollwertSolarladen` | Float | `0` W; gewünschte Netzleistung im Solarladebetrieb. |
|
| `SollwertSolarladen` | Float | `0` W; gewünschte Netzleistung im Solarladebetrieb. |
|
||||||
| `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. |
|
| `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. |
|
||||||
| `PrognoseAnschluss` | String/JSON | Optionaler Forecast-Anschluss. |
|
| `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. |
|
| `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. |
|
||||||
| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. |
|
| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. |
|
||||||
| `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. |
|
| `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. |
|
||||||
| `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. |
|
| `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. |
|
||||||
| `LoggingEin` | Boolean | `false`; laufende Meldungen im Debug-Fenster ausgeben. |
|
| `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
|
Es gibt keine Sollwertquellenauswahl und keine zweite Prioritätseinstellung im
|
||||||
Manager. Im Modus `Aus` bleibt Solarladen aktiv, nur die Peak-Begrenzung entfällt.
|
Manager. Im Modus `Aus` bleibt Solarladen aktiv, nur die Peak-Begrenzung entfällt.
|
||||||
@@ -60,6 +95,115 @@ Die Monatsgrenzen werden in den Grundeinstellungen über eine Schaltfläche
|
|||||||
ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut
|
ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut
|
||||||
ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren.
|
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
|
## Lizenzierung
|
||||||
|
|
||||||
Der Manager arbeitet nur mit einer gueltigen Manager-Lizenz. Das Lizenzfeld steht
|
Der Manager arbeitet nur mit einer gueltigen Manager-Lizenz. Das Lizenzfeld steht
|
||||||
@@ -94,8 +238,11 @@ wird vom Lizenzserver abgelehnt.
|
|||||||
|
|
||||||
| Berechtigung | Freigegebene Funktion |
|
| Berechtigung | Freigegebene Funktion |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `manager_standard` | Manager-Grundregelung ohne Lastspitzenmodus. |
|
| `manager_standard` | Manager-Grundregelung ohne Peak Shaving. |
|
||||||
| `manager_peak` | Manager-Grundregelung einschliesslich konstantem oder monatlichem Lastspitzenmodus. |
|
| `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
|
Wird mit `manager_standard` ein Lastspitzenmodus aktiviert, bleibt der Manager
|
||||||
mit dem Hinweis `Peak Shaving ist nicht lizenziert` gesperrt. `manager_peak`
|
mit dem Hinweis `Peak Shaving ist nicht lizenziert` gesperrt. `manager_peak`
|
||||||
@@ -111,11 +258,15 @@ bis `offlineUntil` verwendbar. Der aktuelle Entwicklungsvertrag setzt diesen
|
|||||||
Zeitpunkt ungefaehr 14 Tage nach Ausstellung. Danach sperrt der Manager die
|
Zeitpunkt ungefaehr 14 Tage nach Ausstellung. Danach sperrt der Manager die
|
||||||
Regelung, bis der Lizenzserver wieder eine gueltige Antwort liefert.
|
Regelung, bis der Lizenzserver wieder eine gueltige Antwort liefert.
|
||||||
|
|
||||||
Die Installations-ID und die Lease liegen in internen Instanzattributen. Bei
|
Die Installations-ID und die Lease liegen in internen Instanzattributen. Die
|
||||||
einer Migration muss deshalb die vollstaendige Manager-Instanz mitsamt ihren
|
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
|
Attributen uebernommen werden. Eine neu erzeugte Instanz erhaelt eine andere
|
||||||
Installations-ID und kann einen bereits gebundenen Code nicht selbststaendig
|
Installations-ID und kann einen bereits gebundenen Code nicht selbststaendig
|
||||||
uebertragen.
|
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
|
### Status und Fehlerbehebung
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# EMS-Module und Modulentwürfe
|
# EMS-Module und Modulentwürfe
|
||||||
|
|
||||||
> Manager, Warmwassererwaermer, Verbraucher 1-Stufig und Ladestation
|
> Manager, Warmwassererwaermer, Pufferspeicher, Batterie, Verbraucher 1-Stufig,
|
||||||
> Stand-Alone sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren
|
> Waermepumpe, Ladestation Stand-Alone, Easee Gateway und Ladestation Gateway
|
||||||
> Ordner enthalten Besprechungsgrundlagen.
|
> sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren Ordner
|
||||||
|
> enthalten Besprechungsgrundlagen.
|
||||||
|
|
||||||
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
|
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
|
||||||
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
|
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
|
||||||
@@ -11,14 +12,14 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte.
|
|||||||
| Modul | Rolle |
|
| Modul | Rolle |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse (implementiert) |
|
| [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse (implementiert) |
|
||||||
| [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers |
|
| [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers (implementiert) |
|
||||||
| [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung (implementiert) |
|
| [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung (implementiert) |
|
||||||
| [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve |
|
| [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve |
|
||||||
| [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) |
|
| [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) |
|
||||||
| [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready |
|
| [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready |
|
||||||
| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung (implementiert) |
|
| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung (implementiert) |
|
||||||
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Ladestation am Easee Gateway |
|
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Eventbasierte Ladestation am Easee Gateway (implementiert) |
|
||||||
| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation |
|
| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation (implementiert) |
|
||||||
|
|
||||||
## Review-Regel
|
## Review-Regel
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +1,89 @@
|
|||||||
# Wärmepumpe
|
# Wärmepumpe
|
||||||
|
|
||||||
> Status: Diskussionsentwurf.
|
> Status: Implementiert.
|
||||||
|
|
||||||
Die Kontaktwirkung ist als Sperrkontakte oder SG Ready konfigurierbar. Die
|
Das Modul bindet eine Wärmepumpe über zwei schaltbare Boolean-Ausgänge in den
|
||||||
elektrische Leistung kann fehlen, berechnet oder gemessen werden.
|
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.
|
||||||
|
|
||||||
## Zusätzliche Variablen
|
## Verhalten
|
||||||
|
|
||||||
| Ident | Typ / Zugriff | Beschreibung |
|
- 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 / Anzeige | `0` unbekannt, `1` aus, `2` normal, `3` erhöht, `4` gesperrt. |
|
| `Waermepumpenzustand` | Integer | `1` normal/aus, `2` normal/laufend, `3` erhöht, `4` gesperrt |
|
||||||
| `SperreAktiv` | Boolean / Anzeige | Aktuelle Sperranforderung. |
|
| `WaermepumpeLaeuft` | Boolean | Bestätigter Betriebsstatus |
|
||||||
| `ErhoehungAktiv` | Boolean / Anzeige | Aktuelle Erhöhungsanforderung. |
|
| `SperreAktiv` | Boolean | Sperrkontakt ist aktiv |
|
||||||
| `SGReadyZustand` | Integer / Anzeige | Bei SG Ready Zustand 0–3. |
|
| `ErhoehungAktiv` | Boolean | Erhöhungskontakt ist aktiv |
|
||||||
| `Laufzeit` | Integer / Anzeige | Kumulierte bestätigte Laufzeit in Sekunden. |
|
| `SGReadyZustand` | Integer | Bitwert der beiden logischen Kontakte |
|
||||||
|
| `Laufzeit` | Integer | Kumulierte bestätigte Laufzeit in Sekunden |
|
||||||
|
|
||||||
## Zusätzliche Properties
|
Optionale Diagnosevariablen zeigen unter anderem Ist- und Sollleistung,
|
||||||
|
Verfügbarkeit, Anlaufstatus, Wiederholsperre, Sperrerholung, verbleibende
|
||||||
|
Mindestzeiten und die Sperrsumme der letzten 24 Stunden.
|
||||||
|
|
||||||
| Ident | Typ | Standard / Beschreibung |
|
## Adaption aus Enelix 1
|
||||||
| --- | --- | --- |
|
|
||||||
| `Kontaktwirkung` | Auswahl | `Sperrkontakte`; alternativ `SG Ready`. |
|
|
||||||
| `Kontakt1VariableID` | Integer | `0`; Sperre oder SG-Ready-Eingang 1. |
|
|
||||||
| `Kontakt2VariableID` | Integer | `0`; Erhöhung oder SG-Ready-Eingang 2. |
|
|
||||||
| `Kontakt1Invertiert` | Boolean | `false`. |
|
|
||||||
| `Kontakt2Invertiert` | Boolean | `false`. |
|
|
||||||
| `Leistungsermittlung` | Auswahl | `Nicht vorhanden`, `Berechnet` oder `Gemessen`; entspricht Quelle 0/1/2. |
|
|
||||||
| `LeistungsmessungVariableID` | Integer | `0`; nur bei gemessener Leistung. |
|
|
||||||
| `Zustandskennlinie` | String/JSON | `[]`; bei berechneter Leistung je Zustand Planleistung und Kontaktbelegung. |
|
|
||||||
| `BetriebsrueckmeldungVariableID` | Integer | `0`; optionale echte Betriebsrückmeldung. |
|
|
||||||
| `Mindestlaufzeit` | Integer | `0*` s; anlagengerecht festlegen. |
|
|
||||||
| `Mindeststillstand` | Integer | `0*` s; anlagengerecht festlegen. |
|
|
||||||
| `MaxSperrzeit` | Integer | `0` s; `0` deaktiviert die EMS-Sperrfunktion. |
|
|
||||||
|
|
||||||
Zeit- und wetterabhängiges Sperren beziehungsweise Erhöhen aus dem Altmodul
|
| Bereich | Behandlung in Enelix 2 |
|
||||||
entfällt.
|
| --- | --- |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
## Zustand
|
## Lizenzierung und Manager
|
||||||
|
|
||||||
`Waermepumpenzustand`, `SperreAktiv`, `ErhoehungAktiv` beziehungsweise
|
Der Manager erkennt das Modul über die Modul-ID
|
||||||
`SGReadyZustand` als Status, `Laufzeit_s` als Istwert und
|
`{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}`. Es verwendet das bestehende
|
||||||
`Kommunikationsfehler` als Störung.
|
Lizenzkontingent `consumer_single`, das im Portal als einstufiger Verbraucher
|
||||||
|
mit Wärmepumpe als Beispiel angeboten wird.
|
||||||
|
|
||||||
## Offene Punkte
|
## Sicherheitshinweise
|
||||||
|
|
||||||
- Der Formularbutton „Wassererwärmer über Wärmepumpe betreiben“ bleibt ohne
|
Die beiden Ausgänge müssen unterschiedliche, schaltbare Boolean-Variablen sein.
|
||||||
Regelwirkung, bis Anschluss und Verriegelung gemeinsam definiert sind.
|
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.
|
||||||
|
|||||||
+11
-9
@@ -42,7 +42,9 @@ werden, wenn sie vor dem Lauf nicht existierten.
|
|||||||
- `single`: genau die als Auswahl übergebenen Module
|
- `single`: genau die als Auswahl übergebenen Module
|
||||||
- `affected`: die durch geänderte Pfade ermittelten Module
|
- `affected`: die durch geänderte Pfade ermittelten Module
|
||||||
|
|
||||||
Verfügbare Module: `LadestationStandAlone`, `Manager`, `VerbraucherEinStufig`, `Warmwassererwaermer`.
|
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.
|
Der Manager-Test enthält Manager ohne Verbraucher, jeden Verbrauchertyp einzeln und alle aktuell implementierten Verbrauchertypen gemeinsam.
|
||||||
|
|
||||||
@@ -83,15 +85,15 @@ Zusätzlich schreibt der Runner die Zusammenfassung in das IP-Symcon-Log.
|
|||||||
|
|
||||||
## CI-Regeln
|
## CI-Regeln
|
||||||
|
|
||||||
Bei jedem Push laufen sämtliche PHPUnit-Tests. Auf `develop` werden zusätzlich
|
Bei jedem Push laufen sämtliche PHPUnit-Tests und die PHP-Syntaxprüfung. Die
|
||||||
die von den geänderten Pfaden betroffenen Symcon-Modultests ausgeführt. Bei
|
Symcon-Modultests werden bis zur Verfügbarkeit eines geschützten Runners auf
|
||||||
gemeinsamen Framework- oder Vertragsänderungen läuft die vollständige Suite.
|
der isolierten IP-Symcon-8.0-Instanz des Agent-Servers ausgeführt. Vor jeder
|
||||||
Für `beta` ist immer `all` vorgeschrieben.
|
Übernahme nach `beta` ist ein erfolgreicher Lauf im Modus `all` verbindlich.
|
||||||
|
Der erzeugte JSON- und JUnit-Bericht gehört zum Freigabenachweis.
|
||||||
|
|
||||||
Die Symcon-Jobs benötigen einen geschützten Runner mit dem Label `symcon-8`,
|
Eine spätere CI-Automatisierung benötigt einen geschützten Runner mit dem Label
|
||||||
lokalem Zugriff auf eine isolierte IP-Symcon-8.x-Instanz und den installierten
|
`symcon-8`, lokalem Zugriff auf die isolierte IP-Symcon-Instanz und den exakt
|
||||||
Repository-Stand. Ohne diesen Runner ist die Richtlinie dokumentiert, aber
|
zu prüfenden Repository-Stand.
|
||||||
nicht technisch durchsetzbar.
|
|
||||||
|
|
||||||
## Checkliste für neue Module
|
## Checkliste für neue Module
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,22 @@
|
|||||||
|
|
||||||
Die Demo erzeugt idempotent eine vollständige, spielbare EMS-Anlage in IP-Symcon 8.x:
|
Die Demo erzeugt idempotent eine vollständige, spielbare EMS-Anlage in IP-Symcon 8.x:
|
||||||
|
|
||||||
- Enelix Manager mit manueller Zuordnung,
|
- Enelix Manager mit manueller Zuordnung von fünf steuerbaren Teilnehmern,
|
||||||
- zwei mehrstufige Boiler (1,2/2,4 kW und 1,5/3,0 kW),
|
- zwei mehrstufige Boiler (1,2/2,4 kW und 1,5/3,0 kW),
|
||||||
- einen einstufigen Entfeuchter (0,9 kW),
|
- einen einstufigen Entfeuchter (0,9 kW),
|
||||||
- simulierte PV-Erzeugung, Grundlast, Netzleistung und Boilertemperaturen,
|
- einen zweistufigen Pufferspeicher (1,8/3,6 kW) mit Heizkurve,
|
||||||
- native Symcon-Instanz `Energy Distribution` für den Energiefluss,
|
- einen bidirektionalen Batteriespeicher (10 kWh, +/-3,5 kW),
|
||||||
- eigene Kachelansichten mit allen Laufzeitparametern je Boiler,
|
- eine technische Anlagentopologie mit Hybridwechselrichter, PV-Fläche und
|
||||||
- kombinierten Verlauf für PV-Leistung, Hausverbrauch und Netzleistung,
|
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,
|
- kombinierten Verlauf der kumulierten PV-, Haus-, Bezugs- und Einspeiseenergie,
|
||||||
- Archivierung der Leistungs-, Energie- und Temperaturwerte.
|
- Archivierung der Leistungs-, Energie- und Temperaturwerte.
|
||||||
|
|
||||||
@@ -27,26 +36,60 @@ print_r($result);
|
|||||||
Ein weiterer Lauf aktualisiert dieselbe Anlage anhand stabiler Idents und erzeugt keine Duplikate.
|
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.
|
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
|
## Bedienung
|
||||||
|
|
||||||
In der Kachelvisualisierung öffnet die Startkategorie `Enelix 2 Demoanlage`.
|
In der Kachelvisualisierung öffnet die Startkategorie `Enelix 2 Demoanlage`.
|
||||||
Unter `Simulation` lassen sich Tageslauf, Tageszeit, Geschwindigkeit,
|
Unter `Simulation` lassen sich Tageslauf, Tageszeit, Geschwindigkeit,
|
||||||
PV-Spitzenleistung, Bewölkung und Haus-Grundlast verändern. Der Manager verteilt
|
dynamische Profile, PV-Spitzenleistung, mittlere Bewölkung, Haus-Grundlast und
|
||||||
den verfügbaren PV-Überschuss nach Priorität auf beide Boiler und den Entfeuchter.
|
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`
|
`Übersicht` zeigt die native Symcon-Energieverteilung. Die Kacheln `Boiler 1`,
|
||||||
und `Boiler 2` öffnen direkt sämtliche Bedien-, Status- und Diagnosevariablen,
|
`Boiler 2`, `Pufferspeicher` und `Batteriespeicher` öffnen direkt die jeweiligen
|
||||||
ohne zuerst das Instanz-Konfigurationsformular zu zeigen. Unter `Leistung und
|
Bedien-, Status- und Diagnosevariablen, ohne zuerst das
|
||||||
Energie` liegen die beiden kombinierten Zeitdiagramme und die Energiezähler.
|
Instanz-Konfigurationsformular zu zeigen. Positive Batterieleistung bedeutet
|
||||||
Die Energiesummen verwenden die beschleunigte Simulationszeit.
|
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
|
## Validierung
|
||||||
|
|
||||||
- Alle vier Enelix-Instanzen müssen Status 102 besitzen.
|
- Die fünf Verbraucher müssen Status 102 besitzen; der Manager nach Lizenzaktivierung ebenfalls (vorher erwarteter Status 203).
|
||||||
- Der Manager muss drei Verbraucher melden.
|
- Der Manager muss fünf Teilnehmer aus vier unterschiedlichen Modultypen melden.
|
||||||
- Bei ausreichendem PV-Überschuss müssen Verbraucher schalten.
|
- Bei ausreichendem PV-Überschuss müssen thermische Verbraucher schalten und die Batterie laden.
|
||||||
- Netzleistung, PV-Leistung, Hausverbrauch, Verbraucherleistungen, Energiesummen und Temperaturen werden archiviert.
|
- Bei höherem verbleibendem PV-Überschuss muss das Wechselrichterregister die PV-Leistung so begrenzen, dass ungefähr 3.000 W ins Netz eingespeist werden.
|
||||||
- Die native Energieverteilung muss sechs Knoten enthalten.
|
- 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.
|
- Beide Diagrammkacheln müssen mindestens drei beschriftete Zeitreihen anzeigen.
|
||||||
- Ein erneuter Installationslauf muss dieselbe Root-ID zurückgeben.
|
- Ein erneuter Installationslauf muss dieselbe Root-ID zurückgeben.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const ENELIX_DEMO_ROOT_IDENT = 'ENELIX_DEMO_2';
|
|||||||
const ENELIX_DEMO_MANAGER_MODULE = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
|
const ENELIX_DEMO_MANAGER_MODULE = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
|
||||||
const ENELIX_DEMO_BOILER_MODULE = '{B7C54AF4-AD7D-4FE4-B75D-203693906251}';
|
const ENELIX_DEMO_BOILER_MODULE = '{B7C54AF4-AD7D-4FE4-B75D-203693906251}';
|
||||||
const ENELIX_DEMO_SINGLE_MODULE = '{15879A4E-D0C2-4495-83DE-46E1E462591E}';
|
const ENELIX_DEMO_SINGLE_MODULE = '{15879A4E-D0C2-4495-83DE-46E1E462591E}';
|
||||||
|
const ENELIX_DEMO_BUFFER_MODULE = '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}';
|
||||||
|
const ENELIX_DEMO_BATTERY_MODULE = '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}';
|
||||||
const ENELIX_DEMO_DUMMY_MODULE = '{485D0419-BE97-4548-AA9C-C083EB82E61E}';
|
const ENELIX_DEMO_DUMMY_MODULE = '{485D0419-BE97-4548-AA9C-C083EB82E61E}';
|
||||||
const ENELIX_DEMO_TILE_MODULE = '{B5B875BB-9B76-45FD-4E67-2607E45B3AC4}';
|
const ENELIX_DEMO_TILE_MODULE = '{B5B875BB-9B76-45FD-4E67-2607E45B3AC4}';
|
||||||
const ENELIX_DEMO_ARCHIVE_MODULE = '{43192F0B-135B-4CE7-A0A7-1475603F3060}';
|
const ENELIX_DEMO_ARCHIVE_MODULE = '{43192F0B-135B-4CE7-A0A7-1475603F3060}';
|
||||||
@@ -28,15 +30,17 @@ function enelixDemoInstall(): array
|
|||||||
|
|
||||||
enelixDemoEnsureProfiles();
|
enelixDemoEnsureProfiles();
|
||||||
|
|
||||||
$legacyFlow = enelixDemoEnsureVariable($dashboard, 'Energiefluss', 'Energiefluss (Legacy)', 3, '~HTMLBox', '', 90);
|
$legacyFlow = enelixDemoEnsureVariable($dashboard, 'Energiefluss', 'Energiefluss (Legacy)', 3, '~HTMLBox', '', 900);
|
||||||
IPS_SetHidden($legacyFlow, true);
|
IPS_SetHidden($legacyFlow, true);
|
||||||
$pvDisplay = enelixDemoEnsureVariable($dashboard, 'PVLeistung', 'PV-Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 20);
|
$pvDisplay = enelixDemoEnsureVariable($dashboard, 'PVLeistung', 'PV-Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 20);
|
||||||
$houseDisplay = enelixDemoEnsureVariable($dashboard, 'Hausverbrauch', 'Hausverbrauch gesamt', 2, 'ENELIX.Demo.PowerPositive', 0.0, 30);
|
$houseDisplay = enelixDemoEnsureVariable($dashboard, 'Hausverbrauch', 'Hausverbrauch gesamt', 2, 'ENELIX.Demo.PowerPositive', 0.0, 30);
|
||||||
$loadDisplay = enelixDemoEnsureVariable($dashboard, 'Verbraucherleistung', 'Geregelte Verbraucher', 2, 'ENELIX.Demo.PowerPositive', 0.0, 40);
|
$loadDisplay = enelixDemoEnsureVariable($dashboard, 'Verbraucherleistung', 'EMS-Leistung inkl. Speicher', 2, 'ENELIX.Demo.Power', 0.0, 40);
|
||||||
$grid = enelixDemoEnsureVariable($dashboard, 'Netzleistung', 'Netzleistung', 2, 'ENELIX.Demo.Power', 0.0, 50);
|
$grid = enelixDemoEnsureVariable($dashboard, 'Netzleistung', 'Netzleistung', 2, 'ENELIX.Demo.Power', 0.0, 50);
|
||||||
$boiler1Power = enelixDemoEnsureVariable($dashboard, 'Boiler1Leistung', 'Boiler 1 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 60);
|
$boiler1Power = enelixDemoEnsureVariable($dashboard, 'Boiler1Leistung', 'Boiler 1 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 60);
|
||||||
$boiler2Power = enelixDemoEnsureVariable($dashboard, 'Boiler2Leistung', 'Boiler 2 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 70);
|
$boiler2Power = enelixDemoEnsureVariable($dashboard, 'Boiler2Leistung', 'Boiler 2 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 70);
|
||||||
$dryerPower = enelixDemoEnsureVariable($dashboard, 'EntfeuchterLeistung', 'Entfeuchter Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80);
|
$dryerPower = enelixDemoEnsureVariable($dashboard, 'EntfeuchterLeistung', 'Entfeuchter Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80);
|
||||||
|
$bufferPower = enelixDemoEnsureVariable($dashboard, 'PufferLeistung', 'Pufferspeicher Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 90);
|
||||||
|
$batteryPower = enelixDemoEnsureVariable($dashboard, 'BatterieLeistung', 'Batterieleistung', 2, 'ENELIX.Demo.Power', 0.0, 100);
|
||||||
|
|
||||||
$powerChart = enelixDemoEnsureVariable($history, 'Leistungsdiagramm', 'Leistungsverlauf (Legacy)', 3, '~HTMLBox', '', 90);
|
$powerChart = enelixDemoEnsureVariable($history, 'Leistungsdiagramm', 'Leistungsverlauf (Legacy)', 3, '~HTMLBox', '', 90);
|
||||||
$energyChart = enelixDemoEnsureVariable($history, 'Energiediagramm', 'Kumulierte Energien (Legacy)', 3, '~HTMLBox', '', 100);
|
$energyChart = enelixDemoEnsureVariable($history, 'Energiediagramm', 'Kumulierte Energien (Legacy)', 3, '~HTMLBox', '', 100);
|
||||||
@@ -50,9 +54,11 @@ function enelixDemoInstall(): array
|
|||||||
$auto = enelixDemoEnsureVariable($simulation, 'Automatik', 'Tageslauf automatisch', 0, '~Switch', true, 10);
|
$auto = enelixDemoEnsureVariable($simulation, 'Automatik', 'Tageslauf automatisch', 0, '~Switch', true, 10);
|
||||||
$hour = enelixDemoEnsureVariable($simulation, 'Tageszeit', 'Simulierte Tageszeit', 2, 'ENELIX.Demo.Hour', 8.0, 20);
|
$hour = enelixDemoEnsureVariable($simulation, 'Tageszeit', 'Simulierte Tageszeit', 2, 'ENELIX.Demo.Hour', 8.0, 20);
|
||||||
$speed = enelixDemoEnsureVariable($simulation, 'Geschwindigkeit', 'Minuten je Schritt', 1, 'ENELIX.Demo.Speed', 10, 30);
|
$speed = enelixDemoEnsureVariable($simulation, 'Geschwindigkeit', 'Minuten je Schritt', 1, 'ENELIX.Demo.Speed', 10, 30);
|
||||||
$peak = enelixDemoEnsureVariable($simulation, 'PVSpitze', 'PV-Spitzenleistung', 2, 'ENELIX.Demo.PowerPositive', 7000.0, 40);
|
$dynamic = enelixDemoEnsureVariable($simulation, 'DynamischeProfile', 'Dynamische Tagesprofile', 0, '~Switch', true, 40);
|
||||||
$cloud = enelixDemoEnsureVariable($simulation, 'Bewoelkung', 'Bewölkung', 1, 'ENELIX.Demo.Percent', 20, 50);
|
$peak = enelixDemoEnsureVariable($simulation, 'PVSpitze', 'PV-Spitzenleistung', 2, 'ENELIX.Demo.PowerPositive', 9000.0, 50);
|
||||||
$base = enelixDemoEnsureVariable($simulation, 'Grundlast', 'Haus-Grundlast', 2, 'ENELIX.Demo.PowerPositive', 900.0, 60);
|
$cloud = enelixDemoEnsureVariable($simulation, 'Bewoelkung', 'Mittlere Bewölkung', 1, 'ENELIX.Demo.Percent', 20, 60);
|
||||||
|
$base = enelixDemoEnsureVariable($simulation, 'Grundlast', 'Mittlere Haus-Grundlast', 2, 'ENELIX.Demo.PowerPositive', 900.0, 70);
|
||||||
|
$extraLoad = enelixDemoEnsureVariable($simulation, 'Zusatzlast', 'Ungeregelte Zusatzlast', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80);
|
||||||
|
|
||||||
$actorScript = enelixDemoEnsureScript(
|
$actorScript = enelixDemoEnsureScript(
|
||||||
$technical,
|
$technical,
|
||||||
@@ -61,6 +67,13 @@ function enelixDemoInstall(): array
|
|||||||
"<?php\nSetValue((int) \$_IPS['VARIABLE'], (bool) \$_IPS['VALUE']);\n",
|
"<?php\nSetValue((int) \$_IPS['VARIABLE'], (bool) \$_IPS['VALUE']);\n",
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
|
$registerScript = enelixDemoEnsureScript(
|
||||||
|
$technical,
|
||||||
|
'RegisterAktion',
|
||||||
|
'Simulierte Register',
|
||||||
|
"<?php\nSetValue((int) \$_IPS['VARIABLE'], \$_IPS['VALUE']);\n",
|
||||||
|
15
|
||||||
|
);
|
||||||
$tickScript = enelixDemoEnsureScript(
|
$tickScript = enelixDemoEnsureScript(
|
||||||
$technical,
|
$technical,
|
||||||
'SimulationTick',
|
'SimulationTick',
|
||||||
@@ -76,7 +89,7 @@ function enelixDemoInstall(): array
|
|||||||
30
|
30
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach ([$auto, $hour, $speed, $peak, $cloud, $base] as $variableId) {
|
foreach ([$auto, $hour, $speed, $dynamic, $peak, $cloud, $base, $extraLoad] as $variableId) {
|
||||||
IPS_SetVariableCustomAction($variableId, $controlScript);
|
IPS_SetVariableCustomAction($variableId, $controlScript);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,36 +97,59 @@ function enelixDemoInstall(): array
|
|||||||
|
|
||||||
$temp1 = enelixDemoEnsureVariable($technical, 'Boiler1Temperatur', 'Boiler 1 Temperatur', 2, '~Temperature', 49.0, 100);
|
$temp1 = enelixDemoEnsureVariable($technical, 'Boiler1Temperatur', 'Boiler 1 Temperatur', 2, '~Temperature', 49.0, 100);
|
||||||
$temp2 = enelixDemoEnsureVariable($technical, 'Boiler2Temperatur', 'Boiler 2 Temperatur', 2, '~Temperature', 47.0, 110);
|
$temp2 = enelixDemoEnsureVariable($technical, 'Boiler2Temperatur', 'Boiler 2 Temperatur', 2, '~Temperature', 47.0, 110);
|
||||||
$boiler11 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe1', 'Boiler 1 Stufe 1', 0, '~Switch', false, 120);
|
$bufferTemp = enelixDemoEnsureVariable($technical, 'PufferTemperatur', 'Puffertemperatur', 2, '~Temperature', 36.0, 120);
|
||||||
$boiler12 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe2', 'Boiler 1 Stufe 2', 0, '~Switch', false, 130);
|
$outsideTemp = enelixDemoEnsureVariable($technical, 'AussenTemperatur', 'Aussentemperatur', 2, '~Temperature', 6.0, 130);
|
||||||
$boiler21 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe1', 'Boiler 2 Stufe 1', 0, '~Switch', false, 140);
|
$boiler11 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe1', 'Boiler 1 Stufe 1', 0, '~Switch', false, 200);
|
||||||
$boiler22 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe2', 'Boiler 2 Stufe 2', 0, '~Switch', false, 150);
|
$boiler12 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe2', 'Boiler 1 Stufe 2', 0, '~Switch', false, 210);
|
||||||
$dryContact = enelixDemoEnsureVariable($technical, 'EntfeuchterKontakt', 'Entfeuchter Kontakt', 0, '~Switch', false, 160);
|
$boiler21 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe1', 'Boiler 2 Stufe 1', 0, '~Switch', false, 220);
|
||||||
$chartUpdated = enelixDemoEnsureVariable($technical, 'LetztesDiagrammUpdate', 'Letztes Diagramm-Update', 1, '', 0, 170);
|
$boiler22 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe2', 'Boiler 2 Stufe 2', 0, '~Switch', false, 230);
|
||||||
|
$dryContact = enelixDemoEnsureVariable($technical, 'EntfeuchterKontakt', 'Entfeuchter Kontakt', 0, '~Switch', false, 240);
|
||||||
|
$buffer1 = enelixDemoEnsureVariable($technical, 'PufferStufe1', 'Puffer Stufe 1', 0, '~Switch', false, 250);
|
||||||
|
$buffer2 = enelixDemoEnsureVariable($technical, 'PufferStufe2', 'Puffer Stufe 2', 0, '~Switch', false, 260);
|
||||||
|
$batteryMaxCharge = enelixDemoEnsureVariable($technical, 'BatterieMaxLaden', 'Batterie max. laden', 2, 'ENELIX.Demo.PowerPositive', 3500.0, 300);
|
||||||
|
$batteryMaxDischarge = enelixDemoEnsureVariable($technical, 'BatterieMaxEntladen', 'Batterie max. entladen', 2, 'ENELIX.Demo.PowerPositive', 3500.0, 310);
|
||||||
|
$batterySoc = enelixDemoEnsureVariable($technical, 'BatterieLadezustand', 'Batterie Ladezustand', 2, 'ENELIX.Demo.PercentFloat', 52.0, 320);
|
||||||
|
$batteryActual = enelixDemoEnsureVariable($technical, 'BatterieIstleistung', 'Batterie Istleistung', 2, 'ENELIX.Demo.Power', 0.0, 330);
|
||||||
|
$batteryManagement = enelixDemoEnsureVariable($technical, 'BatterieManagement', 'Batterie Managementregister', 1, '', 0, 340);
|
||||||
|
$batteryMode = enelixDemoEnsureVariable($technical, 'BatterieModus', 'Batterie Modusregister', 1, '', 0, 350);
|
||||||
|
$batteryRegisterPower = enelixDemoEnsureVariable($technical, 'BatterieRegisterLeistung', 'Batterie Leistungsregister', 2, 'ENELIX.Demo.PowerPositive', 0.0, 360);
|
||||||
|
$inverterActual = enelixDemoEnsureVariable($technical, 'WechselrichterIstleistung', 'PV-Wechselrichter Istleistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 370);
|
||||||
|
$inverterLimit = enelixDemoEnsureVariable($technical, 'WechselrichterLeistungsgrenze', 'PV-Wechselrichter Leistungsgrenze', 2, 'ENELIX.Demo.PowerPositive', max(0.0, (float) GetValue($peak)), 380);
|
||||||
|
$chartUpdated = enelixDemoEnsureVariable($technical, 'LetztesDiagrammUpdate', 'Letztes Diagramm-Update', 1, '', 0, 390);
|
||||||
IPS_SetHidden($chartUpdated, true);
|
IPS_SetHidden($chartUpdated, true);
|
||||||
|
|
||||||
$flowPv = enelixDemoEnsureVariable($technical, 'EnergieflussPVkW', 'PV-Leistung', 2, '~Power', 0.0, 300);
|
$flowPv = enelixDemoEnsureVariable($technical, 'EnergieflussPVkW', 'PV-Leistung', 2, '~Power', 0.0, 500);
|
||||||
$flowGrid = enelixDemoEnsureVariable($technical, 'EnergieflussNetzkW', 'Netzleistung', 2, '~Power', 0.0, 310);
|
$flowGrid = enelixDemoEnsureVariable($technical, 'EnergieflussNetzkW', 'Netzleistung', 2, '~Power', 0.0, 510);
|
||||||
$flowBase = enelixDemoEnsureVariable($technical, 'EnergieflussGrundlastkW', 'Haus-Grundlast', 2, '~Power', 0.0, 320);
|
$flowBase = enelixDemoEnsureVariable($technical, 'EnergieflussGrundlastkW', 'Haus-Grundlast', 2, '~Power', 0.0, 520);
|
||||||
$flowBoiler1 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler1kW', 'Boiler 1', 2, '~Power', 0.0, 330);
|
$flowBoiler1 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler1kW', 'Boiler 1', 2, '~Power', 0.0, 530);
|
||||||
$flowBoiler2 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler2kW', 'Boiler 2', 2, '~Power', 0.0, 340);
|
$flowBoiler2 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler2kW', 'Boiler 2', 2, '~Power', 0.0, 540);
|
||||||
$flowDryer = enelixDemoEnsureVariable($technical, 'EnergieflussEntfeuchterkW', 'Entfeuchter', 2, '~Power', 0.0, 350);
|
$flowDryer = enelixDemoEnsureVariable($technical, 'EnergieflussEntfeuchterkW', 'Entfeuchter', 2, '~Power', 0.0, 550);
|
||||||
$flowHouse = enelixDemoEnsureVariable($technical, 'DiagrammHausverbrauchkW', 'Hausverbrauch gesamt', 2, '~Power', 0.0, 360);
|
$flowBuffer = enelixDemoEnsureVariable($technical, 'EnergieflussPufferkW', 'Pufferspeicher', 2, '~Power', 0.0, 560);
|
||||||
$flowLoads = enelixDemoEnsureVariable($technical, 'DiagrammVerbraucherkW', 'Geregelte Verbraucher', 2, '~Power', 0.0, 370);
|
$flowBattery = enelixDemoEnsureVariable($technical, 'EnergieflussBatteriekW', 'Batteriespeicher', 2, '~Power', 0.0, 570);
|
||||||
|
$flowHouse = enelixDemoEnsureVariable($technical, 'DiagrammHausverbrauchkW', 'Hausverbrauch gesamt', 2, '~Power', 0.0, 580);
|
||||||
|
$flowLoads = enelixDemoEnsureVariable($technical, 'DiagrammVerbraucherkW', 'EMS-Leistung inkl. Speicher', 2, '~Power', 0.0, 590);
|
||||||
foreach ([
|
foreach ([
|
||||||
$flowPv, $flowGrid, $flowBase, $flowBoiler1,
|
$flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2,
|
||||||
$flowBoiler2, $flowDryer, $flowHouse, $flowLoads,
|
$flowDryer, $flowBuffer, $flowBattery, $flowHouse, $flowLoads,
|
||||||
] as $flowVariableId) {
|
] as $flowVariableId) {
|
||||||
IPS_SetHidden($flowVariableId, true);
|
IPS_SetHidden($flowVariableId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ([$boiler11, $boiler12, $boiler21, $boiler22, $dryContact] as $actorId) {
|
foreach ([$boiler11, $boiler12, $boiler21, $boiler22, $dryContact, $buffer1, $buffer2] as $actorId) {
|
||||||
IPS_SetVariableCustomAction($actorId, $actorScript);
|
IPS_SetVariableCustomAction($actorId, $actorScript);
|
||||||
}
|
}
|
||||||
|
foreach ([$batteryManagement, $batteryMode, $batteryRegisterPower, $inverterLimit] as $registerId) {
|
||||||
|
IPS_SetVariableCustomAction($registerId, $registerScript);
|
||||||
|
}
|
||||||
|
foreach ([$temp1, $temp2, $bufferTemp, $outsideTemp, $batteryMaxCharge, $batteryMaxDischarge, $batterySoc, $batteryActual, $inverterActual] as $sensorId) {
|
||||||
|
SetValue($sensorId, GetValue($sensorId));
|
||||||
|
}
|
||||||
|
|
||||||
$boiler1 = enelixDemoEnsureInstance($ems, 'Boiler1', 'Boiler 1 · 300 Liter', ENELIX_DEMO_BOILER_MODULE, 20);
|
$boiler1 = enelixDemoEnsureInstance($ems, 'Boiler1', 'Boiler 1 · 300 Liter', ENELIX_DEMO_BOILER_MODULE, 20);
|
||||||
$boiler2 = enelixDemoEnsureInstance($ems, 'Boiler2', 'Boiler 2 · 500 Liter', ENELIX_DEMO_BOILER_MODULE, 30);
|
$boiler2 = enelixDemoEnsureInstance($ems, 'Boiler2', 'Boiler 2 · 500 Liter', ENELIX_DEMO_BOILER_MODULE, 30);
|
||||||
$dryer = enelixDemoEnsureInstance($ems, 'Entfeuchter', 'Entfeuchter · 1-stufig', ENELIX_DEMO_SINGLE_MODULE, 40);
|
$dryer = enelixDemoEnsureInstance($ems, 'Entfeuchter', 'Entfeuchter · 1-stufig', ENELIX_DEMO_SINGLE_MODULE, 40);
|
||||||
|
$buffer = enelixDemoEnsureInstance($ems, 'Pufferspeicher', 'Pufferspeicher · Heizkurve', ENELIX_DEMO_BUFFER_MODULE, 50);
|
||||||
|
$battery = enelixDemoEnsureInstance($ems, 'Batterie', 'Batteriespeicher · 10 kWh', ENELIX_DEMO_BATTERY_MODULE, 60);
|
||||||
|
|
||||||
enelixDemoConfigure($boiler1, [
|
enelixDemoConfigure($boiler1, [
|
||||||
'Boilerfuehler_PT1' => $temp1,
|
'Boilerfuehler_PT1' => $temp1,
|
||||||
@@ -157,6 +193,52 @@ function enelixDemoInstall(): array
|
|||||||
'Mindestausschaltdauer' => 2,
|
'Mindestausschaltdauer' => 2,
|
||||||
'Mindestlaufzeit' => 0,
|
'Mindestlaufzeit' => 0,
|
||||||
'PrioritaetPV' => 2,
|
'PrioritaetPV' => 2,
|
||||||
|
'PrioritaetPeak' => 3,
|
||||||
|
'Meldeintervall' => 5,
|
||||||
|
'VorgabeTimeout' => 30,
|
||||||
|
'EinstellungenInVisu' => true,
|
||||||
|
'DiagnosevariablenAnzeigen' => true,
|
||||||
|
]);
|
||||||
|
enelixDemoConfigure($buffer, [
|
||||||
|
'LeistungsStufen' => json_encode([
|
||||||
|
['Stufe' => 1, 'Leistung' => 1800, 'Schaltkontakt_Stufe' => $buffer1],
|
||||||
|
['Stufe' => 2, 'Leistung' => 3600, 'Schaltkontakt_Stufe' => $buffer2],
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
|
'PufferfuehlerVariableID' => $bufferTemp,
|
||||||
|
'AussentemperaturVariableID' => $outsideTemp,
|
||||||
|
'FusspunktVorlauftemperatur' => 34.0,
|
||||||
|
'HeizkurvenSteigung' => 0.8,
|
||||||
|
'HeizkurveMinimaltemperatur' => 28.0,
|
||||||
|
'HeizkurveMaximaltemperatur' => 55.0,
|
||||||
|
'Hysterese' => 4.0,
|
||||||
|
'MindesttemperaturModus' => 0,
|
||||||
|
'LastwechselSperrzeit' => 2,
|
||||||
|
'TemperaturMaxAlter' => 30,
|
||||||
|
'PuffertemperaturGlaetten' => false,
|
||||||
|
'PrioritaetPV' => 3,
|
||||||
|
'PrioritaetPeak' => 4,
|
||||||
|
'Meldeintervall' => 5,
|
||||||
|
'VorgabeTimeout' => 30,
|
||||||
|
'EinstellungenInVisu' => true,
|
||||||
|
'DiagnosevariablenAnzeigen' => true,
|
||||||
|
]);
|
||||||
|
enelixDemoConfigure($battery, [
|
||||||
|
'Batterietyp' => 2,
|
||||||
|
'Batteriemanagement' => 2,
|
||||||
|
'MaxLadeleistungVariableID' => $batteryMaxCharge,
|
||||||
|
'MaxEntladeleistungVariableID' => $batteryMaxDischarge,
|
||||||
|
'LadezustandVariableID' => $batterySoc,
|
||||||
|
'NetzleistungVariableID' => $grid,
|
||||||
|
'IstleistungVariableID' => $batteryActual,
|
||||||
|
'ManagementRegisterVariableID' => $batteryManagement,
|
||||||
|
'ModusRegisterVariableID' => $batteryMode,
|
||||||
|
'LeistungsRegisterVariableID' => $batteryRegisterPower,
|
||||||
|
'ReserveLadezustand' => 20.0,
|
||||||
|
'MindestLadezustand' => 10.0,
|
||||||
|
'LadezustandHysterese' => 3.0,
|
||||||
|
'MesswertMaxAlter' => 30,
|
||||||
|
'Aenderungssperre' => 0,
|
||||||
|
'PrioritaetPV' => 4,
|
||||||
'PrioritaetPeak' => 0,
|
'PrioritaetPeak' => 0,
|
||||||
'Meldeintervall' => 5,
|
'Meldeintervall' => 5,
|
||||||
'VorgabeTimeout' => 30,
|
'VorgabeTimeout' => 30,
|
||||||
@@ -178,6 +260,8 @@ function enelixDemoInstall(): array
|
|||||||
enelixDemoEnergyNode($flowBoiler1, 'Boiler 1', 'consumer', $temp1),
|
enelixDemoEnergyNode($flowBoiler1, 'Boiler 1', 'consumer', $temp1),
|
||||||
enelixDemoEnergyNode($flowBoiler2, 'Boiler 2', 'consumer', $temp2),
|
enelixDemoEnergyNode($flowBoiler2, 'Boiler 2', 'consumer', $temp2),
|
||||||
enelixDemoEnergyNode($flowDryer, 'Entfeuchter', 'consumer'),
|
enelixDemoEnergyNode($flowDryer, 'Entfeuchter', 'consumer'),
|
||||||
|
enelixDemoEnergyNode($flowBuffer, 'Pufferspeicher', 'consumer', $bufferTemp),
|
||||||
|
enelixDemoEnergyNode($flowBattery, 'Batteriespeicher', 'battery', $batterySoc),
|
||||||
]);
|
]);
|
||||||
IPS_SetHidden($energyDistribution, false);
|
IPS_SetHidden($energyDistribution, false);
|
||||||
$oldEnergyDistribution = enelixDemoFind($dashboard, 'Energieverteilung');
|
$oldEnergyDistribution = enelixDemoFind($dashboard, 'Energieverteilung');
|
||||||
@@ -188,34 +272,105 @@ function enelixDemoInstall(): array
|
|||||||
foreach ([$boiler1, $boiler2] as $boilerId) {
|
foreach ([$boiler1, $boiler2] as $boilerId) {
|
||||||
IPS_RequestAction($boilerId, 'Mindesttemperatur', 42.0);
|
IPS_RequestAction($boilerId, 'Mindesttemperatur', 42.0);
|
||||||
IPS_RequestAction($boilerId, 'Maximaltemperatur', 62.0);
|
IPS_RequestAction($boilerId, 'Maximaltemperatur', 62.0);
|
||||||
IPS_RequestAction($boilerId, 'Aktiv', true);
|
|
||||||
}
|
}
|
||||||
IPS_RequestAction($dryer, 'Aktiv', true);
|
|
||||||
|
|
||||||
$manager = enelixDemoEnsureInstance($ems, 'Manager', 'Enelix Manager', ENELIX_DEMO_MANAGER_MODULE, 10);
|
$manager = enelixDemoEnsureInstance($ems, 'Manager', 'Enelix Manager', ENELIX_DEMO_MANAGER_MODULE, 10);
|
||||||
$assignment = array_map(
|
$assignment = array_map(
|
||||||
static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true],
|
static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true],
|
||||||
[$boiler1, $boiler2, $dryer]
|
[$boiler1, $boiler2, $dryer, $buffer, $battery]
|
||||||
);
|
);
|
||||||
enelixDemoConfigure($manager, [
|
$pvNennleistungKw = max(0.1, (float) GetValue($peak) / 1000.0);
|
||||||
|
$wechselrichterKonfiguration = [[
|
||||||
|
'ID' => 'demo-hybrid-1',
|
||||||
|
'Name' => 'Demo-Hybridwechselrichter',
|
||||||
|
'Typ' => 'hybrid',
|
||||||
|
'ACNennleistung_kW' => $pvNennleistungKw,
|
||||||
|
'IstleistungVariableID' => $inverterActual,
|
||||||
|
'Istleistungsfaktor' => 1.0,
|
||||||
|
'BegrenzungVariableID' => $inverterLimit,
|
||||||
|
'Begrenzungsart' => 'watt',
|
||||||
|
]];
|
||||||
|
$pvKonfiguration = [[
|
||||||
|
'ID' => 'demo-pv-1',
|
||||||
|
'Name' => 'Demo-PV-Anlage',
|
||||||
|
'DCLeistung_kWp' => $pvNennleistungKw,
|
||||||
|
'Neigung_Grad' => 30.0,
|
||||||
|
'Azimut_Grad' => 0.0,
|
||||||
|
'WechselrichterID' => 'demo-hybrid-1',
|
||||||
|
'MPPT' => 'A',
|
||||||
|
'Modulanzahl' => 0,
|
||||||
|
'Modulleistung_Wp' => 0.0,
|
||||||
|
]];
|
||||||
|
$batterieKonfiguration = [[
|
||||||
|
'ID' => 'demo-battery-1',
|
||||||
|
'Name' => 'Batteriespeicher 10 kWh',
|
||||||
|
'Nennkapazitaet_kWh' => 10.0,
|
||||||
|
'Nutzkapazitaet_kWh' => 10.0,
|
||||||
|
'MaxLadeleistung_kW' => max(0.1, (float) GetValue($batteryMaxCharge) / 1000.0),
|
||||||
|
'MaxEntladeleistung_kW' => max(
|
||||||
|
0.1,
|
||||||
|
(float) GetValue($batteryMaxDischarge) / 1000.0
|
||||||
|
),
|
||||||
|
'WechselrichterID' => 'demo-hybrid-1',
|
||||||
|
'Kopplung' => 'hybrid',
|
||||||
|
'LeistungVariableID' => $batteryActual,
|
||||||
|
'Leistungsfaktor' => 1.0,
|
||||||
|
'SOCVariableID' => $batterySoc,
|
||||||
|
]];
|
||||||
|
$managerProperties = [
|
||||||
'NetzleistungVariableID' => $grid,
|
'NetzleistungVariableID' => $grid,
|
||||||
'Netzleistungsfaktor' => 1.0,
|
'Netzleistungsfaktor' => 1.0,
|
||||||
|
'MesswerteAnzeigen' => true,
|
||||||
|
'EnergyPieAnzeigen' => true,
|
||||||
|
'EnergiediagrammeAnzeigen' => true,
|
||||||
|
'FunFactsAnzeigen' => true,
|
||||||
|
'EnergieflussAnzeigen' => true,
|
||||||
|
'PrognoseSendeintervall' => 60,
|
||||||
'MesswertMaxAlter' => 10,
|
'MesswertMaxAlter' => 10,
|
||||||
'AutomatischeSuche' => false,
|
'AutomatischeSuche' => false,
|
||||||
'SuchbereichID' => $root,
|
'SuchbereichID' => $root,
|
||||||
'VerbraucherZuordnung' => json_encode($assignment, JSON_THROW_ON_ERROR),
|
'VerbraucherZuordnung' => '[]',
|
||||||
'KeepAlive' => 5,
|
'KeepAlive' => 5,
|
||||||
'VerbraucherTimeout' => 15,
|
'VerbraucherTimeout' => 15,
|
||||||
'SollwertSolarladen' => 0.0,
|
'SollwertSolarladen' => 0.0,
|
||||||
'Umschaltdifferenz' => 0.0,
|
'Umschaltdifferenz' => 0.0,
|
||||||
|
'EinspeisebegrenzungAktiv' => true,
|
||||||
|
'Einspeisegrenze' => 3000.0,
|
||||||
|
'PrognoseAktiv' => false,
|
||||||
|
'NetzfahrplanAktiv' => false,
|
||||||
|
'AnlagenWechselrichter' => json_encode(
|
||||||
|
$wechselrichterKonfiguration,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
),
|
||||||
|
'AnlagenPVFlaechen' => json_encode($pvKonfiguration, JSON_THROW_ON_ERROR),
|
||||||
|
'AnlagenBatterien' => json_encode(
|
||||||
|
$batterieKonfiguration,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
),
|
||||||
'DiagnosevariablenAnzeigen' => true,
|
'DiagnosevariablenAnzeigen' => true,
|
||||||
'LoggingEin' => false,
|
'LoggingEin' => false,
|
||||||
]);
|
];
|
||||||
|
enelixDemoConfigure($manager, $managerProperties, [102, 203]);
|
||||||
|
$managerReady = (int) IPS_GetInstance($manager)['InstanceStatus'] === 102;
|
||||||
|
if ($managerReady) {
|
||||||
|
$managerProperties['VerbraucherZuordnung'] = json_encode($assignment, JSON_THROW_ON_ERROR);
|
||||||
|
enelixDemoConfigure($manager, $managerProperties, [102, 203]);
|
||||||
|
$managerReady = (int) IPS_GetInstance($manager)['InstanceStatus'] === 102;
|
||||||
|
}
|
||||||
|
if (!$managerReady && IPS_GetProperty($manager, 'VerbraucherZuordnung') !== '[]') {
|
||||||
|
$managerProperties['VerbraucherZuordnung'] = '[]';
|
||||||
|
enelixDemoConfigure($manager, $managerProperties, [203]);
|
||||||
|
}
|
||||||
|
|
||||||
foreach ([$boiler1, $boiler2, $dryer] as $consumerId) {
|
$consumerIds = [$boiler1, $boiler2, $dryer, $buffer, $battery];
|
||||||
|
foreach ($consumerIds as $consumerId) {
|
||||||
|
IPS_RequestAction($consumerId, 'Aktiv', $managerReady);
|
||||||
|
if ($managerReady) {
|
||||||
IPS_RequestAction($consumerId, 'Melden', true);
|
IPS_RequestAction($consumerId, 'Melden', true);
|
||||||
}
|
}
|
||||||
IPS_RequestAction($manager, 'Aktiv', true);
|
}
|
||||||
|
IPS_RequestAction($manager, 'Aktiv', $managerReady);
|
||||||
|
IPS_RequestAction($manager, 'MesswerteAufzeichnen', true);
|
||||||
|
|
||||||
$boiler1List = enelixDemoEnsureInstance(
|
$boiler1List = enelixDemoEnsureInstance(
|
||||||
$dashboard,
|
$dashboard,
|
||||||
@@ -231,14 +386,32 @@ function enelixDemoInstall(): array
|
|||||||
ENELIX_DEMO_DUMMY_MODULE,
|
ENELIX_DEMO_DUMMY_MODULE,
|
||||||
120
|
120
|
||||||
);
|
);
|
||||||
enelixDemoConfigure($boiler1List, []);
|
$bufferList = enelixDemoEnsureInstance(
|
||||||
enelixDemoConfigure($boiler2List, []);
|
$dashboard,
|
||||||
|
'PufferListe',
|
||||||
|
'Pufferspeicher',
|
||||||
|
ENELIX_DEMO_DUMMY_MODULE,
|
||||||
|
130
|
||||||
|
);
|
||||||
|
$batteryList = enelixDemoEnsureInstance(
|
||||||
|
$dashboard,
|
||||||
|
'BatterieListe',
|
||||||
|
'Batteriespeicher',
|
||||||
|
ENELIX_DEMO_DUMMY_MODULE,
|
||||||
|
140
|
||||||
|
);
|
||||||
|
foreach ([$boiler1List, $boiler2List, $bufferList, $batteryList] as $listId) {
|
||||||
|
enelixDemoConfigure($listId, []);
|
||||||
|
}
|
||||||
enelixDemoLinkInstanceVariables($boiler1List, $boiler1, $temp1);
|
enelixDemoLinkInstanceVariables($boiler1List, $boiler1, $temp1);
|
||||||
enelixDemoLinkInstanceVariables($boiler2List, $boiler2, $temp2);
|
enelixDemoLinkInstanceVariables($boiler2List, $boiler2, $temp2);
|
||||||
|
enelixDemoLinkInstanceVariables($bufferList, $buffer, $bufferTemp, 'Puffertemperatur', 'SensorPuffertemperatur');
|
||||||
|
enelixDemoLinkInstanceVariables($batteryList, $battery, $batterySoc, 'Ladezustand', 'SensorLadezustand');
|
||||||
IPS_SetHidden($boiler1View, true);
|
IPS_SetHidden($boiler1View, true);
|
||||||
IPS_SetHidden($boiler2View, true);
|
IPS_SetHidden($boiler2View, true);
|
||||||
IPS_SetHidden($boiler1, true);
|
foreach ([$boiler1, $boiler2, $buffer, $battery] as $hiddenInstanceId) {
|
||||||
IPS_SetHidden($boiler2, true);
|
IPS_SetHidden($hiddenInstanceId, true);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (['Boiler1Link', 'Boiler2Link'] as $oldLinkIdent) {
|
foreach (['Boiler1Link', 'Boiler2Link'] as $oldLinkIdent) {
|
||||||
$oldLink = enelixDemoFind($dashboard, $oldLinkIdent);
|
$oldLink = enelixDemoFind($dashboard, $oldLinkIdent);
|
||||||
@@ -248,22 +421,25 @@ function enelixDemoInstall(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
enelixDemoEnsureLink($dashboard, 'ManagerLink', 'Manager', $manager, 100);
|
enelixDemoEnsureLink($dashboard, 'ManagerLink', 'Manager', $manager, 100);
|
||||||
enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 130);
|
enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 150);
|
||||||
enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 70);
|
enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 100);
|
||||||
enelixDemoEnsureLink($simulation, 'Boiler2TemperaturLink', 'Boiler 2 Temperatur', $temp2, 80);
|
enelixDemoEnsureLink($simulation, 'Boiler2TemperaturLink', 'Boiler 2 Temperatur', $temp2, 110);
|
||||||
|
enelixDemoEnsureLink($simulation, 'PufferTemperaturLink', 'Puffertemperatur', $bufferTemp, 120);
|
||||||
|
enelixDemoEnsureLink($simulation, 'AussenTemperaturLink', 'Aussentemperatur', $outsideTemp, 130);
|
||||||
|
enelixDemoEnsureLink($simulation, 'BatterieLadezustandLink', 'Batterie Ladezustand', $batterySoc, 140);
|
||||||
|
|
||||||
enelixDemoConfigureArchive([
|
enelixDemoConfigureArchive([
|
||||||
$pvDisplay, $houseDisplay, $loadDisplay, $grid,
|
$pvDisplay, $houseDisplay, $loadDisplay, $grid,
|
||||||
$boiler1Power, $boiler2Power, $dryerPower,
|
$boiler1Power, $boiler2Power, $dryerPower, $bufferPower, $batteryPower,
|
||||||
$pvEnergy, $houseEnergy, $gridImportEnergy, $gridExportEnergy,
|
$pvEnergy, $houseEnergy, $gridImportEnergy, $gridExportEnergy,
|
||||||
$temp1, $temp2,
|
$temp1, $temp2, $bufferTemp, $outsideTemp, $batterySoc, $batteryActual,
|
||||||
$flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2, $flowDryer,
|
$flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2, $flowDryer,
|
||||||
$flowHouse, $flowLoads,
|
$flowBuffer, $flowBattery, $flowHouse, $flowLoads,
|
||||||
]);
|
]);
|
||||||
enelixDemoEnsureChart($history, 'LeistungsdiagrammNativ', 'Leistungsverlauf', [
|
enelixDemoEnsureChart($history, 'LeistungsdiagrammNativ', 'Leistungsverlauf', [
|
||||||
enelixDemoChartDataset($flowPv, 'PV', '#f4c542', '~Power'),
|
enelixDemoChartDataset($flowPv, 'PV', '#f4c542', '~Power'),
|
||||||
enelixDemoChartDataset($flowHouse, 'Hausverbrauch', '#2bb673', '~Power'),
|
enelixDemoChartDataset($flowHouse, 'Hausverbrauch', '#2bb673', '~Power'),
|
||||||
enelixDemoChartDataset($flowLoads, 'Geregelte Verbraucher', '#2786ac', '~Power'),
|
enelixDemoChartDataset($flowLoads, 'EMS-Leistung inkl. Speicher', '#2786ac', '~Power'),
|
||||||
enelixDemoChartDataset($flowGrid, 'Netzleistung', '#e95d5d', '~Power'),
|
enelixDemoChartDataset($flowGrid, 'Netzleistung', '#e95d5d', '~Power'),
|
||||||
], 10);
|
], 10);
|
||||||
enelixDemoEnsureChart($history, 'EnergiediagrammNativ', 'Kumulierte Energien', [
|
enelixDemoEnsureChart($history, 'EnergiediagrammNativ', 'Kumulierte Energien', [
|
||||||
@@ -279,7 +455,7 @@ function enelixDemoInstall(): array
|
|||||||
'rootId' => $root,
|
'rootId' => $root,
|
||||||
'visualizationId' => enelixDemoFirstInstance(ENELIX_DEMO_TILE_MODULE),
|
'visualizationId' => enelixDemoFirstInstance(ENELIX_DEMO_TILE_MODULE),
|
||||||
'managerId' => $manager,
|
'managerId' => $manager,
|
||||||
'consumerIds' => [$boiler1, $boiler2, $dryer],
|
'consumerIds' => $consumerIds,
|
||||||
'tickScriptId' => $tickScript,
|
'tickScriptId' => $tickScript,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -301,13 +477,24 @@ function enelixDemoTick(string $source = 'Manual'): void
|
|||||||
SetValue($hourId, $hour);
|
SetValue($hourId, $hour);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$dynamic = (bool) GetValue(enelixDemoRequiredId($simulation, 'DynamischeProfile'));
|
||||||
|
$environment = enelixDemoEnvironment(
|
||||||
|
$hour,
|
||||||
|
(float) GetValue(enelixDemoRequiredId($simulation, 'Grundlast')),
|
||||||
|
(int) GetValue(enelixDemoRequiredId($simulation, 'Bewoelkung')),
|
||||||
|
$dynamic
|
||||||
|
);
|
||||||
|
$base = $environment['base']
|
||||||
|
+ (float) GetValue(enelixDemoRequiredId($simulation, 'Zusatzlast'));
|
||||||
$peak = (float) GetValue(enelixDemoRequiredId($simulation, 'PVSpitze'));
|
$peak = (float) GetValue(enelixDemoRequiredId($simulation, 'PVSpitze'));
|
||||||
$cloud = (int) GetValue(enelixDemoRequiredId($simulation, 'Bewoelkung'));
|
|
||||||
$base = (float) GetValue(enelixDemoRequiredId($simulation, 'Grundlast'));
|
|
||||||
$sun = ($hour >= 6.0 && $hour <= 18.0)
|
$sun = ($hour >= 6.0 && $hour <= 18.0)
|
||||||
? max(0.0, sin(M_PI * ($hour - 6.0) / 12.0))
|
? max(0.0, sin(M_PI * ($hour - 6.0) / 12.0))
|
||||||
: 0.0;
|
: 0.0;
|
||||||
$pv = $peak * $sun * (1.0 - (min(100, max(0, $cloud)) * 0.0075));
|
$pvAvailable = $peak * $sun * (1.0 - ($environment['cloud'] * 0.0085));
|
||||||
|
$inverterActual = enelixDemoRequiredId($technical, 'WechselrichterIstleistung');
|
||||||
|
$inverterLimit = enelixDemoRequiredId($technical, 'WechselrichterLeistungsgrenze');
|
||||||
|
$pv = enelixDemoLimitPvPower($pvAvailable, (float) GetValue($inverterLimit));
|
||||||
|
SetValue($inverterActual, round($pv, 1));
|
||||||
|
|
||||||
$contacts = [
|
$contacts = [
|
||||||
'boiler11' => enelixDemoRequiredId($technical, 'Boiler1Stufe1'),
|
'boiler11' => enelixDemoRequiredId($technical, 'Boiler1Stufe1'),
|
||||||
@@ -315,15 +502,46 @@ function enelixDemoTick(string $source = 'Manual'): void
|
|||||||
'boiler21' => enelixDemoRequiredId($technical, 'Boiler2Stufe1'),
|
'boiler21' => enelixDemoRequiredId($technical, 'Boiler2Stufe1'),
|
||||||
'boiler22' => enelixDemoRequiredId($technical, 'Boiler2Stufe2'),
|
'boiler22' => enelixDemoRequiredId($technical, 'Boiler2Stufe2'),
|
||||||
'dryer' => enelixDemoRequiredId($technical, 'EntfeuchterKontakt'),
|
'dryer' => enelixDemoRequiredId($technical, 'EntfeuchterKontakt'),
|
||||||
|
'buffer1' => enelixDemoRequiredId($technical, 'PufferStufe1'),
|
||||||
|
'buffer2' => enelixDemoRequiredId($technical, 'PufferStufe2'),
|
||||||
|
'battery' => enelixDemoRequiredId($technical, 'BatterieIstleistung'),
|
||||||
];
|
];
|
||||||
$powerBefore = enelixDemoControlledPower($contacts);
|
|
||||||
$temp1 = enelixDemoRequiredId($technical, 'Boiler1Temperatur');
|
$temp1 = enelixDemoRequiredId($technical, 'Boiler1Temperatur');
|
||||||
$temp2 = enelixDemoRequiredId($technical, 'Boiler2Temperatur');
|
$temp2 = enelixDemoRequiredId($technical, 'Boiler2Temperatur');
|
||||||
|
$bufferTemp = enelixDemoRequiredId($technical, 'PufferTemperatur');
|
||||||
|
$outsideTemp = enelixDemoRequiredId($technical, 'AussenTemperatur');
|
||||||
|
$batterySoc = enelixDemoRequiredId($technical, 'BatterieLadezustand');
|
||||||
|
$batteryMaxCharge = enelixDemoRequiredId($technical, 'BatterieMaxLaden');
|
||||||
|
$batteryMaxDischarge = enelixDemoRequiredId($technical, 'BatterieMaxEntladen');
|
||||||
|
$previousBatteryPower = (float) GetValue($contacts['battery']);
|
||||||
|
|
||||||
|
SetValue($outsideTemp, round($environment['outside'], 2));
|
||||||
|
SetValue($batteryMaxCharge, GetValue($batteryMaxCharge));
|
||||||
|
SetValue($batteryMaxDischarge, GetValue($batteryMaxDischarge));
|
||||||
if ($source === 'Timer') {
|
if ($source === 'Timer') {
|
||||||
enelixDemoUpdateTemperature($temp1, (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0), $minutes);
|
enelixDemoUpdateTemperature(
|
||||||
enelixDemoUpdateTemperature($temp2, (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0), $minutes);
|
$temp1,
|
||||||
|
(bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0),
|
||||||
|
$minutes
|
||||||
|
);
|
||||||
|
enelixDemoUpdateTemperature(
|
||||||
|
$temp2,
|
||||||
|
(bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0),
|
||||||
|
$minutes
|
||||||
|
);
|
||||||
|
enelixDemoUpdateBufferTemperature(
|
||||||
|
$bufferTemp,
|
||||||
|
(bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0),
|
||||||
|
$environment['outside'],
|
||||||
|
$minutes
|
||||||
|
);
|
||||||
|
enelixDemoUpdateBatterySoc($batterySoc, $previousBatteryPower, $minutes);
|
||||||
|
} else {
|
||||||
|
SetValue($bufferTemp, GetValue($bufferTemp));
|
||||||
|
SetValue($batterySoc, GetValue($batterySoc));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$powerBefore = enelixDemoControlledPower($contacts);
|
||||||
$gridId = enelixDemoRequiredId($dashboard, 'Netzleistung');
|
$gridId = enelixDemoRequiredId($dashboard, 'Netzleistung');
|
||||||
SetValue($gridId, round($base + $powerBefore - $pv, 1));
|
SetValue($gridId, round($base + $powerBefore - $pv, 1));
|
||||||
|
|
||||||
@@ -331,38 +549,60 @@ function enelixDemoTick(string $source = 'Manual'): void
|
|||||||
enelixDemoRequiredId($ems, 'Boiler1'),
|
enelixDemoRequiredId($ems, 'Boiler1'),
|
||||||
enelixDemoRequiredId($ems, 'Boiler2'),
|
enelixDemoRequiredId($ems, 'Boiler2'),
|
||||||
enelixDemoRequiredId($ems, 'Entfeuchter'),
|
enelixDemoRequiredId($ems, 'Entfeuchter'),
|
||||||
|
enelixDemoRequiredId($ems, 'Pufferspeicher'),
|
||||||
|
enelixDemoRequiredId($ems, 'Batterie'),
|
||||||
];
|
];
|
||||||
|
$manager = enelixDemoRequiredId($ems, 'Manager');
|
||||||
|
$managerReady = (int) IPS_GetInstance($manager)['InstanceStatus'] === 102;
|
||||||
|
if ($managerReady) {
|
||||||
foreach ($consumers as $consumerId) {
|
foreach ($consumers as $consumerId) {
|
||||||
IPS_RequestAction($consumerId, 'Melden', true);
|
IPS_RequestAction($consumerId, 'Melden', true);
|
||||||
}
|
}
|
||||||
$manager = enelixDemoRequiredId($ems, 'Manager');
|
|
||||||
IPS_RequestAction($manager, 'Regeln', true);
|
IPS_RequestAction($manager, 'Regeln', true);
|
||||||
|
$pv = enelixDemoLimitPvPower(
|
||||||
|
$pvAvailable,
|
||||||
|
(float) GetValue($inverterLimit)
|
||||||
|
);
|
||||||
|
SetValue($inverterActual, round($pv, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
$battery = $managerReady
|
||||||
|
? enelixDemoBatteryRegisterPower(
|
||||||
|
(int) GetValue(enelixDemoRequiredId($technical, 'BatterieManagement')),
|
||||||
|
(int) GetValue(enelixDemoRequiredId($technical, 'BatterieModus')),
|
||||||
|
(float) GetValue(enelixDemoRequiredId($technical, 'BatterieRegisterLeistung'))
|
||||||
|
)
|
||||||
|
: 0.0;
|
||||||
|
SetValue($contacts['battery'], $battery);
|
||||||
|
|
||||||
$boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0);
|
$boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0);
|
||||||
$boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0);
|
$boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0);
|
||||||
$dryer = (bool) GetValue($contacts['dryer']) ? 900 : 0;
|
$dryer = (bool) GetValue($contacts['dryer']) ? 900 : 0;
|
||||||
$power = $boiler1 + $boiler2 + $dryer;
|
$buffer = (bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0);
|
||||||
$house = $base + $power;
|
$loads = $boiler1 + $boiler2 + $dryer + $buffer;
|
||||||
$grid = $house - $pv;
|
$emsPower = $loads + $battery;
|
||||||
|
$house = $base + $loads;
|
||||||
|
$grid = $house + $battery - $pv;
|
||||||
|
|
||||||
$pvId = enelixDemoRequiredId($dashboard, 'PVLeistung');
|
|
||||||
$houseId = enelixDemoRequiredId($dashboard, 'Hausverbrauch');
|
|
||||||
$loadId = enelixDemoRequiredId($dashboard, 'Verbraucherleistung');
|
|
||||||
SetValue($gridId, round($grid, 1));
|
SetValue($gridId, round($grid, 1));
|
||||||
SetValue($pvId, round($pv, 1));
|
SetValue(enelixDemoRequiredId($dashboard, 'PVLeistung'), round($pv, 1));
|
||||||
SetValue($houseId, round($house, 1));
|
SetValue(enelixDemoRequiredId($dashboard, 'Hausverbrauch'), round($house, 1));
|
||||||
SetValue($loadId, (float) $power);
|
SetValue(enelixDemoRequiredId($dashboard, 'Verbraucherleistung'), (float) $emsPower);
|
||||||
SetValue(enelixDemoRequiredId($dashboard, 'Boiler1Leistung'), (float) $boiler1);
|
SetValue(enelixDemoRequiredId($dashboard, 'Boiler1Leistung'), (float) $boiler1);
|
||||||
SetValue(enelixDemoRequiredId($dashboard, 'Boiler2Leistung'), (float) $boiler2);
|
SetValue(enelixDemoRequiredId($dashboard, 'Boiler2Leistung'), (float) $boiler2);
|
||||||
SetValue(enelixDemoRequiredId($dashboard, 'EntfeuchterLeistung'), (float) $dryer);
|
SetValue(enelixDemoRequiredId($dashboard, 'EntfeuchterLeistung'), (float) $dryer);
|
||||||
|
SetValue(enelixDemoRequiredId($dashboard, 'PufferLeistung'), (float) $buffer);
|
||||||
|
SetValue(enelixDemoRequiredId($dashboard, 'BatterieLeistung'), $battery);
|
||||||
SetValue(enelixDemoRequiredId($technical, 'EnergieflussPVkW'), round($pv / 1000.0, 3));
|
SetValue(enelixDemoRequiredId($technical, 'EnergieflussPVkW'), round($pv / 1000.0, 3));
|
||||||
SetValue(enelixDemoRequiredId($technical, 'EnergieflussNetzkW'), round($grid / 1000.0, 3));
|
SetValue(enelixDemoRequiredId($technical, 'EnergieflussNetzkW'), round($grid / 1000.0, 3));
|
||||||
SetValue(enelixDemoRequiredId($technical, 'EnergieflussGrundlastkW'), round($base / 1000.0, 3));
|
SetValue(enelixDemoRequiredId($technical, 'EnergieflussGrundlastkW'), round($base / 1000.0, 3));
|
||||||
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler1kW'), round($boiler1 / 1000.0, 3));
|
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler1kW'), round($boiler1 / 1000.0, 3));
|
||||||
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler2kW'), round($boiler2 / 1000.0, 3));
|
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler2kW'), round($boiler2 / 1000.0, 3));
|
||||||
SetValue(enelixDemoRequiredId($technical, 'EnergieflussEntfeuchterkW'), round($dryer / 1000.0, 3));
|
SetValue(enelixDemoRequiredId($technical, 'EnergieflussEntfeuchterkW'), round($dryer / 1000.0, 3));
|
||||||
|
SetValue(enelixDemoRequiredId($technical, 'EnergieflussPufferkW'), round($buffer / 1000.0, 3));
|
||||||
|
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBatteriekW'), round($battery / 1000.0, 3));
|
||||||
SetValue(enelixDemoRequiredId($technical, 'DiagrammHausverbrauchkW'), round($house / 1000.0, 3));
|
SetValue(enelixDemoRequiredId($technical, 'DiagrammHausverbrauchkW'), round($house / 1000.0, 3));
|
||||||
SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($power / 1000.0, 3));
|
SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($emsPower / 1000.0, 3));
|
||||||
|
|
||||||
$history = enelixDemoRequiredId($root, 'Historie');
|
$history = enelixDemoRequiredId($root, 'Historie');
|
||||||
$energyIds = [
|
$energyIds = [
|
||||||
@@ -378,7 +618,11 @@ function enelixDemoTick(string $source = 'Manual'): void
|
|||||||
enelixDemoAddEnergy($energyIds['import'], max(0.0, $grid), $hours);
|
enelixDemoAddEnergy($energyIds['import'], max(0.0, $grid), $hours);
|
||||||
enelixDemoAddEnergy($energyIds['export'], max(0.0, -$grid), $hours);
|
enelixDemoAddEnergy($energyIds['export'], max(0.0, -$grid), $hours);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enelixDemoLimitPvPower(float $availablePower, float $limitPower): float
|
||||||
|
{
|
||||||
|
return min(max(0.0, $availablePower), max(0.0, $limitPower));
|
||||||
}
|
}
|
||||||
|
|
||||||
function enelixDemoRenderFlow(array $state): string
|
function enelixDemoRenderFlow(array $state): string
|
||||||
@@ -390,9 +634,15 @@ function enelixDemoRenderFlow(array $state): string
|
|||||||
$gridLabel = $grid < -25 ? 'Einspeisung' : ($grid > 25 ? 'Netzbezug' : 'Ausgeglichen');
|
$gridLabel = $grid < -25 ? 'Einspeisung' : ($grid > 25 ? 'Netzbezug' : 'Ausgeglichen');
|
||||||
$gridArrow = $grid < -25 ? '→' : ($grid > 25 ? '←' : '↔');
|
$gridArrow = $grid < -25 ? '→' : ($grid > 25 ? '←' : '↔');
|
||||||
$gridClass = abs($grid) <= 250 ? 'good' : ($grid < 0 ? 'export' : 'import');
|
$gridClass = abs($grid) <= 250 ? 'good' : ($grid < 0 ? 'export' : 'import');
|
||||||
|
$buffer = (float) ($state['buffer'] ?? 0.0);
|
||||||
|
$battery = (float) ($state['battery'] ?? 0.0);
|
||||||
|
$bufferTemp = (float) ($state['bufferTemp'] ?? 36.0);
|
||||||
|
$batterySoc = (float) ($state['batterySoc'] ?? 50.0);
|
||||||
$b1Class = (float) $state['boiler1'] > 0 ? 'on' : '';
|
$b1Class = (float) $state['boiler1'] > 0 ? 'on' : '';
|
||||||
$b2Class = (float) $state['boiler2'] > 0 ? 'on' : '';
|
$b2Class = (float) $state['boiler2'] > 0 ? 'on' : '';
|
||||||
$dryClass = (float) $state['dryer'] > 0 ? 'on' : '';
|
$dryClass = (float) $state['dryer'] > 0 ? 'on' : '';
|
||||||
|
$bufferClass = $buffer > 0 ? 'on' : '';
|
||||||
|
$batteryClass = abs($battery) > 25 ? 'on' : '';
|
||||||
|
|
||||||
return '<style>
|
return '<style>
|
||||||
.enelix-flow{box-sizing:border-box;font-family:Inter,Segoe UI,Arial,sans-serif;background:#10151c;color:#f4f7fa;padding:18px;min-height:420px}
|
.enelix-flow{box-sizing:border-box;font-family:Inter,Segoe UI,Arial,sans-serif;background:#10151c;color:#f4f7fa;padding:18px;min-height:420px}
|
||||||
@@ -403,14 +653,14 @@ function enelixDemoRenderFlow(array $state): string
|
|||||||
.enelix-node small{display:block;color:#9dacbd;margin-bottom:8px}.enelix-node b{font-size:22px}
|
.enelix-node small{display:block;color:#9dacbd;margin-bottom:8px}.enelix-node b{font-size:22px}
|
||||||
.enelix-node.solar{border-color:#b99628}.enelix-node.bus{border-color:#2786ac}.enelix-node.good{border-color:#2bb673}
|
.enelix-node.solar{border-color:#b99628}.enelix-node.bus{border-color:#2786ac}.enelix-node.good{border-color:#2bb673}
|
||||||
.enelix-node.export{border-color:#38a3db}.enelix-node.import{border-color:#e95d5d}.enelix-arrow{text-align:center;font-size:30px;color:#9dacbd}
|
.enelix-node.export{border-color:#38a3db}.enelix-node.import{border-color:#e95d5d}.enelix-arrow{text-align:center;font-size:30px;color:#9dacbd}
|
||||||
.enelix-loads{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:18px}
|
.enelix-loads{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin-top:18px}
|
||||||
.enelix-load{border-left:4px solid #566371;background:#17202a;padding:13px 14px;border-radius:6px}
|
.enelix-load{border-left:4px solid #566371;background:#17202a;padding:13px 14px;border-radius:6px}
|
||||||
.enelix-load.on{border-left-color:#2bb673}.enelix-load b{display:block;font-size:17px;margin:4px 0}.enelix-load span{color:#9dacbd;font-size:12px}
|
.enelix-load.on{border-left-color:#2bb673}.enelix-load b{display:block;font-size:17px;margin:4px 0}.enelix-load span{color:#9dacbd;font-size:12px}
|
||||||
.enelix-foot{display:flex;justify-content:space-between;gap:12px;margin-top:16px;padding-top:12px;border-top:1px solid #2a3541;color:#9dacbd;font-size:12px}
|
.enelix-foot{display:flex;justify-content:space-between;gap:12px;margin-top:16px;padding-top:12px;border-top:1px solid #2a3541;color:#9dacbd;font-size:12px}
|
||||||
@media(max-width:640px){.enelix-line{grid-template-columns:1fr}.enelix-arrow{transform:rotate(90deg)}.enelix-loads{grid-template-columns:1fr}.enelix-head{align-items:start;flex-direction:column;gap:4px}}
|
@media(max-width:640px){.enelix-line{grid-template-columns:1fr}.enelix-arrow{transform:rotate(90deg)}.enelix-loads{grid-template-columns:1fr}.enelix-head{align-items:start;flex-direction:column;gap:4px}}
|
||||||
</style><div class="enelix-flow"><div class="enelix-head"><div><div class="enelix-title">Enelix 2 Energiefluss</div><div class="enelix-sub">Manager regelt drei flexible Verbraucher</div></div><div class="enelix-sub">Simulation ' . $time . ' Uhr</div></div>
|
</style><div class="enelix-flow"><div class="enelix-head"><div><div class="enelix-title">Enelix 2 Energiefluss</div><div class="enelix-sub">Manager regelt fünf flexible Teilnehmer aus vier Modultypen</div></div><div class="enelix-sub">Simulation ' . $time . ' Uhr</div></div>
|
||||||
<div class="enelix-line"><div class="enelix-node solar"><small>PV-Erzeugung</small><b>' . $format((float) $state['pv']) . ' W</b></div><div class="enelix-arrow">→</div><div class="enelix-node bus"><small>Haus + flexible Lasten</small><b>' . $format((float) $state['base'] + (float) $state['boiler1'] + (float) $state['boiler2'] + (float) $state['dryer']) . ' W</b></div><div class="enelix-arrow">' . $gridArrow . '</div><div class="enelix-node ' . $gridClass . '"><small>' . $gridLabel . '</small><b>' . $format(abs($grid)) . ' W</b></div></div>
|
<div class="enelix-line"><div class="enelix-node solar"><small>PV-Erzeugung</small><b>' . $format((float) $state['pv']) . ' W</b></div><div class="enelix-arrow">→</div><div class="enelix-node bus"><small>Haus + EMS-Leistung</small><b>' . $format((float) $state['base'] + (float) $state['boiler1'] + (float) $state['boiler2'] + (float) $state['dryer'] + $buffer + $battery) . ' W</b></div><div class="enelix-arrow">' . $gridArrow . '</div><div class="enelix-node ' . $gridClass . '"><small>' . $gridLabel . '</small><b>' . $format(abs($grid)) . ' W</b></div></div>
|
||||||
<div class="enelix-loads"><div class="enelix-load ' . $b1Class . '"><span>Priorität 1</span><b>Boiler 1 · ' . $format((float) $state['boiler1']) . ' W</b><span>' . number_format((float) $state['temp1'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $b2Class . '"><span>Priorität 2</span><b>Boiler 2 · ' . $format((float) $state['boiler2']) . ' W</b><span>' . number_format((float) $state['temp2'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $dryClass . '"><span>Priorität 3</span><b>Entfeuchter · ' . $format((float) $state['dryer']) . ' W</b><span>einstufiger Verbraucher</span></div></div>
|
<div class="enelix-loads"><div class="enelix-load ' . $b1Class . '"><span>Priorität 1</span><b>Boiler 1 · ' . $format((float) $state['boiler1']) . ' W</b><span>' . number_format((float) $state['temp1'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $b2Class . '"><span>Priorität 2</span><b>Boiler 2 · ' . $format((float) $state['boiler2']) . ' W</b><span>' . number_format((float) $state['temp2'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $dryClass . '"><span>Priorität 3</span><b>Entfeuchter · ' . $format((float) $state['dryer']) . ' W</b><span>einstufiger Verbraucher</span></div><div class="enelix-load ' . $bufferClass . '"><span>Priorität 4</span><b>Pufferspeicher · ' . $format($buffer) . ' W</b><span>' . number_format($bufferTemp, 1, ',', '') . ' °C · Heizkurve</span></div><div class="enelix-load ' . $batteryClass . '"><span>Priorität 5 / Peak 1</span><b>Batterie · ' . $format($battery) . ' W</b><span>' . number_format($batterySoc, 1, ',', '') . ' % · bidirektional</span></div></div>
|
||||||
<div class="enelix-foot"><span>Grundlast ' . $format((float) $state['base']) . ' W</span><span>Manager: ' . htmlspecialchars((string) $state['manager'], ENT_QUOTES, 'UTF-8') . '</span></div></div>';
|
<div class="enelix-foot"><span>Grundlast ' . $format((float) $state['base']) . ' W</span><span>Manager: ' . htmlspecialchars((string) $state['manager'], ENT_QUOTES, 'UTF-8') . '</span></div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,7 +668,65 @@ function enelixDemoControlledPower(array $contacts): int
|
|||||||
{
|
{
|
||||||
$boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0);
|
$boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0);
|
||||||
$boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0);
|
$boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0);
|
||||||
return $boiler1 + $boiler2 + ((bool) GetValue($contacts['dryer']) ? 900 : 0);
|
$buffer = (bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0);
|
||||||
|
$battery = (int) round((float) GetValue($contacts['battery']));
|
||||||
|
|
||||||
|
return $boiler1 + $boiler2 + ((bool) GetValue($contacts['dryer']) ? 900 : 0) + $buffer + $battery;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{base: float, cloud: float, outside: float} */
|
||||||
|
function enelixDemoEnvironment(float $hour, float $base, int $cloud, bool $dynamic): array
|
||||||
|
{
|
||||||
|
$hour = fmod(max(0.0, $hour), 24.0);
|
||||||
|
$cloud = min(100, max(0, $cloud));
|
||||||
|
if (!$dynamic) {
|
||||||
|
return ['base' => $base, 'cloud' => (float) $cloud, 'outside' => 6.0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$morning = exp(-pow(($hour - 7.5) / 1.5, 2));
|
||||||
|
$evening = exp(-pow(($hour - 19.0) / 2.2, 2));
|
||||||
|
$night = ($hour < 5.0 || $hour >= 23.0) ? 0.65 : 0.82;
|
||||||
|
$baseFactor = $night + (0.48 * $morning) + (0.72 * $evening);
|
||||||
|
$cloudVariation = (18.0 * sin(($hour + 0.4) * 1.7)) + (8.0 * sin($hour * 4.6));
|
||||||
|
$outside = 7.0 + (6.0 * sin(2.0 * M_PI * ($hour - 9.0) / 24.0));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'base' => max(150.0, $base * $baseFactor),
|
||||||
|
'cloud' => min(100.0, max(0.0, $cloud + $cloudVariation)),
|
||||||
|
'outside' => $outside,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function enelixDemoBatteryRegisterPower(int $management, int $mode, float $power): float
|
||||||
|
{
|
||||||
|
if ($management !== 2) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if ($mode === 11) {
|
||||||
|
return max(0.0, $power);
|
||||||
|
}
|
||||||
|
if ($mode === 12) {
|
||||||
|
return -max(0.0, $power);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enelixDemoUpdateBufferTemperature(int $variableId, int $power, float $outside, int $minutes): void
|
||||||
|
{
|
||||||
|
$current = (float) GetValue($variableId);
|
||||||
|
$heating = ($power / 1000.0) * 0.018 * $minutes;
|
||||||
|
$loss = max(0.0, $current - $outside) * 0.0007 * $minutes;
|
||||||
|
SetValue($variableId, round(min(65.0, max(22.0, $current + $heating - $loss)), 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function enelixDemoUpdateBatterySoc(int $variableId, float $power, int $minutes): void
|
||||||
|
{
|
||||||
|
$current = (float) GetValue($variableId);
|
||||||
|
$energy = ($power / 1000.0) * ($minutes / 60.0);
|
||||||
|
$storedEnergy = $energy >= 0.0 ? $energy * 0.94 : $energy / 0.94;
|
||||||
|
$delta = ($storedEnergy / 10.0) * 100.0;
|
||||||
|
SetValue($variableId, round(min(100.0, max(5.0, $current + $delta)), 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function enelixDemoUpdateTemperature(int $variableId, int $power, int $minutes): void
|
function enelixDemoUpdateTemperature(int $variableId, int $power, int $minutes): void
|
||||||
@@ -439,6 +747,8 @@ function enelixDemoValidateRuntime(): void
|
|||||||
ENELIX_DEMO_MANAGER_MODULE,
|
ENELIX_DEMO_MANAGER_MODULE,
|
||||||
ENELIX_DEMO_BOILER_MODULE,
|
ENELIX_DEMO_BOILER_MODULE,
|
||||||
ENELIX_DEMO_SINGLE_MODULE,
|
ENELIX_DEMO_SINGLE_MODULE,
|
||||||
|
ENELIX_DEMO_BUFFER_MODULE,
|
||||||
|
ENELIX_DEMO_BATTERY_MODULE,
|
||||||
ENELIX_DEMO_DUMMY_MODULE,
|
ENELIX_DEMO_DUMMY_MODULE,
|
||||||
ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE,
|
ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE,
|
||||||
] as $moduleId) {
|
] as $moduleId) {
|
||||||
@@ -450,10 +760,11 @@ function enelixDemoValidateRuntime(): void
|
|||||||
|
|
||||||
function enelixDemoEnsureProfiles(): void
|
function enelixDemoEnsureProfiles(): void
|
||||||
{
|
{
|
||||||
enelixDemoEnsureProfile('ENELIX.Demo.Power', 2, -12000, 12000, 100, 0, ' W');
|
enelixDemoEnsureProfile('ENELIX.Demo.Power', 2, -20000, 20000, 100, 0, ' W');
|
||||||
enelixDemoEnsureProfile('ENELIX.Demo.PowerPositive', 2, 0, 12000, 100, 0, ' W');
|
enelixDemoEnsureProfile('ENELIX.Demo.PowerPositive', 2, 0, 20000, 100, 0, ' W');
|
||||||
enelixDemoEnsureProfile('ENELIX.Demo.Hour', 2, 0, 23.75, 0.25, 2, ' h');
|
enelixDemoEnsureProfile('ENELIX.Demo.Hour', 2, 0, 23.75, 0.25, 2, ' h');
|
||||||
enelixDemoEnsureProfile('ENELIX.Demo.Percent', 1, 0, 100, 5, 0, ' %');
|
enelixDemoEnsureProfile('ENELIX.Demo.Percent', 1, 0, 100, 5, 0, ' %');
|
||||||
|
enelixDemoEnsureProfile('ENELIX.Demo.PercentFloat', 2, 0, 100, 0.1, 1, ' %');
|
||||||
enelixDemoEnsureProfile('ENELIX.Demo.Speed', 1, 1, 60, 1, 0, ' min');
|
enelixDemoEnsureProfile('ENELIX.Demo.Speed', 1, 1, 60, 1, 0, ' min');
|
||||||
enelixDemoEnsureProfile('ENELIX.Demo.Energy', 2, 0, 1000000, 0.01, 2, ' kWh');
|
enelixDemoEnsureProfile('ENELIX.Demo.Energy', 2, 0, 1000000, 0.01, 2, ' kWh');
|
||||||
}
|
}
|
||||||
@@ -628,15 +939,34 @@ function enelixDemoEnsureCyclicEvent(int $scriptId, string $ident, int $seconds)
|
|||||||
return $id;
|
return $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function enelixDemoConfigure(int $instanceId, array $properties): void
|
function enelixDemoConfigure(
|
||||||
|
int $instanceId,
|
||||||
|
array $properties,
|
||||||
|
array $allowedStatuses = [102]
|
||||||
|
): void
|
||||||
{
|
{
|
||||||
foreach ($properties as $name => $value) {
|
foreach ($properties as $name => $value) {
|
||||||
IPS_SetProperty($instanceId, (string) $name, $value);
|
IPS_SetProperty($instanceId, (string) $name, $value);
|
||||||
}
|
}
|
||||||
IPS_ApplyChanges($instanceId);
|
IPS_ApplyChanges($instanceId);
|
||||||
if ((int) IPS_GetInstance($instanceId)['InstanceStatus'] !== 102) {
|
$deadline = microtime(true) + 2.0;
|
||||||
throw new RuntimeException('Instanz ist nach der Konfiguration nicht aktiv: ' . IPS_GetName($instanceId));
|
do {
|
||||||
|
$status = (int) IPS_GetInstance($instanceId)['InstanceStatus'];
|
||||||
|
if ($status === 102) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
IPS_Sleep(50);
|
||||||
|
} while (microtime(true) < $deadline);
|
||||||
|
|
||||||
|
if (in_array($status, $allowedStatuses, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Instanz ist nach der Konfiguration nicht aktiv: '
|
||||||
|
. IPS_GetName($instanceId)
|
||||||
|
. ' (Status ' . $status . ', erwartet: ' . implode(', ', $allowedStatuses) . ')'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function enelixDemoConfigureArchive(array $variableIds): void
|
function enelixDemoConfigureArchive(array $variableIds): void
|
||||||
@@ -713,7 +1043,7 @@ function enelixDemoRemove(bool $confirmed = false): void
|
|||||||
IPS_ApplyChanges($visualization);
|
IPS_ApplyChanges($visualization);
|
||||||
}
|
}
|
||||||
enelixDemoDeleteTree($root);
|
enelixDemoDeleteTree($root);
|
||||||
foreach (['ENELIX.Demo.Power', 'ENELIX.Demo.PowerPositive', 'ENELIX.Demo.Hour', 'ENELIX.Demo.Percent', 'ENELIX.Demo.Speed', 'ENELIX.Demo.Energy'] as $profile) {
|
foreach (['ENELIX.Demo.Power', 'ENELIX.Demo.PowerPositive', 'ENELIX.Demo.Hour', 'ENELIX.Demo.Percent', 'ENELIX.Demo.PercentFloat', 'ENELIX.Demo.Speed', 'ENELIX.Demo.Energy'] as $profile) {
|
||||||
if (IPS_VariableProfileExists($profile)) {
|
if (IPS_VariableProfileExists($profile)) {
|
||||||
IPS_DeleteVariableProfile($profile);
|
IPS_DeleteVariableProfile($profile);
|
||||||
}
|
}
|
||||||
@@ -771,9 +1101,14 @@ function enelixDemoConfigureEnergyDistribution(int $instanceId, array $nodes): v
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function enelixDemoLinkInstanceVariables(int $categoryId, int $instanceId, int $sensorId): void
|
function enelixDemoLinkInstanceVariables(
|
||||||
{
|
int $categoryId,
|
||||||
enelixDemoEnsureLink($categoryId, 'SensorTemperatur', 'Gemessene Temperatur', $sensorId, 5);
|
int $instanceId,
|
||||||
|
int $sensorId,
|
||||||
|
string $sensorName = 'Gemessene Temperatur',
|
||||||
|
string $sensorIdent = 'SensorTemperatur'
|
||||||
|
): void {
|
||||||
|
enelixDemoEnsureLink($categoryId, $sensorIdent, $sensorName, $sensorId, 5);
|
||||||
$position = 10;
|
$position = 10;
|
||||||
foreach (IPS_GetChildrenIDs($instanceId) as $childId) {
|
foreach (IPS_GetChildrenIDs($instanceId) as $childId) {
|
||||||
if ((int) IPS_GetObject($childId)['ObjectType'] !== 2) {
|
if ((int) IPS_GetObject($childId)['ObjectType'] !== 2) {
|
||||||
|
|||||||
+2
-2
@@ -7,6 +7,6 @@
|
|||||||
"version": "8.0"
|
"version": "8.0"
|
||||||
},
|
},
|
||||||
"version": "0.1",
|
"version": "0.1",
|
||||||
"build": 0,
|
"build": 1,
|
||||||
"date": 0
|
"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,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixEMS;
|
||||||
|
|
||||||
|
final class EaseeGatewayProtokoll
|
||||||
|
{
|
||||||
|
/** @var list<int> */
|
||||||
|
public const BEOBACHTUNGEN = [
|
||||||
|
47, 48, 100, 104, 109, 110, 119, 120, 182, 183, 184, 185, 250,
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function seriennummer(string $wert): string
|
||||||
|
{
|
||||||
|
return strtoupper(trim($wert));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $knoten
|
||||||
|
* @param list<string> $abonnements
|
||||||
|
* @return list<array{Seriennummer: string, ID: int, Wert: mixed}>
|
||||||
|
*/
|
||||||
|
public static function extrahiereBeobachtungen(
|
||||||
|
$knoten,
|
||||||
|
array $abonnements,
|
||||||
|
string $seriennummer = '',
|
||||||
|
int $tiefe = 0
|
||||||
|
): array {
|
||||||
|
if (!is_array($knoten) || $tiefe > 12) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$abonnements = array_values(array_unique(array_map(
|
||||||
|
[self::class, 'seriennummer'],
|
||||||
|
$abonnements
|
||||||
|
)));
|
||||||
|
foreach (['serialNumber', 'SerialNumber', 'chargerId', 'ChargerId', 'mid', 'Mid'] as $feld) {
|
||||||
|
if (!isset($knoten[$feld]) || !is_scalar($knoten[$feld])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$kandidat = self::seriennummer((string) $knoten[$feld]);
|
||||||
|
if (in_array($kandidat, $abonnements, true)) {
|
||||||
|
$seriennummer = $kandidat;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::istListe($knoten)) {
|
||||||
|
foreach ($knoten as $wert) {
|
||||||
|
if (!is_string($wert)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$kandidat = self::seriennummer($wert);
|
||||||
|
if (in_array($kandidat, $abonnements, true)) {
|
||||||
|
$seriennummer = $kandidat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = null;
|
||||||
|
foreach (['id', 'Id', 'observationId', 'ObservationId'] as $feld) {
|
||||||
|
if (array_key_exists($feld, $knoten) && is_numeric($knoten[$feld])) {
|
||||||
|
$id = (int) $knoten[$feld];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$wertVorhanden = false;
|
||||||
|
$wert = null;
|
||||||
|
foreach (['value', 'Value'] as $feld) {
|
||||||
|
if (array_key_exists($feld, $knoten)) {
|
||||||
|
$wertVorhanden = true;
|
||||||
|
$wert = $knoten[$feld];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ergebnis = [];
|
||||||
|
if (
|
||||||
|
$seriennummer !== ''
|
||||||
|
&& $wertVorhanden
|
||||||
|
&& in_array($id, self::BEOBACHTUNGEN, true)
|
||||||
|
) {
|
||||||
|
$ergebnis[] = [
|
||||||
|
'Seriennummer' => $seriennummer,
|
||||||
|
'ID' => $id,
|
||||||
|
'Wert' => $wert,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($knoten as $kind) {
|
||||||
|
if (is_array($kind)) {
|
||||||
|
$ergebnis = array_merge(
|
||||||
|
$ergebnis,
|
||||||
|
self::extrahiereBeobachtungen(
|
||||||
|
$kind,
|
||||||
|
$abonnements,
|
||||||
|
$seriennummer,
|
||||||
|
$tiefe + 1
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ergebnis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<mixed> $wert */
|
||||||
|
private static function istListe(array $wert): bool
|
||||||
|
{
|
||||||
|
$index = 0;
|
||||||
|
foreach ($wert as $schluessel => $_) {
|
||||||
|
if ($schluessel !== $index++) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixEMS;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class EaseeLadestatus
|
||||||
|
{
|
||||||
|
public const BETRIEBSMODUS_EASEE = 0;
|
||||||
|
public const BETRIEBSMODUS_NUR_SOLAR = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $beobachtungen
|
||||||
|
* @return array{
|
||||||
|
* StatusGueltig: bool,
|
||||||
|
* FahrzeugVerbunden: bool,
|
||||||
|
* FahrzeugGeladen: bool,
|
||||||
|
* Fahrzeugstatus: int,
|
||||||
|
* Phasenzahl: int,
|
||||||
|
* Istleistung_W: float,
|
||||||
|
* Ladestrom_A: float,
|
||||||
|
* ApiMaximalstrom_A: int,
|
||||||
|
* Stoerung: bool,
|
||||||
|
* Stoertext: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public static function ausBeobachtungen(array $beobachtungen, int $konfigurierterMaximalstrom): array
|
||||||
|
{
|
||||||
|
if ($konfigurierterMaximalstrom < 6 || $konfigurierterMaximalstrom > 32) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'MaximalerLadestrom muss zwischen 6 und 32 A liegen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rohBetriebszustand = $beobachtungen['109'] ?? null;
|
||||||
|
$statusGueltig = is_numeric($rohBetriebszustand)
|
||||||
|
&& floor((float) $rohBetriebszustand) === (float) $rohBetriebszustand
|
||||||
|
&& in_array((int) $rohBetriebszustand, [0, 1, 2, 3, 4, 5, 6, 7, 8], true);
|
||||||
|
$betriebszustand = $statusGueltig ? (int) $rohBetriebszustand : 0;
|
||||||
|
$verbunden = $statusGueltig
|
||||||
|
&& in_array($betriebszustand, [2, 3, 4, 5, 6, 7, 8], true);
|
||||||
|
$geladen = $betriebszustand === 4;
|
||||||
|
$fahrzeugstatus = self::fahrzeugstatus($betriebszustand);
|
||||||
|
|
||||||
|
$phasenzahl = $verbunden
|
||||||
|
? self::phasenzahl((int) ($beobachtungen['110'] ?? 0))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
$leistung = max(0.0, (float) ($beobachtungen['120'] ?? 0.0) * 1000.0);
|
||||||
|
$stroeme = [];
|
||||||
|
foreach ([182, 183, 184, 185] as $id) {
|
||||||
|
if (array_key_exists((string) $id, $beobachtungen)) {
|
||||||
|
$stroeme[] = abs((float) $beobachtungen[(string) $id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ladestrom = $stroeme === [] ? 0.0 : max($stroeme);
|
||||||
|
|
||||||
|
$grenzen = [$konfigurierterMaximalstrom];
|
||||||
|
foreach ([47, 104] as $id) {
|
||||||
|
$grenze = (float) ($beobachtungen[(string) $id] ?? 0.0);
|
||||||
|
if ($grenze >= 6.0) {
|
||||||
|
$grenzen[] = (int) floor($grenze);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$apiMaximalstrom = max(6, min($grenzen));
|
||||||
|
|
||||||
|
$fehlercode = (int) ($beobachtungen['119'] ?? 0);
|
||||||
|
$stoerung = $betriebszustand === 5 || $fehlercode !== 0;
|
||||||
|
$stoertext = '';
|
||||||
|
if ($betriebszustand === 5) {
|
||||||
|
$stoertext = 'Easee meldet einen Fehlerzustand.';
|
||||||
|
}
|
||||||
|
if ($fehlercode !== 0) {
|
||||||
|
$stoertext .= ($stoertext === '' ? '' : ' ') . 'Easee-Fehlercode: ' . $fehlercode . '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'StatusGueltig' => $statusGueltig,
|
||||||
|
'FahrzeugVerbunden' => $verbunden,
|
||||||
|
'FahrzeugGeladen' => $geladen,
|
||||||
|
'Fahrzeugstatus' => $fahrzeugstatus,
|
||||||
|
'Phasenzahl' => $phasenzahl,
|
||||||
|
'Istleistung_W' => $leistung,
|
||||||
|
'Ladestrom_A' => $ladestrom,
|
||||||
|
'ApiMaximalstrom_A' => $apiMaximalstrom,
|
||||||
|
'Stoerung' => $stoerung,
|
||||||
|
'Stoertext' => trim($stoertext),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function solarladenErzwungen(int $betriebsmodus): bool
|
||||||
|
{
|
||||||
|
if (!in_array($betriebsmodus, [
|
||||||
|
self::BETRIEBSMODUS_EASEE,
|
||||||
|
self::BETRIEBSMODUS_NUR_SOLAR,
|
||||||
|
], true)) {
|
||||||
|
throw new InvalidArgumentException('Unbekannter Easee-Betriebsmodus.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $betriebsmodus === self::BETRIEBSMODUS_NUR_SOLAR;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function fahrzeugstatus(int $betriebszustand): int
|
||||||
|
{
|
||||||
|
if ($betriebszustand === 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (in_array($betriebszustand, [2, 6, 7, 8], true)) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if ($betriebszustand === 3) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if ($betriebszustand === 4) {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
if ($betriebszustand === 5) {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function phasenzahl(int $ausgangsphase): int
|
||||||
|
{
|
||||||
|
if ($ausgangsphase >= 10 && $ausgangsphase <= 15) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ($ausgangsphase === 30) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ final class Lizenzpruefung
|
|||||||
{
|
{
|
||||||
public const MANAGER_STANDARD = 'manager_standard';
|
public const MANAGER_STANDARD = 'manager_standard';
|
||||||
public const MANAGER_PEAK = 'manager_peak';
|
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
|
public static function normalisiereCode(string $code): string
|
||||||
{
|
{
|
||||||
@@ -82,6 +85,98 @@ final class Lizenzpruefung
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die aufsummierte aktive Berechtigung fuer einen Katalogeintrag.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $lease
|
||||||
|
*/
|
||||||
|
public static function berechtigungsMenge(array $lease, string $kennung): int
|
||||||
|
{
|
||||||
|
$berechtigungen = $lease['entitlements'] ?? null;
|
||||||
|
if (!is_array($berechtigungen)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$menge = 0;
|
||||||
|
foreach ($berechtigungen as $berechtigung) {
|
||||||
|
if (!is_array($berechtigung)
|
||||||
|
|| ($berechtigung['status'] ?? null) !== 'active'
|
||||||
|
|| !is_int($berechtigung['quantity'] ?? null)
|
||||||
|
|| $berechtigung['quantity'] < 1
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$berechtigungsKennung = $berechtigung['catalog_key']
|
||||||
|
?? $berechtigung['sku']
|
||||||
|
?? null;
|
||||||
|
if ($berechtigungsKennung === $kennung) {
|
||||||
|
$menge += $berechtigung['quantity'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $menge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnet freie, bestehende Einzellizenzen auf nicht separat lizenzierte Waermepumpen an.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $lease
|
||||||
|
* @param array<string, int> $verwendeteMengen
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
public static function beruecksichtigeBestandslizenzFuerWaermepumpen(
|
||||||
|
array $lease,
|
||||||
|
array $verwendeteMengen
|
||||||
|
): array {
|
||||||
|
$waermepumpen = $verwendeteMengen['heat_pump'] ?? 0;
|
||||||
|
if ($waermepumpen <= 0) {
|
||||||
|
return $verwendeteMengen;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nichtSeparatLizenziert = max(
|
||||||
|
0,
|
||||||
|
$waermepumpen - self::berechtigungsMenge($lease, 'heat_pump')
|
||||||
|
);
|
||||||
|
$verbraucher = $verwendeteMengen['consumer_single'] ?? 0;
|
||||||
|
$freieBestandslizenzen = max(
|
||||||
|
0,
|
||||||
|
self::berechtigungsMenge($lease, 'consumer_single') - $verbraucher
|
||||||
|
);
|
||||||
|
$angerechnet = min($nichtSeparatLizenziert, $freieBestandslizenzen);
|
||||||
|
if ($angerechnet === 0) {
|
||||||
|
return $verwendeteMengen;
|
||||||
|
}
|
||||||
|
|
||||||
|
$verwendeteMengen['heat_pump'] = $waermepumpen - $angerechnet;
|
||||||
|
$verwendeteMengen['consumer_single'] = $verbraucher + $angerechnet;
|
||||||
|
|
||||||
|
return $verwendeteMengen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $lease
|
||||||
|
* @param array<string, int> $verwendeteMengen
|
||||||
|
*/
|
||||||
|
public static function pruefeVerbrauchermengen(array $lease, array $verwendeteMengen): void
|
||||||
|
{
|
||||||
|
foreach ($verwendeteMengen as $kennung => $verwendet) {
|
||||||
|
if ($verwendet < 0) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'Die verwendete Menge fuer ' . $kennung . ' darf nicht negativ sein.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$lizenziert = self::berechtigungsMenge($lease, $kennung);
|
||||||
|
if ($verwendet > $lizenziert) {
|
||||||
|
throw new InvalidArgumentException(sprintf(
|
||||||
|
'Lizenzkontingent fuer %s ueberschritten: %d verwendet, %d lizenziert.',
|
||||||
|
$kennung,
|
||||||
|
$verwendet,
|
||||||
|
$lizenziert
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $lease
|
* @param array<string, mixed> $lease
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,33 @@ final class Nachrichtenvertrag
|
|||||||
self::pruefeZustand($daten['Zustand']);
|
self::pruefeZustand($daten['Zustand']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
|
||||||
|
*/
|
||||||
|
public static function enthaeltLeistungswert(array $leistungswerte, int $leistung): bool
|
||||||
|
{
|
||||||
|
foreach ($leistungswerte as $eintrag) {
|
||||||
|
if (is_int($eintrag)) {
|
||||||
|
if ($eintrag === $leistung) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($eintrag)
|
||||||
|
&& isset($eintrag['Von_W'], $eintrag['Bis_W'])
|
||||||
|
&& is_int($eintrag['Von_W'])
|
||||||
|
&& is_int($eintrag['Bis_W'])
|
||||||
|
&& $leistung >= $eintrag['Von_W']
|
||||||
|
&& $leistung <= $eintrag['Bis_W']
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param mixed $kopf
|
* @param mixed $kopf
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,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,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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,12 @@ final class DemoanlageBootstrapTest extends TestCase
|
|||||||
'boiler1' => 2400,
|
'boiler1' => 2400,
|
||||||
'boiler2' => 1500,
|
'boiler2' => 1500,
|
||||||
'dryer' => 900,
|
'dryer' => 900,
|
||||||
|
'buffer' => 1800,
|
||||||
|
'battery' => -1200,
|
||||||
'temp1' => 51.2,
|
'temp1' => 51.2,
|
||||||
'temp2' => 48.7,
|
'temp2' => 48.7,
|
||||||
|
'bufferTemp' => 39.4,
|
||||||
|
'batterySoc' => 63.5,
|
||||||
'manager' => 'PV',
|
'manager' => 'PV',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -27,6 +31,9 @@ final class DemoanlageBootstrapTest extends TestCase
|
|||||||
self::assertStringContainsString('Boiler 1', $html);
|
self::assertStringContainsString('Boiler 1', $html);
|
||||||
self::assertStringContainsString('Boiler 2', $html);
|
self::assertStringContainsString('Boiler 2', $html);
|
||||||
self::assertStringContainsString('Entfeuchter', $html);
|
self::assertStringContainsString('Entfeuchter', $html);
|
||||||
|
self::assertStringContainsString('Pufferspeicher', $html);
|
||||||
|
self::assertStringContainsString('Batterie', $html);
|
||||||
|
self::assertStringContainsString('63,5 %', $html);
|
||||||
self::assertStringContainsString('Einspeisung', $html);
|
self::assertStringContainsString('Einspeisung', $html);
|
||||||
self::assertStringContainsString('12:30', $html);
|
self::assertStringContainsString('12:30', $html);
|
||||||
}
|
}
|
||||||
@@ -105,6 +112,31 @@ final class DemoanlageBootstrapTest extends TestCase
|
|||||||
self::assertStringContainsString('ENELIX_DEMO_DUMMY_MODULE', $source);
|
self::assertStringContainsString('ENELIX_DEMO_DUMMY_MODULE', $source);
|
||||||
self::assertStringContainsString("'Boiler1Liste'", $source);
|
self::assertStringContainsString("'Boiler1Liste'", $source);
|
||||||
self::assertStringContainsString("'Boiler2Liste'", $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("'EnergieflussPVkW'", $source);
|
||||||
self::assertStringContainsString("'~Power'", $source);
|
self::assertStringContainsString("'~Power'", $source);
|
||||||
self::assertStringContainsString("'~Electricity'", $source);
|
self::assertStringContainsString("'~Electricity'", $source);
|
||||||
@@ -120,6 +152,20 @@ final class DemoanlageBootstrapTest extends TestCase
|
|||||||
"enelixDemoEnergyNode(\$flowDryer, 'Entfeuchter', 'consumer')",
|
"enelixDemoEnergyNode(\$flowDryer, 'Entfeuchter', 'consumer')",
|
||||||
$source
|
$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(
|
self::assertStringContainsString(
|
||||||
"enelixDemoEnsureChart(\$history, 'LeistungsdiagrammNativ'",
|
"enelixDemoEnsureChart(\$history, 'LeistungsdiagrammNativ'",
|
||||||
$source
|
$source
|
||||||
@@ -135,6 +181,35 @@ final class DemoanlageBootstrapTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
public function testRollbackIstExplizitBestaetigungspflichtig(): void
|
||||||
{
|
{
|
||||||
$this->expectException(RuntimeException::class);
|
$this->expectException(RuntimeException::class);
|
||||||
|
|||||||
@@ -8,27 +8,27 @@ use PHPUnit\Framework\TestCase;
|
|||||||
|
|
||||||
final class DokumentationsstrukturTest extends TestCase
|
final class DokumentationsstrukturTest extends TestCase
|
||||||
{
|
{
|
||||||
/** @return list<array{string}> */
|
public function testWaermepumpeDokumentiertDenImplementiertenStand(): void
|
||||||
public function modulReadmes(): array
|
|
||||||
{
|
{
|
||||||
return [
|
$pfad = __DIR__ . '/../docs/module/Waermepumpe/README.md';
|
||||||
['Batterie'],
|
|
||||||
['Waermepumpe'],
|
|
||||||
['Ladestation-Gateway'],
|
|
||||||
['Easee-Gateway'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @dataProvider modulReadmes */
|
|
||||||
public function testJederModulentwurfHatEinReadme(string $modul): void
|
|
||||||
{
|
|
||||||
$pfad = __DIR__ . '/../docs/module/' . $modul . '/README.md';
|
|
||||||
self::assertFileExists($pfad);
|
self::assertFileExists($pfad);
|
||||||
|
|
||||||
$inhalt = file_get_contents($pfad);
|
$inhalt = file_get_contents($pfad);
|
||||||
self::assertNotFalse($inhalt);
|
self::assertNotFalse($inhalt);
|
||||||
self::assertStringContainsString('Status: Diskussionsentwurf', $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('Properties', $inhalt);
|
||||||
|
self::assertStringContainsString('Registeradapter', $inhalt);
|
||||||
|
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle-Batterie.md');
|
||||||
|
self::assertFileExists(__DIR__ . '/../docs/migration/Batterie.md');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPufferspeicherDokumentiertDenImplementiertenStand(): void
|
public function testPufferspeicherDokumentiertDenImplementiertenStand(): void
|
||||||
@@ -48,9 +48,23 @@ final class DokumentationsstrukturTest extends TestCase
|
|||||||
self::assertStringContainsString('Fake-HTTP-Transport', $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
|
public function testSchnittstellenDokumentiertSind(): void
|
||||||
{
|
{
|
||||||
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle.md');
|
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle.md');
|
||||||
|
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle-Easee-Gateway.md');
|
||||||
self::assertFileExists(__DIR__ . '/../docs/Obere-Anschluesse.md');
|
self::assertFileExists(__DIR__ . '/../docs/Obere-Anschluesse.md');
|
||||||
self::assertFileExists(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
|
self::assertFileExists(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixEMS\Tests;
|
||||||
|
|
||||||
|
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class EaseeGatewayProtokollTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testVerschachtelteSignalRBeobachtungenWerdenExtrahiert(): void
|
||||||
|
{
|
||||||
|
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
|
||||||
|
'arguments' => [
|
||||||
|
'EH123456',
|
||||||
|
[
|
||||||
|
['id' => 109, 'value' => 3],
|
||||||
|
['ObservationId' => 110, 'Value' => 30],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], ['EH123456']);
|
||||||
|
|
||||||
|
self::assertSame([
|
||||||
|
['Seriennummer' => 'EH123456', 'ID' => 109, 'Wert' => 3],
|
||||||
|
['Seriennummer' => 'EH123456', 'ID' => 110, 'Wert' => 30],
|
||||||
|
], $beobachtungen);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNichtAbonnierteStationenUndUnbekannteIdsWerdenIgnoriert(): void
|
||||||
|
{
|
||||||
|
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
|
||||||
|
[
|
||||||
|
'serialNumber' => 'EH999999',
|
||||||
|
'id' => 109,
|
||||||
|
'value' => 3,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'serialNumber' => 'EH123456',
|
||||||
|
'id' => 999,
|
||||||
|
'value' => 1,
|
||||||
|
],
|
||||||
|
], ['EH123456']);
|
||||||
|
|
||||||
|
self::assertSame([], $beobachtungen);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSeriennummerWirdNormalisiert(): void
|
||||||
|
{
|
||||||
|
self::assertSame(
|
||||||
|
'EH123456',
|
||||||
|
EaseeGatewayProtokoll::seriennummer(' eh123456 ')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixEMS\Tests;
|
||||||
|
|
||||||
|
use Belevo\EnelixEMS\EaseeLadestatus;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class EaseeLadestatusTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testFahrzeugUndEinphasigesLadenKommenDirektAusApiWerten(): void
|
||||||
|
{
|
||||||
|
$status = EaseeLadestatus::ausBeobachtungen([
|
||||||
|
'109' => 3,
|
||||||
|
'110' => 10,
|
||||||
|
'120' => 2.3,
|
||||||
|
'182' => 10.1,
|
||||||
|
], 16);
|
||||||
|
|
||||||
|
self::assertTrue($status['FahrzeugVerbunden']);
|
||||||
|
self::assertFalse($status['FahrzeugGeladen']);
|
||||||
|
self::assertSame(3, $status['Fahrzeugstatus']);
|
||||||
|
self::assertSame(1, $status['Phasenzahl']);
|
||||||
|
self::assertEqualsWithDelta(2300.0, $status['Istleistung_W'], 0.01);
|
||||||
|
self::assertEqualsWithDelta(10.1, $status['Ladestrom_A'], 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDreiphasigesLadenWirdNichtAusLeistungGeschaetzt(): void
|
||||||
|
{
|
||||||
|
$status = EaseeLadestatus::ausBeobachtungen([
|
||||||
|
'109' => 3,
|
||||||
|
'110' => 30,
|
||||||
|
'120' => 1.0,
|
||||||
|
'182' => 2.0,
|
||||||
|
'183' => 2.2,
|
||||||
|
'184' => 2.1,
|
||||||
|
], 32);
|
||||||
|
|
||||||
|
self::assertSame(3, $status['Phasenzahl']);
|
||||||
|
self::assertEqualsWithDelta(2.2, $status['Ladestrom_A'], 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testZweiphasigerApiWertWirdNichtAlsEinOderDreiGeschaetzt(): void
|
||||||
|
{
|
||||||
|
$status = EaseeLadestatus::ausBeobachtungen([
|
||||||
|
'109' => 3,
|
||||||
|
'110' => 20,
|
||||||
|
'120' => 4.6,
|
||||||
|
], 16);
|
||||||
|
|
||||||
|
self::assertSame(0, $status['Phasenzahl']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetrenntUndGeladenFolgenDemEaseeBetriebszustand(): void
|
||||||
|
{
|
||||||
|
$getrennt = EaseeLadestatus::ausBeobachtungen(['109' => 1, '110' => 30], 16);
|
||||||
|
$geladen = EaseeLadestatus::ausBeobachtungen(['109' => 4, '110' => 30], 16);
|
||||||
|
|
||||||
|
self::assertFalse($getrennt['FahrzeugVerbunden']);
|
||||||
|
self::assertSame(0, $getrennt['Phasenzahl']);
|
||||||
|
self::assertTrue($geladen['FahrzeugVerbunden']);
|
||||||
|
self::assertTrue($geladen['FahrzeugGeladen']);
|
||||||
|
self::assertSame(4, $geladen['Fahrzeugstatus']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnbekannterApiBetriebszustandGibtRegelungNichtFrei(): void
|
||||||
|
{
|
||||||
|
$status = EaseeLadestatus::ausBeobachtungen([
|
||||||
|
'109' => 99,
|
||||||
|
'110' => 30,
|
||||||
|
], 16);
|
||||||
|
|
||||||
|
self::assertFalse($status['StatusGueltig']);
|
||||||
|
self::assertFalse($status['FahrzeugVerbunden']);
|
||||||
|
self::assertSame(0, $status['Fahrzeugstatus']);
|
||||||
|
self::assertSame(0, $status['Phasenzahl']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApiUndKabelgrenzenBegrenzenDenKonfiguriertenStrom(): void
|
||||||
|
{
|
||||||
|
$status = EaseeLadestatus::ausBeobachtungen([
|
||||||
|
'109' => 2,
|
||||||
|
'110' => 10,
|
||||||
|
'47' => 25.0,
|
||||||
|
'104' => 13.7,
|
||||||
|
], 32);
|
||||||
|
|
||||||
|
self::assertSame(13, $status['ApiMaximalstrom_A']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNurSolarladenIstFestErzwungen(): void
|
||||||
|
{
|
||||||
|
self::assertFalse(EaseeLadestatus::solarladenErzwungen(0));
|
||||||
|
self::assertTrue(EaseeLadestatus::solarladenErzwungen(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixEMS\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class EaseeModuleStrukturTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testGatewayUndLadestationSindVollstaendigeModule(): void
|
||||||
|
{
|
||||||
|
foreach (['EaseeGateway', 'LadestationGateway'] as $modul) {
|
||||||
|
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
|
||||||
|
self::assertFileExists(__DIR__ . '/../' . $modul . '/' . $datei);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDatenSchnittstellenPassenZusammen(): void
|
||||||
|
{
|
||||||
|
$gateway = $this->module('EaseeGateway');
|
||||||
|
$ladestation = $this->module('LadestationGateway');
|
||||||
|
|
||||||
|
self::assertContains(
|
||||||
|
'{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}',
|
||||||
|
$gateway['implemented']
|
||||||
|
);
|
||||||
|
self::assertContains(
|
||||||
|
'{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}',
|
||||||
|
$gateway['childRequirements']
|
||||||
|
);
|
||||||
|
self::assertContains(
|
||||||
|
'{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}',
|
||||||
|
$ladestation['parentRequirements']
|
||||||
|
);
|
||||||
|
self::assertContains(
|
||||||
|
'{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}',
|
||||||
|
$ladestation['implemented']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testZugangsdatenLiegenNurImGateway(): void
|
||||||
|
{
|
||||||
|
$gatewayForm = (string) file_get_contents(
|
||||||
|
__DIR__ . '/../EaseeGateway/form.json'
|
||||||
|
);
|
||||||
|
$ladestationForm = (string) file_get_contents(
|
||||||
|
__DIR__ . '/../LadestationGateway/form.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertStringContainsString('PasswordTextBox', $gatewayForm);
|
||||||
|
self::assertStringNotContainsString('PasswordTextBox', $ladestationForm);
|
||||||
|
self::assertStringNotContainsString('Username', $ladestationForm);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLadestationVerwendetApiStatusUndGemeinsamenVertrag(): void
|
||||||
|
{
|
||||||
|
$modul = (string) file_get_contents(
|
||||||
|
__DIR__ . '/../LadestationGateway/module.php'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
|
||||||
|
self::assertStringContainsString('EaseeLadestatus::ausBeobachtungen', $modul);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
"RegisterPropertyBoolean('Ladefreigabe', true)",
|
||||||
|
$modul
|
||||||
|
);
|
||||||
|
self::assertStringContainsString("'FahrzeugVerbunden'", $modul);
|
||||||
|
self::assertStringContainsString("'Phasenzahl'", $modul);
|
||||||
|
self::assertStringNotContainsString('7500', $modul);
|
||||||
|
self::assertStringNotContainsString('Abfrageintervall', $modul);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function module(string $name): array
|
||||||
|
{
|
||||||
|
return json_decode(
|
||||||
|
(string) file_get_contents(__DIR__ . '/../' . $name . '/module.json'),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,10 @@ final class LadestationStandAloneModulstrukturTest extends TestCase
|
|||||||
{
|
{
|
||||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||||
|
|
||||||
|
self::assertStringContainsString(
|
||||||
|
"RegisterPropertyBoolean('Ladefreigabe', true)",
|
||||||
|
$modul
|
||||||
|
);
|
||||||
self::assertStringContainsString("EnableAction('Ladefreigabe')", $modul);
|
self::assertStringContainsString("EnableAction('Ladefreigabe')", $modul);
|
||||||
self::assertStringContainsString("EnableAction('Solarladen')", $modul);
|
self::assertStringContainsString("EnableAction('Solarladen')", $modul);
|
||||||
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul);
|
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul);
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ final class LibraryMetadataTest extends TestCase
|
|||||||
self::assertSame('Enelix EMS', $daten['name']);
|
self::assertSame('Enelix EMS', $daten['name']);
|
||||||
self::assertSame('Enelix', $daten['author']);
|
self::assertSame('Enelix', $daten['author']);
|
||||||
self::assertSame('8.0', $daten['compatibility']['version']);
|
self::assertSame('8.0', $daten['compatibility']['version']);
|
||||||
|
self::assertSame('0.1', $daten['version']);
|
||||||
|
self::assertSame(1, $daten['build']);
|
||||||
|
self::assertGreaterThan(0, $daten['date']);
|
||||||
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']);
|
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,130 @@ final class LizenzpruefungTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testZweiLizenzierteBoilerWerdenFreigegeben(): void
|
||||||
|
{
|
||||||
|
$lease = $this->lease('manager_standard');
|
||||||
|
$lease['entitlements'][] = [
|
||||||
|
'sku' => 'ENX-BOILER-MULTI',
|
||||||
|
'catalog_key' => 'boiler_multi',
|
||||||
|
'quantity' => 2,
|
||||||
|
'status' => 'active',
|
||||||
|
];
|
||||||
|
|
||||||
|
self::assertSame(2, Lizenzpruefung::berechtigungsMenge($lease, 'boiler_multi'));
|
||||||
|
Lizenzpruefung::pruefeVerbrauchermengen($lease, ['boiler_multi' => 2]);
|
||||||
|
|
||||||
|
self::assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDritterBoilerBeiZweiLizenzenWirdAbgelehnt(): void
|
||||||
|
{
|
||||||
|
$lease = $this->lease('manager_standard');
|
||||||
|
$lease['entitlements'][] = [
|
||||||
|
'sku' => 'ENX-BOILER-MULTI',
|
||||||
|
'catalog_key' => 'boiler_multi',
|
||||||
|
'quantity' => 2,
|
||||||
|
'status' => 'active',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage(
|
||||||
|
'Lizenzkontingent fuer boiler_multi ueberschritten: 3 verwendet, 2 lizenziert.'
|
||||||
|
);
|
||||||
|
Lizenzpruefung::pruefeVerbrauchermengen($lease, ['boiler_multi' => 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerbraucherkontingenteWerdenJeTypSummiertUndGetrenntGeprueft(): void
|
||||||
|
{
|
||||||
|
$lease = $this->lease('manager_standard');
|
||||||
|
$lease['entitlements'] = array_merge($lease['entitlements'], [
|
||||||
|
[
|
||||||
|
'catalog_key' => 'boiler_multi',
|
||||||
|
'quantity' => 1,
|
||||||
|
'status' => 'active',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'catalog_key' => 'boiler_multi',
|
||||||
|
'quantity' => 1,
|
||||||
|
'status' => 'active',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'catalog_key' => 'battery',
|
||||||
|
'quantity' => 1,
|
||||||
|
'status' => 'active',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'catalog_key' => 'battery',
|
||||||
|
'quantity' => 10,
|
||||||
|
'status' => 'revoked',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(2, Lizenzpruefung::berechtigungsMenge($lease, 'boiler_multi'));
|
||||||
|
self::assertSame(1, Lizenzpruefung::berechtigungsMenge($lease, 'battery'));
|
||||||
|
Lizenzpruefung::pruefeVerbrauchermengen($lease, [
|
||||||
|
'boiler_multi' => 2,
|
||||||
|
'battery' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSeparateWaermepumpenlizenzBleibtUnveraendert(): void
|
||||||
|
{
|
||||||
|
$lease = $this->lease('manager_standard');
|
||||||
|
$lease['entitlements'][] = [
|
||||||
|
'catalog_key' => 'heat_pump',
|
||||||
|
'quantity' => 1,
|
||||||
|
'status' => 'active',
|
||||||
|
];
|
||||||
|
$verwendung = ['heat_pump' => 1];
|
||||||
|
|
||||||
|
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
|
||||||
|
$lease,
|
||||||
|
$verwendung
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame($verwendung, $pruefmengen);
|
||||||
|
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFreieBestandslizenzDecktEineWaermepumpeAb(): void
|
||||||
|
{
|
||||||
|
$lease = $this->lease('manager_standard');
|
||||||
|
$lease['entitlements'][] = [
|
||||||
|
'catalog_key' => 'consumer_single',
|
||||||
|
'quantity' => 1,
|
||||||
|
'status' => 'active',
|
||||||
|
];
|
||||||
|
|
||||||
|
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
|
||||||
|
$lease,
|
||||||
|
['heat_pump' => 1]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['heat_pump' => 0, 'consumer_single' => 1], $pruefmengen);
|
||||||
|
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBestandslizenzDarfNichtDoppeltVerwendetWerden(): void
|
||||||
|
{
|
||||||
|
$lease = $this->lease('manager_standard');
|
||||||
|
$lease['entitlements'][] = [
|
||||||
|
'catalog_key' => 'consumer_single',
|
||||||
|
'quantity' => 1,
|
||||||
|
'status' => 'active',
|
||||||
|
];
|
||||||
|
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
|
||||||
|
$lease,
|
||||||
|
['consumer_single' => 1, 'heat_pump' => 1]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Lizenzkontingent fuer heat_pump ueberschritten');
|
||||||
|
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
|
||||||
|
}
|
||||||
|
|
||||||
public function testLizenzcodeWirdWieAufDemServerNormalisiert(): void
|
public function testLizenzcodeWirdWieAufDemServerNormalisiert(): void
|
||||||
{
|
{
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,18 +48,49 @@ final class ManagerModulstrukturTest extends TestCase
|
|||||||
'VerbraucherTimeout',
|
'VerbraucherTimeout',
|
||||||
'MesswertMaxAlter',
|
'MesswertMaxAlter',
|
||||||
'Umschaltdifferenz',
|
'Umschaltdifferenz',
|
||||||
|
'EinspeisebegrenzungAktiv',
|
||||||
|
'Einspeisegrenze',
|
||||||
|
'PrognoseAktiv',
|
||||||
|
'PrognoseLizenzInformation',
|
||||||
|
'NetzfahrplanAktiv',
|
||||||
|
'NetzbezugEnergieVariableID',
|
||||||
|
'NetzeinspeisungEnergieVariableID',
|
||||||
|
'IstleistungVariableID',
|
||||||
|
'ErzeugungsenergieVariableID',
|
||||||
|
'Erzeugungsenergiefaktor',
|
||||||
|
'BegrenzungVariableID',
|
||||||
|
'Begrenzungsart',
|
||||||
'DiagnosevariablenAnzeigen',
|
'DiagnosevariablenAnzeigen',
|
||||||
'LoggingEin',
|
'LoggingEin',
|
||||||
'Lizenzcode',
|
'Lizenzcode',
|
||||||
'LizenzPruefen',
|
'LizenzPruefen',
|
||||||
|
'LizenzVerwalten',
|
||||||
'LizenzInstallationID',
|
'LizenzInstallationID',
|
||||||
'LizenzInformation',
|
'LizenzInformation',
|
||||||
'VerbraucherAktualisieren',
|
'VerbraucherAktualisieren',
|
||||||
|
'AnlagenWechselrichter',
|
||||||
|
'AnlagenPVFlaechen',
|
||||||
|
'AnlagenBatterien',
|
||||||
|
'Istleistungsfaktor',
|
||||||
|
'LeistungVariableID',
|
||||||
|
'Leistungsfaktor',
|
||||||
|
'SOCVariableID',
|
||||||
|
'LadeenergieVariableID',
|
||||||
|
'EntladeenergieVariableID',
|
||||||
|
'Energiefaktor',
|
||||||
|
'EnergyPieAnzeigen',
|
||||||
|
'EnergiediagrammeAnzeigen',
|
||||||
|
'FunFactsAnzeigen',
|
||||||
|
'EnergieflussAnzeigen',
|
||||||
|
'PrognoseSendeintervall',
|
||||||
|
'StoerueberwachungAktiv',
|
||||||
|
'StoerungsSendeintervall',
|
||||||
] as $property) {
|
] as $property) {
|
||||||
self::assertStringContainsString($property, $json);
|
self::assertStringContainsString($property, $json);
|
||||||
}
|
}
|
||||||
foreach (['Regelintervall', 'Vorgabeintervall', 'UmschaltschwelleUeberschuss',
|
foreach (['Regelintervall', 'Vorgabeintervall', 'UmschaltschwelleUeberschuss',
|
||||||
'UmschaltschwelleLastspitze', 'Optionale obere Anschluesse', 'LizenzAnschluss'] as $entfernt
|
'UmschaltschwelleLastspitze', 'Optionale obere Anschluesse', 'LizenzAnschluss',
|
||||||
|
'Einspeisetoleranz', 'ACEinspeisegrenze_kW', 'ACBezugsgrenze_kW'] as $entfernt
|
||||||
) {
|
) {
|
||||||
self::assertStringNotContainsString($entfernt, $json);
|
self::assertStringNotContainsString($entfernt, $json);
|
||||||
}
|
}
|
||||||
@@ -69,6 +100,8 @@ final class ManagerModulstrukturTest extends TestCase
|
|||||||
self::assertStringContainsString($bereich, $json);
|
self::assertStringContainsString($bereich, $json);
|
||||||
}
|
}
|
||||||
self::assertSame('Lizenzierung', $formular['elements'][0]['caption']);
|
self::assertSame('Lizenzierung', $formular['elements'][0]['caption']);
|
||||||
|
self::assertStringContainsString('https://license.enelix.ch', $json);
|
||||||
|
self::assertStringContainsString('"link":true', $json);
|
||||||
self::assertStringContainsString(
|
self::assertStringContainsString(
|
||||||
"'SuchbereichID' => \$SuchbereichID",
|
"'SuchbereichID' => \$SuchbereichID",
|
||||||
$json
|
$json
|
||||||
@@ -107,13 +140,182 @@ final class ManagerModulstrukturTest extends TestCase
|
|||||||
$inhalt
|
$inhalt
|
||||||
);
|
);
|
||||||
self::assertStringContainsString("RegisterAttributeString('LizenzInstallationID'", $inhalt);
|
self::assertStringContainsString("RegisterAttributeString('LizenzInstallationID'", $inhalt);
|
||||||
|
self::assertSame(
|
||||||
|
1,
|
||||||
|
preg_match('/public function Create\(\): void.*?public function ApplyChanges\(\): void/s', $inhalt, $treffer)
|
||||||
|
);
|
||||||
|
self::assertStringNotContainsString(
|
||||||
|
"WriteAttributeString('LizenzInstallationID'",
|
||||||
|
$treffer[0]
|
||||||
|
);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
"parent::ApplyChanges();\n\n \$this->initialisiereLizenzInstallationID();",
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
self::assertSame(1, substr_count(
|
||||||
|
$inhalt,
|
||||||
|
"WriteAttributeString('LizenzInstallationID'"
|
||||||
|
));
|
||||||
self::assertStringContainsString("RegisterAttributeString('LizenzLease'", $inhalt);
|
self::assertStringContainsString("RegisterAttributeString('LizenzLease'", $inhalt);
|
||||||
self::assertStringContainsString("RegisterAttributeString('LizenzcodeHash'", $inhalt);
|
self::assertStringContainsString("RegisterAttributeString('LizenzcodeHash'", $inhalt);
|
||||||
self::assertStringContainsString('STATUS_LIZENZ_UNGUELTIG', $inhalt);
|
self::assertStringContainsString('STATUS_LIZENZ_UNGUELTIG', $inhalt);
|
||||||
self::assertStringContainsString('aktualisiereLizenzfreigabe(', $inhalt);
|
self::assertStringContainsString('aktualisiereLizenzfreigabe(', $inhalt);
|
||||||
|
self::assertStringContainsString('pruefeVerbraucherlizenzen(', $inhalt);
|
||||||
|
self::assertStringContainsString('lizenzBerechtigungenPassen(', $inhalt);
|
||||||
|
self::assertStringContainsString('peakShavingLizenziert(', $inhalt);
|
||||||
|
self::assertStringContainsString('Aus (Peak-Shaving-Lizenz fehlt)', $inhalt);
|
||||||
|
self::assertStringContainsString('Aus (Peak-Shaving-Lizenz verfuegbar)', $inhalt);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'Konstant und Monatlich benoetigen eine Peak-Shaving-Lizenz.',
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
foreach (['battery', 'buffer_storage', 'ev_charger', 'consumer_single', 'heat_pump', 'boiler_multi'] as $katalog) {
|
||||||
|
self::assertStringContainsString("'Katalog' => '" . $katalog . "'", $inhalt);
|
||||||
|
}
|
||||||
|
foreach ([
|
||||||
|
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
|
||||||
|
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
|
||||||
|
'{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}',
|
||||||
|
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
|
||||||
|
'{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}',
|
||||||
|
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}',
|
||||||
|
'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}',
|
||||||
|
] as $modulID) {
|
||||||
|
self::assertStringContainsString("'" . $modulID . "' =>", $inhalt);
|
||||||
|
}
|
||||||
|
self::assertStringContainsString("'Name' => 'Pufferspeicher'", $inhalt);
|
||||||
|
self::assertStringContainsString("'Name' => 'Batteriespeicher'", $inhalt);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'Bitte Auswahl reduzieren oder Lizenz erweitern.',
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
foreach ([
|
||||||
|
"'NichtGeregelt'",
|
||||||
|
"'NichtGeregelteGeraete'",
|
||||||
|
"'InstanzStatus'",
|
||||||
|
"'LetzteMeldung'",
|
||||||
|
"'GemeldeteBetriebsart'",
|
||||||
|
"'Verfuegbar'",
|
||||||
|
] as $detailfeld) {
|
||||||
|
self::assertStringContainsString($detailfeld, $inhalt);
|
||||||
|
}
|
||||||
self::assertStringNotContainsString("'publicKey' =>", $inhalt);
|
self::assertStringNotContainsString("'publicKey' =>", $inhalt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testManagerSynchronisiertDieTopologieMitSeparatemGeraetezugang(): void
|
||||||
|
{
|
||||||
|
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||||
|
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology',
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
"RegisterAttributeString('PrognoseInstallationsToken', '')",
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
self::assertStringContainsString('JSON_PRESERVE_ZERO_FRACTION', $inhalt);
|
||||||
|
self::assertStringContainsString("'issueDeviceToken' =>", $inhalt);
|
||||||
|
self::assertStringContainsString("unset(\$neueLease['deviceToken'])", $inhalt);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
"|| \$this->ReadPropertyBoolean('StoerueberwachungAktiv'))",
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
"\$this->ReadPropertyString('Lizenzcode'),\n \$geraetezugangFehlt",
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
self::assertStringContainsString('synchronisiereAnlagentopologie()', $inhalt);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry',
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
self::assertStringContainsString("'PrognoseSenden',", $inhalt);
|
||||||
|
self::assertStringContainsString("'capturedAt' => gmdate(", $inhalt);
|
||||||
|
foreach (["'PV'", "'Hausverbrauch'", "'Netzleistung'", "'SOC'"] as $messwert) {
|
||||||
|
self::assertStringContainsString($messwert, $inhalt);
|
||||||
|
}
|
||||||
|
self::assertStringContainsString("'Authorization: Bearer ' . \$token", $inhalt);
|
||||||
|
self::assertStringNotContainsString(
|
||||||
|
"protokolliere('Prognosesynchronisation', \$token)",
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
|
||||||
|
$leasePruefung = strpos($inhalt, 'Lizenzpruefung::pruefeLease(');
|
||||||
|
$tokenSpeicherung = strpos(
|
||||||
|
$inhalt,
|
||||||
|
"WriteAttributeString('PrognoseInstallationsToken', \$geraeteToken)"
|
||||||
|
);
|
||||||
|
self::assertNotFalse($leasePruefung);
|
||||||
|
self::assertNotFalse($tokenSpeicherung);
|
||||||
|
self::assertTrue(
|
||||||
|
$leasePruefung < $tokenSpeicherung,
|
||||||
|
'Der Gerätezugang darf erst nach validierter Lizenzantwort gespeichert werden.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testManagerUebertraegtStoerungsvollsnapshotsMitRetry(): void
|
||||||
|
{
|
||||||
|
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||||
|
|
||||||
|
self::assertFileExists(__DIR__ . '/../libs/StoerungsSnapshot.php');
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'https://license.enelix.ch/api/v1/installations/%s/faults',
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
foreach ([
|
||||||
|
"RegisterPropertyBoolean('StoerueberwachungAktiv', false)",
|
||||||
|
"RegisterPropertyInteger('StoerungsSendeintervall', 300)",
|
||||||
|
"RegisterAttributeString('StoerungsSnapshot', '{}')",
|
||||||
|
"RegisterAttributeString('StoerungsGesendeterHash', '')",
|
||||||
|
"RegisterAttributeInteger('StoerungsNaechsterVersuch', 0)",
|
||||||
|
"'StoerungenSenden',",
|
||||||
|
'StoerungsSnapshot::erstellen(',
|
||||||
|
'StoerungsSnapshot::zustandHash(',
|
||||||
|
"CURLOPT_CUSTOMREQUEST => 'PUT'",
|
||||||
|
] as $erwartet) {
|
||||||
|
self::assertStringContainsString($erwartet, $inhalt);
|
||||||
|
}
|
||||||
|
self::assertStringNotContainsString(
|
||||||
|
'"name":"StoermeldeAnschluss"',
|
||||||
|
str_replace(' ', '', (string) file_get_contents(__DIR__ . '/../Manager/form.json'))
|
||||||
|
);
|
||||||
|
self::assertGreaterThanOrEqual(
|
||||||
|
3,
|
||||||
|
substr_count(
|
||||||
|
$inhalt,
|
||||||
|
"|| \$this->ReadPropertyBoolean('StoerueberwachungAktiv'))"
|
||||||
|
),
|
||||||
|
'Der Gerätezugang muss beim Anwenden, Erneuern und Anfordern berücksichtigt werden.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testManagerRegeltDieAnlagenweiteEinspeisegrenzeUndDenNetzfahrplan(): void
|
||||||
|
{
|
||||||
|
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||||
|
|
||||||
|
self::assertFileExists(__DIR__ . '/../libs/EinspeiseRegler.php');
|
||||||
|
self::assertStringContainsString('EinspeiseRegler::verteile(', $inhalt);
|
||||||
|
self::assertStringContainsString('EinspeiseRegler::stellwert(', $inhalt);
|
||||||
|
self::assertStringContainsString("RegisterAttributeString('LetztePvGrenzen'", $inhalt);
|
||||||
|
self::assertStringContainsString("RegisterAttributeString('Netzfahrplan'", $inhalt);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'https://license.enelix.ch/api/v1/installations/%s/prognosis/schedule',
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
self::assertStringContainsString('Lizenzpruefung::NETZFAHRPLAN', $inhalt);
|
||||||
|
self::assertStringContainsString('Lizenzpruefung::PROGNOSE_PV', $inhalt);
|
||||||
|
self::assertStringContainsString('Lizenzpruefung::PROGNOSE_VERBRAUCH', $inhalt);
|
||||||
|
self::assertStringContainsString('prognoseLizenzStatustext(', $inhalt);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
"foreach (['PeakShavingTitel', 'EinspeisebegrenzungAktiv', 'Einspeisegrenze']",
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
self::assertStringContainsString('aktuelleNetzfahrplanVorgabe()', $inhalt);
|
||||||
|
self::assertStringContainsString('gibEinspeisebegrenzungFrei()', $inhalt);
|
||||||
|
self::assertStringNotContainsString("RegisterPropertyFloat('Einspeisetoleranz'", $inhalt);
|
||||||
|
}
|
||||||
|
|
||||||
public function testManagerVerwendetDieNeuenZeitdefaults(): void
|
public function testManagerVerwendetDieNeuenZeitdefaults(): void
|
||||||
{
|
{
|
||||||
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||||
@@ -136,6 +338,8 @@ final class ManagerModulstrukturTest extends TestCase
|
|||||||
{
|
{
|
||||||
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||||
self::assertStringContainsString('implements ManagerSchnittstelle', $inhalt);
|
self::assertStringContainsString('implements ManagerSchnittstelle', $inhalt);
|
||||||
|
self::assertStringContainsString("require_once __DIR__ . '/../libs/Anlagentopologie.php'", $inhalt);
|
||||||
|
self::assertStringContainsString('public function AnlagentopologieExportieren(): string', $inhalt);
|
||||||
self::assertStringContainsString('VerbraucherdatenEmpfangen(array $daten)', $inhalt);
|
self::assertStringContainsString('VerbraucherdatenEmpfangen(array $daten)', $inhalt);
|
||||||
self::assertStringContainsString("'ManagerdatenEmpfangen'", $inhalt);
|
self::assertStringContainsString("'ManagerdatenEmpfangen'", $inhalt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,17 @@ final class NachrichtenvertragTest extends TestCase
|
|||||||
self::assertTrue(true);
|
self::assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testLeistungswertWirdInEinzelwertenUndBereichenGefunden(): void
|
||||||
|
{
|
||||||
|
$angebot = [0, ['Von_W' => 1000, 'Bis_W' => 2000], 3000];
|
||||||
|
|
||||||
|
self::assertTrue(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 0));
|
||||||
|
self::assertTrue(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 1537));
|
||||||
|
self::assertTrue(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 2000));
|
||||||
|
self::assertFalse(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 999));
|
||||||
|
self::assertFalse(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 2500));
|
||||||
|
}
|
||||||
|
|
||||||
public function testNegativePrioritaetWirdAbgelehnt(): void
|
public function testNegativePrioritaetWirdAbgelehnt(): void
|
||||||
{
|
{
|
||||||
$daten = $this->verbraucherdaten();
|
$daten = $this->verbraucherdaten();
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixEMS\Tests;
|
||||||
|
|
||||||
|
use Belevo\EnelixEMS\StoerungsSnapshot;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class StoerungsSnapshotTest extends TestCase
|
||||||
|
{
|
||||||
|
private const INSTALLATION_ID = '123e4567-e89b-42d3-a456-426614174000';
|
||||||
|
|
||||||
|
public function testSnapshotNormalisiertDedupliziertUndSortiertMeldungen(): void
|
||||||
|
{
|
||||||
|
$snapshot = StoerungsSnapshot::erstellen(
|
||||||
|
self::INSTALLATION_ID,
|
||||||
|
42,
|
||||||
|
'main',
|
||||||
|
true,
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'sourceType' => 'consumer',
|
||||||
|
'sourceId' => 'ips:8',
|
||||||
|
'sourceName' => ' Boiler ',
|
||||||
|
'code' => 'Temp.High',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'message' => " Temperatur hoch ",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'sourceType' => 'manager',
|
||||||
|
'sourceId' => 'ips:42',
|
||||||
|
'sourceName' => 'Manager',
|
||||||
|
'code' => 'Grid.Missing',
|
||||||
|
'severity' => 'critical',
|
||||||
|
'message' => 'Netzmessung fehlt',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'sourceType' => 'consumer',
|
||||||
|
'sourceId' => 'ips:8',
|
||||||
|
'sourceName' => 'Boiler',
|
||||||
|
'code' => 'Temp.High',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'message' => 'Temperatur sehr hoch',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
1_700_000_000
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('1.0', $snapshot['version']);
|
||||||
|
self::assertSame('2023-11-14T22:13:20Z', $snapshot['capturedAt']);
|
||||||
|
self::assertSame(
|
||||||
|
['Grid.Missing', 'Temp.High'],
|
||||||
|
array_column($snapshot['faults'], 'code')
|
||||||
|
);
|
||||||
|
self::assertSame('Temperatur sehr hoch', $snapshot['faults'][1]['message']);
|
||||||
|
self::assertSame(
|
||||||
|
['instanceId' => 42, 'role' => 'main', 'active' => true],
|
||||||
|
$snapshot['manager']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testZustandHashIgnoriertDenErfassungszeitpunkt(): void
|
||||||
|
{
|
||||||
|
$a = StoerungsSnapshot::erstellen(
|
||||||
|
self::INSTALLATION_ID,
|
||||||
|
42,
|
||||||
|
'standalone',
|
||||||
|
false,
|
||||||
|
[],
|
||||||
|
1_700_000_000
|
||||||
|
);
|
||||||
|
$b = StoerungsSnapshot::erstellen(
|
||||||
|
self::INSTALLATION_ID,
|
||||||
|
42,
|
||||||
|
'standalone',
|
||||||
|
false,
|
||||||
|
[],
|
||||||
|
1_700_000_300
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(StoerungsSnapshot::zustandHash($a), StoerungsSnapshot::zustandHash($b));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSnapshotBegrenztSichAufHundertPriorisierteMeldungen(): void
|
||||||
|
{
|
||||||
|
$meldungen = [];
|
||||||
|
for ($index = 1; $index <= 101; ++$index) {
|
||||||
|
$meldungen[] = [
|
||||||
|
'sourceType' => 'consumer',
|
||||||
|
'sourceId' => 'ips:' . $index,
|
||||||
|
'sourceName' => sprintf('Quelle %03d', $index),
|
||||||
|
'code' => 'Fault.' . $index,
|
||||||
|
'severity' => $index === 101 ? 'critical' : 'warning',
|
||||||
|
'message' => 'Testmeldung ' . $index,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshot = StoerungsSnapshot::erstellen(
|
||||||
|
self::INSTALLATION_ID,
|
||||||
|
42,
|
||||||
|
'standalone',
|
||||||
|
true,
|
||||||
|
$meldungen
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(100, $snapshot['faults']);
|
||||||
|
self::assertSame('Fault.101', $snapshot['faults'][0]['code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUngueltigerSchweregradWirdAbgelehnt(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
StoerungsSnapshot::erstellen(
|
||||||
|
self::INSTALLATION_ID,
|
||||||
|
42,
|
||||||
|
'standalone',
|
||||||
|
true,
|
||||||
|
[[
|
||||||
|
'sourceType' => 'consumer',
|
||||||
|
'sourceId' => 'ips:8',
|
||||||
|
'sourceName' => 'Boiler',
|
||||||
|
'code' => 'Temp.High',
|
||||||
|
'severity' => 'alarm',
|
||||||
|
'message' => 'Temperatur hoch',
|
||||||
|
]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,35 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'Batterie' => [
|
||||||
|
'file' => __DIR__ . '/modules/Batterie.php',
|
||||||
|
'paths' => [
|
||||||
|
'Batterie/*',
|
||||||
|
'libs/BatterieRegler.php',
|
||||||
|
'libs/VerbraucherBasisTrait.php',
|
||||||
|
'libs/VerbraucherSchnittstelle.php',
|
||||||
|
'libs/Nachrichtenvertrag.php',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'EaseeGateway' => [
|
||||||
|
'file' => __DIR__ . '/modules/EaseeGateway.php',
|
||||||
|
'paths' => [
|
||||||
|
'EaseeGateway/*',
|
||||||
|
'libs/EaseeGatewayProtokoll.php',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'LadestationGateway' => [
|
||||||
|
'file' => __DIR__ . '/modules/LadestationGateway.php',
|
||||||
|
'paths' => [
|
||||||
|
'LadestationGateway/*',
|
||||||
|
'libs/EaseeGatewayProtokoll.php',
|
||||||
|
'libs/EaseeLadestatus.php',
|
||||||
|
'libs/LadestationRegler.php',
|
||||||
|
'libs/VerbraucherBasisTrait.php',
|
||||||
|
'libs/VerbraucherSchnittstelle.php',
|
||||||
|
'libs/Nachrichtenvertrag.php',
|
||||||
|
],
|
||||||
|
],
|
||||||
'LadestationStandAlone' => [
|
'LadestationStandAlone' => [
|
||||||
'file' => __DIR__ . '/modules/LadestationStandAlone.php',
|
'file' => __DIR__ . '/modules/LadestationStandAlone.php',
|
||||||
'paths' => [
|
'paths' => [
|
||||||
@@ -18,6 +47,7 @@ return [
|
|||||||
'file' => __DIR__ . '/modules/Manager.php',
|
'file' => __DIR__ . '/modules/Manager.php',
|
||||||
'paths' => [
|
'paths' => [
|
||||||
'Manager/*',
|
'Manager/*',
|
||||||
|
'libs/EinspeiseRegler.php',
|
||||||
'libs/Lizenzpruefung.php',
|
'libs/Lizenzpruefung.php',
|
||||||
'libs/ManagerRegler.php',
|
'libs/ManagerRegler.php',
|
||||||
'libs/ManagerSchnittstelle.php',
|
'libs/ManagerSchnittstelle.php',
|
||||||
@@ -45,6 +75,16 @@ return [
|
|||||||
'libs/Nachrichtenvertrag.php',
|
'libs/Nachrichtenvertrag.php',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'Waermepumpe' => [
|
||||||
|
'file' => __DIR__ . '/modules/Waermepumpe.php',
|
||||||
|
'paths' => [
|
||||||
|
'Waermepumpe/*',
|
||||||
|
'libs/WaermepumpenRegler.php',
|
||||||
|
'libs/VerbraucherBasisTrait.php',
|
||||||
|
'libs/VerbraucherSchnittstelle.php',
|
||||||
|
'libs/Nachrichtenvertrag.php',
|
||||||
|
],
|
||||||
|
],
|
||||||
'Warmwassererwaermer' => [
|
'Warmwassererwaermer' => [
|
||||||
'file' => __DIR__ . '/modules/Warmwassererwaermer.php',
|
'file' => __DIR__ . '/modules/Warmwassererwaermer.php',
|
||||||
'paths' => [
|
'paths' => [
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Belevo\EnelixEMS\SymconTest\TestContext;
|
||||||
|
|
||||||
|
return static function (TestContext $test): void {
|
||||||
|
$numerischesRegister = static function (
|
||||||
|
TestContext $test,
|
||||||
|
string $name,
|
||||||
|
int $typ = 2
|
||||||
|
): int {
|
||||||
|
$variableID = $test->createVariable(
|
||||||
|
$typ,
|
||||||
|
$name,
|
||||||
|
$typ === 1 ? 0 : 0.0
|
||||||
|
);
|
||||||
|
$skriptID = IPS_CreateScript(0);
|
||||||
|
IPS_SetParent($skriptID, $test->rootId());
|
||||||
|
IPS_SetName($skriptID, $name . ' Aktion');
|
||||||
|
IPS_SetHidden($skriptID, true);
|
||||||
|
IPS_SetScriptContent($skriptID, <<<'PHP'
|
||||||
|
<?php
|
||||||
|
|
||||||
|
SetValue((int) $_IPS['VARIABLE'], $_IPS['VALUE']);
|
||||||
|
PHP
|
||||||
|
);
|
||||||
|
IPS_SetVariableCustomAction($variableID, $skriptID);
|
||||||
|
|
||||||
|
return $variableID;
|
||||||
|
};
|
||||||
|
|
||||||
|
$maxLadenID = $test->createVariable(2, 'Max Laden', 2000.0);
|
||||||
|
$maxEntladenID = $test->createVariable(2, 'Max Entladen', 2000.0);
|
||||||
|
$ladezustandID = $test->createVariable(2, 'Ladezustand', 50.0);
|
||||||
|
$netzleistungID = $test->createVariable(2, 'Netzleistung', 0.0);
|
||||||
|
$istleistungID = $test->createVariable(2, 'Batterieleistung', 0.0);
|
||||||
|
$managementID = $numerischesRegister($test, 'Management', 1);
|
||||||
|
$modusID = $numerischesRegister($test, 'Modus', 1);
|
||||||
|
$leistungID = $numerischesRegister($test, 'Leistung', 2);
|
||||||
|
|
||||||
|
$batterieID = $test->createInstance(
|
||||||
|
'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}',
|
||||||
|
'Batterie GoodWe',
|
||||||
|
[
|
||||||
|
'Batterietyp' => 2,
|
||||||
|
'Batteriemanagement' => 2,
|
||||||
|
'MaxLadeleistungVariableID' => $maxLadenID,
|
||||||
|
'MaxEntladeleistungVariableID' => $maxEntladenID,
|
||||||
|
'LadezustandVariableID' => $ladezustandID,
|
||||||
|
'NetzleistungVariableID' => $netzleistungID,
|
||||||
|
'IstleistungVariableID' => $istleistungID,
|
||||||
|
'ManagementRegisterVariableID' => $managementID,
|
||||||
|
'ModusRegisterVariableID' => $modusID,
|
||||||
|
'LeistungsRegisterVariableID' => $leistungID,
|
||||||
|
'ReserveLadezustand' => 20.0,
|
||||||
|
'MindestLadezustand' => 10.0,
|
||||||
|
'LadezustandHysterese' => 2.0,
|
||||||
|
'MesswertMaxAlter' => 120,
|
||||||
|
'Aenderungssperre' => 0,
|
||||||
|
'DiagnosevariablenAnzeigen' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$managerID = $test->createInstance(
|
||||||
|
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
|
||||||
|
'Hilfsmanager Batterie',
|
||||||
|
[
|
||||||
|
'AutomatischeSuche' => false,
|
||||||
|
'VerbraucherZuordnung' => json_encode([
|
||||||
|
['InstanzID' => $batterieID, 'Aktiv' => true],
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$managerdatenSenden = static function (
|
||||||
|
string $betriebsart,
|
||||||
|
?int $leistung
|
||||||
|
) use ($managerID, $batterieID): void {
|
||||||
|
IPS_RequestAction($batterieID, 'ManagerdatenEmpfangen', json_encode([
|
||||||
|
'Kopf' => [
|
||||||
|
'Version' => '4.0',
|
||||||
|
'AbsenderID' => $managerID,
|
||||||
|
'EmpfaengerID' => $batterieID,
|
||||||
|
'Zeitpunkt' => time(),
|
||||||
|
],
|
||||||
|
'Betriebsart' => $betriebsart,
|
||||||
|
'Sollleistung_W' => $leistung,
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
};
|
||||||
|
|
||||||
|
$test->runCase(
|
||||||
|
'Instanz und Diagnosevariablen werden angelegt',
|
||||||
|
static function (TestContext $test) use ($batterieID): void {
|
||||||
|
$test->assertInstanceStatus($batterieID);
|
||||||
|
foreach ([
|
||||||
|
'Aktiv',
|
||||||
|
'Ladestatus',
|
||||||
|
'Istleistung',
|
||||||
|
'Sollleistung',
|
||||||
|
'Ladezustand',
|
||||||
|
'LadezustandGueltig',
|
||||||
|
'HystereseAktiv',
|
||||||
|
'LeistungsangebotDiagnose',
|
||||||
|
'LetzterRegisterbefehl',
|
||||||
|
] as $ident) {
|
||||||
|
$test->objectByIdent($ident, $batterieID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$test->runCase(
|
||||||
|
'GoodWe-Laden schreibt Leistung Modus und Management direkt',
|
||||||
|
static function (TestContext $test) use (
|
||||||
|
$batterieID,
|
||||||
|
$managerdatenSenden,
|
||||||
|
$managementID,
|
||||||
|
$modusID,
|
||||||
|
$leistungID
|
||||||
|
): void {
|
||||||
|
IPS_RequestAction($batterieID, 'Aktiv', true);
|
||||||
|
$managerdatenSenden('PV', null);
|
||||||
|
$managerdatenSenden('PV', 537);
|
||||||
|
|
||||||
|
$test->waitUntil(
|
||||||
|
static fn (): bool => (float) GetValue($leistungID) === 537.0
|
||||||
|
&& (int) GetValue($modusID) === 11
|
||||||
|
&& (int) GetValue($managementID) === 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$test->runCase(
|
||||||
|
'GoodWe-Entladen verwendet negativen Managerwert und Modus 12',
|
||||||
|
static function (TestContext $test) use (
|
||||||
|
$managerdatenSenden,
|
||||||
|
$modusID,
|
||||||
|
$leistungID
|
||||||
|
): void {
|
||||||
|
$managerdatenSenden('PV', -843);
|
||||||
|
$test->waitUntil(
|
||||||
|
static fn (): bool => (float) GetValue($leistungID) === 843.0
|
||||||
|
&& (int) GetValue($modusID) === 12
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$test->runCase(
|
||||||
|
'Istleistungsereignis aktualisiert den Ladestatus ohne Regelzyklus',
|
||||||
|
static function (TestContext $test) use (
|
||||||
|
$batterieID,
|
||||||
|
$istleistungID
|
||||||
|
): void {
|
||||||
|
SetValue($istleistungID, -250.0);
|
||||||
|
$ladestatusID = $test->objectByIdent('Ladestatus', $batterieID);
|
||||||
|
$test->waitUntil(
|
||||||
|
static fn (): bool => (int) GetValue($ladestatusID) === 3
|
||||||
|
);
|
||||||
|
|
||||||
|
SetValue($istleistungID, 250.0);
|
||||||
|
$test->waitUntil(
|
||||||
|
static fn (): bool => (int) GetValue($ladestatusID) === 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$test->runCase(
|
||||||
|
'Wechselrichtersteuerung setzt Leistung auf null und Automatikmodus',
|
||||||
|
static function (TestContext $test) use (
|
||||||
|
$batterieID,
|
||||||
|
$managementID,
|
||||||
|
$leistungID
|
||||||
|
): void {
|
||||||
|
$test->configureInstance($batterieID, ['Batteriemanagement' => 1]);
|
||||||
|
$test->waitUntil(
|
||||||
|
static fn (): bool => (float) GetValue($leistungID) === 0.0
|
||||||
|
&& (int) GetValue($managementID) === 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Belevo\EnelixEMS\SymconTest\TestContext;
|
||||||
|
|
||||||
|
return static function (TestContext $test): void {
|
||||||
|
$gateway = $test->createInstance(
|
||||||
|
'{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}',
|
||||||
|
'Easee Gateway Test',
|
||||||
|
[
|
||||||
|
'Active' => true,
|
||||||
|
'Testmodus' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$test->runCase('Testgateway ist ohne Zugangsdaten aktiv', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($gateway): void {
|
||||||
|
$test->assertInstanceStatus($gateway);
|
||||||
|
$test->assertSame(
|
||||||
|
true,
|
||||||
|
GetValue($test->objectByIdent('Connected', $gateway))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$test->runCase('Abonnement liefert injizierten API-Zustand', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($gateway): void {
|
||||||
|
ENELIXEASEE_ProcessStationRequest($gateway, json_encode([
|
||||||
|
'action' => 'Subscribe',
|
||||||
|
'serialNumber' => 'EH123456',
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
foreach ([109 => 3, 110 => 30, 120 => 1.0] as $id => $value) {
|
||||||
|
IPS_RequestAction($gateway, 'TestObservation', json_encode([
|
||||||
|
'serialNumber' => 'EH123456',
|
||||||
|
'id' => $id,
|
||||||
|
'value' => $value,
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
$antwort = json_decode(ENELIXEASEE_ProcessStationRequest(
|
||||||
|
$gateway,
|
||||||
|
json_encode([
|
||||||
|
'action' => 'GetState',
|
||||||
|
'serialNumber' => 'EH123456',
|
||||||
|
], JSON_THROW_ON_ERROR)
|
||||||
|
), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$test->assertSame(true, $antwort['success']);
|
||||||
|
$test->assertSame(3, $antwort['state']['109']);
|
||||||
|
$test->assertSame(30, $antwort['state']['110']);
|
||||||
|
$test->assertSame(
|
||||||
|
1,
|
||||||
|
GetValue($test->objectByIdent('SubscriptionCount', $gateway))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$test->runCase('Stromvorgabe wird ueber Gateway angenommen', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($gateway): void {
|
||||||
|
$antwort = json_decode(ENELIXEASEE_ProcessStationRequest(
|
||||||
|
$gateway,
|
||||||
|
json_encode([
|
||||||
|
'action' => 'SetDynamicChargerCurrent',
|
||||||
|
'serialNumber' => 'EH123456',
|
||||||
|
'amps' => 13,
|
||||||
|
], JSON_THROW_ON_ERROR)
|
||||||
|
), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$test->assertSame(true, $antwort['success']);
|
||||||
|
$test->assertSame(200, $antwort['httpCode']);
|
||||||
|
});
|
||||||
|
|
||||||
|
$test->runCase('Gebrochene Stromvorgabe wird abgelehnt', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($gateway): void {
|
||||||
|
$antwort = json_decode(ENELIXEASEE_ProcessStationRequest(
|
||||||
|
$gateway,
|
||||||
|
json_encode([
|
||||||
|
'action' => 'SetDynamicChargerCurrent',
|
||||||
|
'serialNumber' => 'EH123456',
|
||||||
|
'amps' => 6.5,
|
||||||
|
], JSON_THROW_ON_ERROR)
|
||||||
|
), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$test->assertSame(false, $antwort['success']);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Belevo\EnelixEMS\SymconTest\TestContext;
|
||||||
|
|
||||||
|
return static function (TestContext $test): void {
|
||||||
|
$gateway = $test->createInstance(
|
||||||
|
'{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}',
|
||||||
|
'Easee Gateway fuer Ladestation',
|
||||||
|
[
|
||||||
|
'Active' => true,
|
||||||
|
'Testmodus' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$station = $test->createInstance(
|
||||||
|
'{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}',
|
||||||
|
'Easee Ladestation',
|
||||||
|
[
|
||||||
|
'Betriebsmodus' => 0,
|
||||||
|
'Ladestationskennung' => 'EH123456',
|
||||||
|
'MaximalerLadestrom' => 16,
|
||||||
|
'Ladefreigabe' => true,
|
||||||
|
'Solarladen' => true,
|
||||||
|
'EinstellungenInVisu' => true,
|
||||||
|
'DiagnosevariablenAnzeigen' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
IPS_ConnectInstance($station, $gateway);
|
||||||
|
IPS_ApplyChanges($station);
|
||||||
|
|
||||||
|
$nurSolar = $test->createInstance(
|
||||||
|
'{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}',
|
||||||
|
'Easee nur Solarladen',
|
||||||
|
[
|
||||||
|
'Betriebsmodus' => 1,
|
||||||
|
'Ladestationskennung' => 'EH654321',
|
||||||
|
'MaximalerLadestrom' => 16,
|
||||||
|
'Ladefreigabe' => true,
|
||||||
|
'Solarladen' => false,
|
||||||
|
'EinstellungenInVisu' => true,
|
||||||
|
'DiagnosevariablenAnzeigen' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
IPS_ConnectInstance($nurSolar, $gateway);
|
||||||
|
IPS_ApplyChanges($nurSolar);
|
||||||
|
|
||||||
|
$sendeBeobachtung = static function (
|
||||||
|
string $seriennummer,
|
||||||
|
int $id,
|
||||||
|
$value
|
||||||
|
) use ($gateway): void {
|
||||||
|
IPS_RequestAction($gateway, 'TestObservation', json_encode([
|
||||||
|
'serialNumber' => $seriennummer,
|
||||||
|
'id' => $id,
|
||||||
|
'value' => $value,
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (['EH123456', 'EH654321'] as $seriennummer) {
|
||||||
|
$sendeBeobachtung($seriennummer, 47, 16.0);
|
||||||
|
$sendeBeobachtung($seriennummer, 104, 32.0);
|
||||||
|
$sendeBeobachtung($seriennummer, 109, 3);
|
||||||
|
$sendeBeobachtung($seriennummer, 110, 30);
|
||||||
|
$sendeBeobachtung($seriennummer, 120, 1.0);
|
||||||
|
$sendeBeobachtung($seriennummer, 182, 2.1);
|
||||||
|
$sendeBeobachtung($seriennummer, 183, 2.0);
|
||||||
|
$sendeBeobachtung($seriennummer, 184, 2.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$test->runCase('Fahrzeug und drei Phasen folgen direkt den API-Ereignissen', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($station): void {
|
||||||
|
$test->assertSame(
|
||||||
|
true,
|
||||||
|
GetValue($test->objectByIdent('FahrzeugVerbunden', $station))
|
||||||
|
);
|
||||||
|
$test->assertSame(
|
||||||
|
3,
|
||||||
|
GetValue($test->objectByIdent('Phasenzahl', $station))
|
||||||
|
);
|
||||||
|
$test->assertSame(
|
||||||
|
3,
|
||||||
|
GetValue($test->objectByIdent('Fahrzeugstatus', $station))
|
||||||
|
);
|
||||||
|
$test->assertEquals(
|
||||||
|
2.2,
|
||||||
|
(float) GetValue($test->objectByIdent('Ladestrom', $station)),
|
||||||
|
0.01
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$test->runCase('Event erzeugt ohne Polling ein dreiphasiges Leistungsangebot', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($station): void {
|
||||||
|
IPS_RequestAction($station, 'Aktiv', true);
|
||||||
|
$test->assertSame(
|
||||||
|
'[0,4150,4850,5550,6250,6950,7600,8300,9000,9700,10300,11000]',
|
||||||
|
(string) GetValue(
|
||||||
|
$test->objectByIdent('LeistungsangebotDiagnose', $station)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$test->assertTrue(str_contains(
|
||||||
|
(string) GetValue(
|
||||||
|
$test->objectByIdent('LetzterGeraetebefehl', $station)
|
||||||
|
),
|
||||||
|
'"Ampere":0'
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
$test->runCase('Nur-Solar-Variante laesst Solarladen nicht abschalten', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($nurSolar): void {
|
||||||
|
IPS_RequestAction($nurSolar, 'Solarladen', false);
|
||||||
|
$test->assertSame(
|
||||||
|
true,
|
||||||
|
GetValue($test->objectByIdent('Solarladen', $nurSolar))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$test->runCase('Unbekannter API-Status sperrt die Regelung', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($sendeBeobachtung, $station): void {
|
||||||
|
$sendeBeobachtung('EH123456', 109, 99);
|
||||||
|
$test->assertSame(
|
||||||
|
false,
|
||||||
|
GetValue($test->objectByIdent('FahrzeugVerbunden', $station))
|
||||||
|
);
|
||||||
|
$test->assertInstanceStatus($station, 203);
|
||||||
|
|
||||||
|
$sendeBeobachtung('EH123456', 109, 3);
|
||||||
|
$test->assertSame(
|
||||||
|
true,
|
||||||
|
GetValue($test->objectByIdent('FahrzeugVerbunden', $station))
|
||||||
|
);
|
||||||
|
$test->assertInstanceStatus($station);
|
||||||
|
});
|
||||||
|
|
||||||
|
$test->runCase('Gateway-Ausfall verwirft Fahrzeug- und Phasenstatus', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($gateway, $station): void {
|
||||||
|
IPS_SetProperty($gateway, 'Active', false);
|
||||||
|
IPS_ApplyChanges($gateway);
|
||||||
|
|
||||||
|
$test->assertSame(
|
||||||
|
false,
|
||||||
|
GetValue($test->objectByIdent('FahrzeugVerbunden', $station))
|
||||||
|
);
|
||||||
|
$test->assertSame(
|
||||||
|
0,
|
||||||
|
GetValue($test->objectByIdent('Phasenzahl', $station))
|
||||||
|
);
|
||||||
|
$test->assertInstanceStatus($station, 202);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
|||||||
use Belevo\EnelixEMS\SymconTest\TestContext;
|
use Belevo\EnelixEMS\SymconTest\TestContext;
|
||||||
|
|
||||||
return static function (TestContext $test): void {
|
return static function (TestContext $test): void {
|
||||||
$netzleistungId = $test->createVariable(2, 'Netzleistung', 0.0);
|
$netzleistungId = $test->createVariable(2, 'Netzleistung', -800.0);
|
||||||
|
$pvLeistungId = $test->createVariable(2, 'PV-Leistung', 4200.0);
|
||||||
|
$batterieLeistungId = $test->createVariable(2, 'Batterieleistung', 700.0);
|
||||||
|
$batterieSocId = $test->createVariable(2, 'Batterie-Ladezustand', 62.0);
|
||||||
$einKontaktId = $test->createActionVariable('Kontakt Verbraucher 1-Stufig');
|
$einKontaktId = $test->createActionVariable('Kontakt Verbraucher 1-Stufig');
|
||||||
$einStufigId = $test->createInstance(
|
$einStufigId = $test->createInstance(
|
||||||
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
|
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
|
||||||
@@ -72,12 +75,48 @@ return static function (TestContext $test): void {
|
|||||||
['InstanzID' => $warmwasserId, 'Aktiv' => true],
|
['InstanzID' => $warmwasserId, 'Aktiv' => true],
|
||||||
['InstanzID' => $pufferspeicherId, 'Aktiv' => true],
|
['InstanzID' => $pufferspeicherId, 'Aktiv' => true],
|
||||||
];
|
];
|
||||||
|
$wechselrichterKonfiguration = [[
|
||||||
|
'ID' => 'hybrid-1',
|
||||||
|
'Name' => 'Hybridwechselrichter',
|
||||||
|
'Typ' => 'hybrid',
|
||||||
|
'ACNennleistung_kW' => 10.0,
|
||||||
|
'IstleistungVariableID' => $pvLeistungId,
|
||||||
|
'Istleistungsfaktor' => 1.0,
|
||||||
|
]];
|
||||||
|
$pvKonfiguration = [[
|
||||||
|
'ID' => 'pv-1',
|
||||||
|
'Name' => 'Suedflaeche',
|
||||||
|
'DCLeistung_kWp' => 12.0,
|
||||||
|
'Neigung_Grad' => 30.0,
|
||||||
|
'Azimut_Grad' => 0.0,
|
||||||
|
'WechselrichterID' => 'hybrid-1',
|
||||||
|
'MPPT' => 'A',
|
||||||
|
'Modulanzahl' => 30,
|
||||||
|
'Modulleistung_Wp' => 400.0,
|
||||||
|
]];
|
||||||
|
$batterieKonfiguration = [[
|
||||||
|
'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',
|
||||||
|
'LeistungVariableID' => $batterieLeistungId,
|
||||||
|
'Leistungsfaktor' => 1.0,
|
||||||
|
'SOCVariableID' => $batterieSocId,
|
||||||
|
]];
|
||||||
$lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE'));
|
$lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE'));
|
||||||
$managerId = $test->createInstance(
|
$managerId = $test->createInstance(
|
||||||
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
|
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
|
||||||
'Manager',
|
'Manager',
|
||||||
[
|
[
|
||||||
'NetzleistungVariableID' => $netzleistungId,
|
'NetzleistungVariableID' => $netzleistungId,
|
||||||
|
'EnergyPieAnzeigen' => true,
|
||||||
|
'EnergiediagrammeAnzeigen' => true,
|
||||||
|
'FunFactsAnzeigen' => true,
|
||||||
|
'EnergieflussAnzeigen' => true,
|
||||||
'Lizenzcode' => $lizenzcode,
|
'Lizenzcode' => $lizenzcode,
|
||||||
'AutomatischeSuche' => false,
|
'AutomatischeSuche' => false,
|
||||||
'VerbraucherZuordnung' => json_encode($zuordnung, JSON_THROW_ON_ERROR),
|
'VerbraucherZuordnung' => json_encode($zuordnung, JSON_THROW_ON_ERROR),
|
||||||
@@ -87,6 +126,12 @@ return static function (TestContext $test): void {
|
|||||||
'Lastspitzengrenze' => 0.0,
|
'Lastspitzengrenze' => 0.0,
|
||||||
'SollwertSolarladen' => 0.0,
|
'SollwertSolarladen' => 0.0,
|
||||||
'Umschaltdifferenz' => 0.0,
|
'Umschaltdifferenz' => 0.0,
|
||||||
|
'AnlagenWechselrichter' => json_encode(
|
||||||
|
$wechselrichterKonfiguration,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
),
|
||||||
|
'AnlagenPVFlaechen' => json_encode($pvKonfiguration, JSON_THROW_ON_ERROR),
|
||||||
|
'AnlagenBatterien' => json_encode($batterieKonfiguration, JSON_THROW_ON_ERROR),
|
||||||
'DiagnosevariablenAnzeigen' => true,
|
'DiagnosevariablenAnzeigen' => true,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -145,6 +190,114 @@ return static function (TestContext $test): void {
|
|||||||
|
|
||||||
return is_array($daten) ? $daten : [];
|
return is_array($daten) ? $daten : [];
|
||||||
};
|
};
|
||||||
|
$lizenzInstallationID = static function () use ($test, $managerId): string {
|
||||||
|
$formular = IPS_GetConfigurationForm($managerId);
|
||||||
|
if (preg_match(
|
||||||
|
'/Installations-ID: ([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/i',
|
||||||
|
$formular,
|
||||||
|
$treffer
|
||||||
|
) !== 1) {
|
||||||
|
$test->fail('Das Manager-Formular enthaelt keine gueltige Installations-ID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower($treffer[1]);
|
||||||
|
};
|
||||||
|
$test->runCase('Installations-ID bleibt bei ApplyChanges stabil', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($managerId, $lizenzInstallationID): void {
|
||||||
|
$vorher = $lizenzInstallationID();
|
||||||
|
IPS_ApplyChanges($managerId);
|
||||||
|
$test->assertSame($vorher, $lizenzInstallationID());
|
||||||
|
|
||||||
|
$topologie = json_decode(
|
||||||
|
ENELIX_AnlagentopologieExportieren($managerId),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
);
|
||||||
|
$test->assertSame($vorher, $topologie['installationId'] ?? null);
|
||||||
|
$test->assertSame('1.0', $topologie['version'] ?? null);
|
||||||
|
$test->assertSame(12.0, $topologie['summary']['pvDcKwp'] ?? null);
|
||||||
|
$test->assertSame(10.0, $topologie['summary']['pvAcKw'] ?? null);
|
||||||
|
$test->assertSame(12.8, $topologie['summary']['usableBatteryKwh'] ?? null);
|
||||||
|
$test->assertSame('hybrid-1', $topologie['sharedAcLimits'][0]['inverterId'] ?? null);
|
||||||
|
$test->assertSame(['pv-1'], $topologie['sharedAcLimits'][0]['pvArrayIds'] ?? null);
|
||||||
|
$test->assertSame(['bat-1'], $topologie['sharedAcLimits'][0]['batteryIds'] ?? null);
|
||||||
|
});
|
||||||
|
$test->runCase('Energiewerte, Archivregeln und Unterobjekte werden erzeugt', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($managerId): void {
|
||||||
|
IPS_RequestAction($managerId, 'MesswerteAufzeichnen', true);
|
||||||
|
$test->assertSame(
|
||||||
|
4200.0,
|
||||||
|
GetValue($test->objectByIdent('PVLeistungArchiv', $managerId))
|
||||||
|
);
|
||||||
|
$test->assertSame(
|
||||||
|
2700.0,
|
||||||
|
GetValue($test->objectByIdent('HausverbrauchLeistungArchiv', $managerId))
|
||||||
|
);
|
||||||
|
$test->assertSame(
|
||||||
|
-800.0,
|
||||||
|
GetValue($test->objectByIdent('NetzleistungArchiv', $managerId))
|
||||||
|
);
|
||||||
|
$test->assertSame(
|
||||||
|
700.0,
|
||||||
|
GetValue($test->objectByIdent('BatterieleistungArchiv', $managerId))
|
||||||
|
);
|
||||||
|
|
||||||
|
$archive = IPS_GetInstanceListByModuleID(
|
||||||
|
'{43192F0B-135B-4CE7-A0A7-1475603F3060}'
|
||||||
|
);
|
||||||
|
$test->assertTrue($archive !== []);
|
||||||
|
$archivID = (int) $archive[0];
|
||||||
|
$leistungID = $test->objectByIdent('PVLeistungArchiv', $managerId);
|
||||||
|
$energieID = $test->objectByIdent('PVEnergie', $managerId);
|
||||||
|
$test->assertSame(true, AC_GetLoggingStatus($archivID, $leistungID));
|
||||||
|
$test->assertSame(0, AC_GetAggregationType($archivID, $leistungID));
|
||||||
|
$test->assertSame(1, AC_GetAggregationType($archivID, $energieID));
|
||||||
|
|
||||||
|
foreach (['Leistungsdiagramm', 'Energiediagramm'] as $ident) {
|
||||||
|
$medienID = $test->objectByIdent($ident, $managerId);
|
||||||
|
$test->assertSame(4, (int) IPS_GetMedia($medienID)['MediaType']);
|
||||||
|
}
|
||||||
|
$html = (string) GetValue($test->objectByIdent('EnergieFunFacts', $managerId));
|
||||||
|
$test->assertTrue(str_contains($html, 'Solar'));
|
||||||
|
$test->assertTrue(str_contains($html, 'Netz & Haus'));
|
||||||
|
$energieflussID = $test->objectByIdent('Energiefluss', $managerId);
|
||||||
|
$test->assertSame(
|
||||||
|
'{BA90FDB3-C203-8F26-B4D6-F3DB99905724}',
|
||||||
|
IPS_GetInstance($energieflussID)['ModuleInfo']['ModuleID']
|
||||||
|
);
|
||||||
|
|
||||||
|
IPS_RequestAction($managerId, 'FormEnergieflussAnzeigen', false);
|
||||||
|
$test->assertSame(true, (bool) IPS_GetObject($energieflussID)['ObjectIsHidden']);
|
||||||
|
IPS_RequestAction($managerId, 'FormEnergieflussAnzeigen', true);
|
||||||
|
$test->assertSame(false, (bool) IPS_GetObject($energieflussID)['ObjectIsHidden']);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'EnergyPieAnzeigen',
|
||||||
|
'EnergiediagrammeAnzeigen',
|
||||||
|
'FunFactsAnzeigen',
|
||||||
|
'EnergieflussAnzeigen',
|
||||||
|
] as $property) {
|
||||||
|
IPS_SetProperty($managerId, $property, false);
|
||||||
|
}
|
||||||
|
IPS_ApplyChanges($managerId);
|
||||||
|
foreach ([
|
||||||
|
'EnergyPie',
|
||||||
|
'Leistungsdiagramm',
|
||||||
|
'Energiediagramm',
|
||||||
|
'EnergieFunFacts',
|
||||||
|
] as $ident) {
|
||||||
|
$test->assertSame(
|
||||||
|
false,
|
||||||
|
@IPS_GetObjectIDByIdent($ident, $managerId),
|
||||||
|
$ident . ' wurde beim Ausschalten nicht entfernt.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$test->assertSame(true, (bool) IPS_GetObject($energieflussID)['ObjectIsHidden']);
|
||||||
|
});
|
||||||
|
|
||||||
$zustandswert = static function (array $daten, int $instanzId, string $kennung) {
|
$zustandswert = static function (array $daten, int $instanzId, string $kennung) {
|
||||||
foreach ($daten['Verbraucher'] ?? [] as $verbraucher) {
|
foreach ($daten['Verbraucher'] ?? [] as $verbraucher) {
|
||||||
if (($verbraucher['InstanzID'] ?? 0) !== $instanzId) {
|
if (($verbraucher['InstanzID'] ?? 0) !== $instanzId) {
|
||||||
@@ -163,7 +316,7 @@ return static function (TestContext $test): void {
|
|||||||
if ($lizenzcode === '') {
|
if ($lizenzcode === '') {
|
||||||
$test->runCase('Manager bleibt ohne Lizenz sicher gesperrt', static function (
|
$test->runCase('Manager bleibt ohne Lizenz sicher gesperrt', static function (
|
||||||
TestContext $test
|
TestContext $test
|
||||||
) use ($managerId): void {
|
) use ($managerId, $verbraucherstatus): void {
|
||||||
IPS_RequestAction($managerId, 'Aktiv', true);
|
IPS_RequestAction($managerId, 'Aktiv', true);
|
||||||
IPS_RequestAction($managerId, 'Regeln', true);
|
IPS_RequestAction($managerId, 'Regeln', true);
|
||||||
$test->assertInstanceStatus($managerId, 203);
|
$test->assertInstanceStatus($managerId, 203);
|
||||||
@@ -171,6 +324,21 @@ return static function (TestContext $test): void {
|
|||||||
'Inaktiv',
|
'Inaktiv',
|
||||||
GetValue($test->objectByIdent('Betriebsart', $managerId))
|
GetValue($test->objectByIdent('Betriebsart', $managerId))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$daten = $verbraucherstatus();
|
||||||
|
$test->assertSame(false, $daten['Synchronisiert'] ?? null);
|
||||||
|
$test->assertSame(3, count($daten['NichtGeregelt'] ?? []));
|
||||||
|
foreach ($daten['NichtGeregelt'] as $detail) {
|
||||||
|
$test->assertTrue(is_int($detail['InstanzID'] ?? null));
|
||||||
|
$test->assertTrue(is_string($detail['Name'] ?? null));
|
||||||
|
$test->assertTrue(is_string($detail['Typ'] ?? null));
|
||||||
|
$test->assertSame('Lizenz nicht freigegeben', $detail['Grund'] ?? null);
|
||||||
|
}
|
||||||
|
$detailtext = (string) GetValue(
|
||||||
|
$test->objectByIdent('NichtGeregelteGeraete', $managerId)
|
||||||
|
);
|
||||||
|
$test->assertTrue(str_contains($detailtext, 'Boiler'));
|
||||||
|
$test->assertTrue(str_contains($detailtext, 'Lizenz nicht freigegeben'));
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Belevo\EnelixEMS\SymconTest\TestContext;
|
||||||
|
|
||||||
|
return static function (TestContext $test): void {
|
||||||
|
$kontakt1 = $test->createActionVariable('WP Kontakt 1');
|
||||||
|
$kontakt2 = $test->createActionVariable('WP Kontakt 2');
|
||||||
|
$rueckmeldung = $test->createVariable(0, 'WP läuft', false);
|
||||||
|
$waermepumpe = $test->createInstance(
|
||||||
|
'{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}',
|
||||||
|
'Wärmepumpe',
|
||||||
|
[
|
||||||
|
'Kontaktart' => 0,
|
||||||
|
'Kontakt1VariableID' => $kontakt1,
|
||||||
|
'Kontakt2VariableID' => $kontakt2,
|
||||||
|
'Nennleistung' => 5000,
|
||||||
|
'Rueckmeldungsart' => 1,
|
||||||
|
'IstleistungVariableID' => $rueckmeldung,
|
||||||
|
'Anlaufwartezeit' => 1,
|
||||||
|
'Wiederholsperre' => 3,
|
||||||
|
'Mindestlaufzeit' => 5,
|
||||||
|
'Mindestsperrzeit' => 0,
|
||||||
|
'MaximaleSperrzeit' => 2,
|
||||||
|
'MaximaleSperrzeit24h' => 60,
|
||||||
|
'VorgabeTimeout' => 30,
|
||||||
|
'DiagnosevariablenAnzeigen' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$manager = $test->createInstance(
|
||||||
|
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
|
||||||
|
'WP Hilfsmanager',
|
||||||
|
[
|
||||||
|
'AutomatischeSuche' => false,
|
||||||
|
'VerbraucherZuordnung' => json_encode([
|
||||||
|
['InstanzID' => $waermepumpe, 'Aktiv' => true],
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$senden = static function (?int $leistung, string $betriebsart) use (
|
||||||
|
$manager,
|
||||||
|
$waermepumpe
|
||||||
|
): void {
|
||||||
|
IPS_RequestAction(
|
||||||
|
$waermepumpe,
|
||||||
|
'ManagerdatenEmpfangen',
|
||||||
|
json_encode([
|
||||||
|
'Kopf' => [
|
||||||
|
'Version' => '4.0',
|
||||||
|
'AbsenderID' => $manager,
|
||||||
|
'EmpfaengerID' => $waermepumpe,
|
||||||
|
'Zeitpunkt' => time(),
|
||||||
|
],
|
||||||
|
'Betriebsart' => $betriebsart,
|
||||||
|
'Sollleistung_W' => $leistung,
|
||||||
|
], JSON_THROW_ON_ERROR)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
$test->runCase('Instanz und Diagnosevariablen werden angelegt', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($waermepumpe): void {
|
||||||
|
$test->assertInstanceStatus($waermepumpe);
|
||||||
|
foreach ([
|
||||||
|
'Aktiv',
|
||||||
|
'Waermepumpenzustand',
|
||||||
|
'WaermepumpeLaeuft',
|
||||||
|
'SperreAktiv',
|
||||||
|
'ErhoehungAktiv',
|
||||||
|
'SGReadyZustand',
|
||||||
|
'Istleistung',
|
||||||
|
'Sollleistung',
|
||||||
|
'AnlaufAusstehend',
|
||||||
|
'Sperrzeit24h',
|
||||||
|
] as $ident) {
|
||||||
|
$test->objectByIdent($ident, $waermepumpe);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$test->runCase('PV fordert Erhoehung an und bestaetigt den Anlauf', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($waermepumpe, $kontakt1, $kontakt2, $rueckmeldung, $senden): void {
|
||||||
|
IPS_RequestAction($waermepumpe, 'Aktiv', true);
|
||||||
|
$senden(null, 'PV');
|
||||||
|
$senden(5000, 'PV');
|
||||||
|
$test->waitUntil(static fn (): bool => GetValue($kontakt2) === true);
|
||||||
|
$test->assertSame(false, GetValue($kontakt1), 'Kontakt 1 muss im PV-Anlauf aus sein.');
|
||||||
|
$test->assertSame(true, GetValue(
|
||||||
|
$test->objectByIdent('AnlaufAusstehend', $waermepumpe)
|
||||||
|
), 'Der PV-Anlauf muss bis zur Rueckmeldung ausstehend sein.');
|
||||||
|
|
||||||
|
$test->waitUntil(static fn (): bool => GetValue($kontakt2) === false, 2500);
|
||||||
|
$test->waitUntil(static fn (): bool => GetValue(
|
||||||
|
$test->objectByIdent('WiederholsperreAktiv', $waermepumpe)
|
||||||
|
) === true);
|
||||||
|
$test->assertSame(true, GetValue(
|
||||||
|
$test->objectByIdent('WiederholsperreAktiv', $waermepumpe)
|
||||||
|
), 'Nach dem fehlgeschlagenen Anlauf muss die Wiederholsperre aktiv sein.');
|
||||||
|
$test->assertSame(false, GetValue(
|
||||||
|
$test->objectByIdent('AenderungMoeglich', $waermepumpe)
|
||||||
|
), 'Waehrend der Wiederholsperre darf keine Aenderung angeboten werden.');
|
||||||
|
$test->waitUntil(static fn (): bool => GetValue(
|
||||||
|
$test->objectByIdent('WiederholsperreAktiv', $waermepumpe)
|
||||||
|
) === false, 4500);
|
||||||
|
|
||||||
|
$senden(5000, 'PV');
|
||||||
|
$test->waitUntil(static fn (): bool => GetValue($kontakt2) === true);
|
||||||
|
SetValue($rueckmeldung, true);
|
||||||
|
$test->waitUntil(static fn (): bool => GetValue(
|
||||||
|
$test->objectByIdent('WaermepumpeLaeuft', $waermepumpe)
|
||||||
|
) === true);
|
||||||
|
$test->assertSame(5000, (int) GetValue(
|
||||||
|
$test->objectByIdent('Istleistung', $waermepumpe)
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
$test->runCase('Lauf ohne Vorgabe erzwingt keine Erhoehung', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($waermepumpe, $kontakt1, $kontakt2): void {
|
||||||
|
IPS_RequestAction($waermepumpe, 'Aktiv', false);
|
||||||
|
IPS_RequestAction($waermepumpe, 'Aktiv', true);
|
||||||
|
$test->waitUntil(
|
||||||
|
static fn (): bool => GetValue($kontakt1) === false
|
||||||
|
&& GetValue($kontakt2) === false
|
||||||
|
);
|
||||||
|
$test->assertSame(false, GetValue($kontakt1));
|
||||||
|
$test->assertSame(false, GetValue($kontakt2));
|
||||||
|
IPS_Sleep(5100);
|
||||||
|
});
|
||||||
|
|
||||||
|
$test->runCase('Peak sperrt und maximale Sperrzeit erzwingt Normalbetrieb', static function (
|
||||||
|
TestContext $test
|
||||||
|
) use ($waermepumpe, $kontakt1, $kontakt2, $senden): void {
|
||||||
|
$senden(null, 'Peak');
|
||||||
|
$senden(0, 'Peak');
|
||||||
|
$test->waitUntil(static fn (): bool => GetValue($kontakt1) === true);
|
||||||
|
$test->assertSame(false, GetValue($kontakt2));
|
||||||
|
$test->assertSame(true, GetValue(
|
||||||
|
$test->objectByIdent('SperreAktiv', $waermepumpe)
|
||||||
|
));
|
||||||
|
|
||||||
|
$test->waitUntil(static fn (): bool => GetValue($kontakt1) === false, 2500);
|
||||||
|
$test->assertSame(true, GetValue(
|
||||||
|
$test->objectByIdent('SperrerholungAktiv', $waermepumpe)
|
||||||
|
));
|
||||||
|
$test->assertSame(false, GetValue(
|
||||||
|
$test->objectByIdent('SperreAktiv', $waermepumpe)
|
||||||
|
));
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -58,7 +58,16 @@ final class SymconTestContractTest extends TestCase
|
|||||||
public function testAffectedModuleResolverSelectsAllMatches(): void
|
public function testAffectedModuleResolverSelectsAllMatches(): void
|
||||||
{
|
{
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'],
|
[
|
||||||
|
'Batterie',
|
||||||
|
'LadestationGateway',
|
||||||
|
'LadestationStandAlone',
|
||||||
|
'Manager',
|
||||||
|
'Pufferspeicher',
|
||||||
|
'VerbraucherEinStufig',
|
||||||
|
'Waermepumpe',
|
||||||
|
'Warmwassererwaermer',
|
||||||
|
],
|
||||||
$this->resolveAffectedModules(['libs/Nachrichtenvertrag.php'])
|
$this->resolveAffectedModules(['libs/Nachrichtenvertrag.php'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,7 +83,17 @@ final class SymconTestContractTest extends TestCase
|
|||||||
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
|
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
|
||||||
{
|
{
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'],
|
[
|
||||||
|
'Batterie',
|
||||||
|
'EaseeGateway',
|
||||||
|
'LadestationGateway',
|
||||||
|
'LadestationStandAlone',
|
||||||
|
'Manager',
|
||||||
|
'Pufferspeicher',
|
||||||
|
'VerbraucherEinStufig',
|
||||||
|
'Waermepumpe',
|
||||||
|
'Warmwassererwaermer',
|
||||||
|
],
|
||||||
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
|
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixEMS\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class WaermepumpeModulstrukturTest extends TestCase
|
||||||
|
{
|
||||||
|
private const MODULPFAD = __DIR__ . '/../Waermepumpe/';
|
||||||
|
private const MODULID = '{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}';
|
||||||
|
|
||||||
|
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
|
||||||
|
{
|
||||||
|
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
|
||||||
|
self::assertFileExists(self::MODULPFAD . $datei);
|
||||||
|
}
|
||||||
|
$metadaten = json_decode(
|
||||||
|
(string) file_get_contents(self::MODULPFAD . 'module.json'),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('VerbraucherWaermepumpe', $metadaten['name']);
|
||||||
|
self::assertSame('Enelix', $metadaten['vendor']);
|
||||||
|
self::assertSame(['Waermepumpe'], $metadaten['aliases']);
|
||||||
|
self::assertSame(self::MODULID, $metadaten['id']);
|
||||||
|
self::assertSame(3, $metadaten['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllePropertiesSindRegistriertUndImFormularEnthalten(): 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 testVertrag40UndBetriebsartabhaengigesAngebotWerdenVerwendet(): void
|
||||||
|
{
|
||||||
|
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||||
|
|
||||||
|
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
|
||||||
|
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul);
|
||||||
|
self::assertStringContainsString('Nachrichtenvertrag::pruefeManagerdaten', $modul);
|
||||||
|
self::assertStringContainsString('Nachrichtenvertrag::pruefeVerbraucherdaten', $modul);
|
||||||
|
self::assertStringContainsString("['Betriebsart']", $modul);
|
||||||
|
self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $modul);
|
||||||
|
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegelungIstEreignisbasiertUndBegrenztSperrzeiten(): void
|
||||||
|
{
|
||||||
|
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||||
|
|
||||||
|
self::assertStringContainsString('public function MessageSink', $modul);
|
||||||
|
self::assertStringContainsString('RegisterMessage($neueID, self::VM_UPDATE)', $modul);
|
||||||
|
foreach ([
|
||||||
|
'Meldezyklus',
|
||||||
|
'MeldungVerzoegert',
|
||||||
|
'Zustandspruefung',
|
||||||
|
'VorgabeTimeout',
|
||||||
|
] as $timer) {
|
||||||
|
self::assertStringContainsString("'" . $timer . "'", $modul);
|
||||||
|
}
|
||||||
|
foreach ([
|
||||||
|
'Mindestlaufzeit',
|
||||||
|
'Mindestsperrzeit',
|
||||||
|
'MaximaleSperrzeit',
|
||||||
|
'MaximaleSperrzeit24h',
|
||||||
|
'Anlaufwartezeit',
|
||||||
|
'Wiederholsperre',
|
||||||
|
] as $property) {
|
||||||
|
self::assertStringContainsString("'" . $property . "'", $modul);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKontakteWerdenBreakBeforeMakeGeschaltet(): void
|
||||||
|
{
|
||||||
|
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||||
|
$aus = strpos($modul, '$this->setzeKontakt(1, false)');
|
||||||
|
$ein = strpos($modul, '$this->setzeKontakt(1, true)');
|
||||||
|
|
||||||
|
self::assertIsInt($aus);
|
||||||
|
self::assertIsInt($ein);
|
||||||
|
self::assertLessThan($ein, $aus);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFachUndDiagnosevariablenSindVorhanden(): void
|
||||||
|
{
|
||||||
|
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||||
|
foreach ([
|
||||||
|
'Waermepumpenzustand',
|
||||||
|
'WaermepumpeLaeuft',
|
||||||
|
'SperreAktiv',
|
||||||
|
'ErhoehungAktiv',
|
||||||
|
'SGReadyZustand',
|
||||||
|
'Laufzeit',
|
||||||
|
'AnlaufAusstehend',
|
||||||
|
'WiederholsperreAktiv',
|
||||||
|
'SperrerholungAktiv',
|
||||||
|
'Sperrzeit24h',
|
||||||
|
] as $ident) {
|
||||||
|
self::assertStringContainsString("'" . $ident . "'", $modul);
|
||||||
|
}
|
||||||
|
self::assertStringContainsString("ReadPropertyBoolean('DiagnosevariablenAnzeigen')", $modul);
|
||||||
|
self::assertStringContainsString('UnregisterVariable($ident)', $modul);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testManagerKenntDieModulIDGenauEinmal(): void
|
||||||
|
{
|
||||||
|
$manager = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||||
|
|
||||||
|
self::assertSame(1, substr_count($manager, self::MODULID));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDokumentationBeschreibtImplementiertenStand(): void
|
||||||
|
{
|
||||||
|
$dokumentation = (string) file_get_contents(
|
||||||
|
__DIR__ . '/../docs/module/Waermepumpe/README.md'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertStringContainsString('Status: Implementiert', $dokumentation);
|
||||||
|
self::assertStringContainsString('nicht uebernommen', $dokumentation);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixEMS\Tests;
|
||||||
|
|
||||||
|
use Belevo\EnelixEMS\Nachrichtenvertrag;
|
||||||
|
use Belevo\EnelixEMS\WaermepumpenRegler;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class WaermepumpenReglerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testKontaktwerteBildenNormalSperreUndErhoehungAb(): void
|
||||||
|
{
|
||||||
|
self::assertSame(
|
||||||
|
['Kontakt1' => false, 'Kontakt2' => false],
|
||||||
|
WaermepumpenRegler::kontaktwerte(0, WaermepumpenRegler::STEUERZUSTAND_NORMAL)
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
['Kontakt1' => true, 'Kontakt2' => false],
|
||||||
|
WaermepumpenRegler::kontaktwerte(1, WaermepumpenRegler::STEUERZUSTAND_SPERRE)
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
['Kontakt1' => false, 'Kontakt2' => true],
|
||||||
|
WaermepumpenRegler::kontaktwerte(1, WaermepumpenRegler::STEUERZUSTAND_ERHOEHUNG)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLeistungUndBetriebsstatusBestimmenLaufUndIstleistung(): void
|
||||||
|
{
|
||||||
|
self::assertTrue(WaermepumpenRegler::laeuft(650.0, 0, 100.0));
|
||||||
|
self::assertFalse(WaermepumpenRegler::laeuft(50.0, 0, 100.0));
|
||||||
|
self::assertTrue(WaermepumpenRegler::laeuft(true, 1, 100.0));
|
||||||
|
self::assertSame(650.0, WaermepumpenRegler::istleistung(650.0, 0, 5000));
|
||||||
|
self::assertSame(5000.0, WaermepumpenRegler::istleistung(true, 1, 5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPvBietetStartUndErhoehungNurBeiFreigabeAn(): void
|
||||||
|
{
|
||||||
|
self::assertSame(
|
||||||
|
[0, 5000],
|
||||||
|
WaermepumpenRegler::leistungsangebot(
|
||||||
|
5000,
|
||||||
|
Nachrichtenvertrag::BETRIEBSART_PV,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
[0],
|
||||||
|
WaermepumpenRegler::leistungsangebot(
|
||||||
|
5000,
|
||||||
|
Nachrichtenvertrag::BETRIEBSART_PV,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPeakBietetSperreNurBeiLaufenderUndFreigegebenerPumpeAn(): void
|
||||||
|
{
|
||||||
|
self::assertSame(
|
||||||
|
[0, 5000],
|
||||||
|
WaermepumpenRegler::leistungsangebot(
|
||||||
|
5000,
|
||||||
|
Nachrichtenvertrag::BETRIEBSART_PEAK,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
[5000],
|
||||||
|
WaermepumpenRegler::leistungsangebot(
|
||||||
|
5000,
|
||||||
|
Nachrichtenvertrag::BETRIEBSART_PEAK,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
[0],
|
||||||
|
WaermepumpenRegler::leistungsangebot(
|
||||||
|
5000,
|
||||||
|
Nachrichtenvertrag::BETRIEBSART_PEAK,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSperrerholungHaeltDenBestaetigtenBetrieb(): void
|
||||||
|
{
|
||||||
|
self::assertSame(
|
||||||
|
[5000],
|
||||||
|
WaermepumpenRegler::leistungsangebot(
|
||||||
|
5000,
|
||||||
|
Nachrichtenvertrag::BETRIEBSART_PEAK,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRestzeitWirdNieNegativ(): void
|
||||||
|
{
|
||||||
|
self::assertSame(20, WaermepumpenRegler::restzeit(100, 30, 110));
|
||||||
|
self::assertSame(0, WaermepumpenRegler::restzeit(100, 30, 140));
|
||||||
|
self::assertSame(0, WaermepumpenRegler::restzeit(0, 30, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSperrzeit24hSchneidetAlteIntervalleAb(): void
|
||||||
|
{
|
||||||
|
$jetzt = 200000;
|
||||||
|
$historie = [
|
||||||
|
['Von' => $jetzt - 90000, 'Bis' => $jetzt - 80000],
|
||||||
|
['Von' => $jetzt - 1000, 'Bis' => $jetzt - 400],
|
||||||
|
];
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
7400,
|
||||||
|
WaermepumpenRegler::sperrsekunden24h($historie, $jetzt, $jetzt - 400)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUngueltigeKontaktartWirdAbgewiesen(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
WaermepumpenRegler::kontaktwerte(99, WaermepumpenRegler::STEUERZUSTAND_NORMAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user