23 Commits
Author SHA1 Message Date
dh 18c34b32e7 chore(release): erste Beta vorbereiten
Tests / test (push) Successful in 52s
2026-09-29 06:25:26 +00:00
dh b111a5c501 Ladefreigabe für Ladestationen standardmäßig aktivieren
Tests / test (push) Successful in 55s
2026-09-29 06:24:26 +00:00
dh ef708564d8 feat: Wärmepumpenlizenz separat freischalten
Tests / test (push) Successful in 47s
2026-09-29 05:53:15 +00:00
dh cf2ce3aff4 feat(manager): Lizenzierung und Energietopologie verfeinern
Tests / test (push) Successful in 46s
2026-09-28 17:47:22 +00:00
dh 708a8ecea2 feat(manager): Energieaufzeichnung und Visualisierungen integrieren
Tests / test (push) Successful in 47s
2026-09-27 19:29:37 +00:00
dh 40f41a2fb8 fix(manager): Störungszugang zuverlässig aktivieren
Tests / test (push) Successful in 52s
2026-09-27 16:39:15 +00:00
dh 2d24ce0900 feat(manager): Störungsüberwachung ans Portal anbinden
Tests / test (push) Successful in 47s
2026-09-27 15:41:15 +00:00
dh f47c908798 feat(batterie): stufenlose Leistungsbereiche unterstützen
Tests / test (push) Successful in 49s
2026-09-27 08:46:17 +00:00
dh 72d96aeb81 feat: Einspeisebegrenzung in Demo simulieren
Tests / test (push) Successful in 46s
2026-09-26 10:09:10 +00:00
dh dcf51bd8be feat: anlagenweite Einspeisebegrenzung und Netzfahrplan
Tests / test (push) Successful in 50s
2026-09-26 09:09:08 +00:00
dh efa8fe698c feat: Prognosetelemetrie aus Demoanlage senden
Tests / test (push) Successful in 48s
2026-09-23 06:40:17 +00:00
dh f250dea59a feat: Demoanlage an Prognosetopologie anbinden
Tests / test (push) Successful in 49s
2026-09-23 05:46:07 +00:00
dh 1e6481330a feat: Anlagentopologie mit Prognoseportal synchronisieren
Tests / test (push) Successful in 45s
2026-09-23 04:36:59 +00:00
dh dbd728dd03 feat: Anlagentopologie im Manager abbilden 2026-09-22 17:11:12 +00:00
dh a67018e68f fix: Waermepumpe ohne Vorgabe normal betreiben
Tests / test (push) Successful in 45s
2026-09-22 15:13:04 +00:00
dh 0f1e0c9fbb Installations-ID bei Manager-Updates erhalten
Tests / test (push) Successful in 45s
2026-09-22 15:06:31 +00:00
dh 693beb2263 docs: Projektstatus und Einrichtungsentscheidung aktualisieren
Tests / test (push) Successful in 49s
2026-09-22 14:43:36 +00:00
dh b1cb289922 docs: Ladestation Stand-Alone vollständig dokumentieren 2026-09-22 14:43:36 +00:00
dh 98ee569178 feat: Demoanlage um Batterie und Tagesprofile erweitern 2026-09-22 14:43:36 +00:00
dh 2392a9c209 feat: Lizenzkontingente im Manager durchsetzen 2026-09-22 14:43:36 +00:00
dh 7845c66fd8 feat: Wärmepumpenmodul integrieren
Tests / test (push) Successful in 49s
2026-09-22 14:34:23 +00:00
dh d7308663da feat: Batteriemodul ereignisbasiert integrieren
Tests / test (push) Successful in 45s
2026-09-22 09:27:49 +00:00
dh d03a7fd8fb Easee-Gateway und Ladestationsmodul implementieren
Tests / test (push) Successful in 44s
2026-09-22 07:09:59 +00:00
77 changed files with 14124 additions and 434 deletions
+6
View File
@@ -21,6 +21,12 @@ jobs:
- name: Abhaengigkeiten installieren
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
run: composer check
+174
View File
@@ -0,0 +1,174 @@
# Batterie
IP-Symcon-Modul fuer Batteriespeicher im Enelix EMS. Das Modul uebernimmt die
Leistungsangebote und SoC-Logik der bisherigen Enelix-Batterie, arbeitet aber
ereignisbasiert und schreibt Stellwerte direkt in ausgewaehlte
IP-Symcon-Registervariablen.
## Funktionen
- positive Leistung bedeutet Laden, negative Leistung bedeutet Entladen
- stufenlose Leistungsbereiche in ganzen Watt innerhalb der dynamischen Grenzen
- unterschiedliche Leistungsangebote fuer PV- und Peakbetrieb
- Reserve- und Mindestladezustand mit konfigurierbarer Hysterese
- direkte Registeransteuerung fuer herstellerunabhaengige Batterien, GoodWe,
SolarEdge und Sigenergy
- Umschaltung zwischen Wechselrichter- und Enelix-Steuerung
- ereignisbasierte Reaktion auf alle ausgewaehlten Messwerte
- Managerkommunikation ueber den Enelix-Vertrag 4.0
- Sollwert-Timeout, zeitbasierte Aenderungssperre und periodische Vollmeldung
- optionale Diagnosevariablen und Debug-Logging
Es gibt keinen zyklischen Regel- oder Berechnungstimer. Timer werden nur fuer
Vollmeldungen, den Ablauf einer Manager-Vorgabe und das Ende der
Aenderungssperre verwendet.
## Voraussetzungen
- IP-Symcon ab Version 8.0
- ein eingerichteter Enelix Manager
- numerische Variablen fuer Ladezustand, Netzleistung, Batterieleistung sowie
maximale Lade- und Entladeleistung
- numerische Registervariablen mit Standard- oder benutzerdefinierter Aktion
## Vorzeichen und Einheiten
| Wert | Positiv | Negativ | Einheit |
| --- | --- | --- | --- |
| Manager-Sollleistung | Laden | Entladen | W |
| Batterieleistung | Laden | Entladen | W |
| Netzleistung | Netzbezug | Einspeisung | W |
| Ladezustand | - | - | % |
Sigenergy-Leistungsregister werden in kW beschrieben. Alle anderen
Leistungsregister werden in W beschrieben.
## Batterietypen und Register
| Batterietyp | Management | Modus | Leistungsregister |
| --- | --- | --- | --- |
| Herstellerunabhaengig | WR 0, Enelix 1 | Laden 0, Entladen 1 | getrennt Laden/Entladen in W |
| GoodWe | WR 1, Enelix 2 | Laden 11, Entladen 12 | gemeinsam, absoluter Wert in W |
| SolarEdge | WR 1, Enelix 4 | Laden 3, Entladen 4 | getrennt Laden/Entladen in W |
| Sigenergy | WR 0, Enelix 1 | Laden 3, Entladen 6 | getrennt Laden/Entladen in kW |
Bei Wechselrichtersteuerung bietet die Batterie dem Manager nur [0] an. Das
Modul setzt die Leistungsregister auf 0 und schreibt den passenden
Automatikcode in das Managementregister.
Die Konfigurationsmaske zeigt nach Wahl des Batterietyps nur die benoetigten
Registerauswahlen:
- GoodWe: Management, Modus und gemeinsames Leistungsregister
- alle anderen Typen: Management, Modus, Ladeleistung und Entladeleistung
## Messwerte
| Property | Beschreibung |
| --- | --- |
| MaxLadeleistungVariableID | Aktuell zulaessige maximale Ladeleistung in W |
| MaxEntladeleistungVariableID | Aktuell zulaessige maximale Entladeleistung in W |
| LadezustandVariableID | Ladezustand in % |
| NetzleistungVariableID | Netzbezug beziehungsweise Einspeisung in W |
| IstleistungVariableID | Aktuelle Batterieleistung in W |
| MesswertMaxAlter | Maximales Alter jedes Messwertes in Sekunden |
Alle Messwerte muessen als Integer- oder Floatvariable vorliegen. Fehlt ein
Messwert, ist er veraltet oder liegt der Ladezustand ausserhalb von 0 bis
100 %, meldet sich die Batterie als nicht verfuegbar und bietet nur [0] an.
## Ladezustandslogik
ReserveLadezustand entspricht der bisherigen Peakshaving-Reserve.
MindestLadezustand schuetzt vor weiterer Entladung.
LadezustandHysterese ersetzt die bisher fest im Code hinterlegte
2-Prozent-Hysterese.
Die bisherigen betriebsartabhaengigen Angebote bleiben erhalten:
- Im PV-Betrieb steht oberhalb der Reserve der durchgaengige Bereich von der
maximalen Entlade- bis zur maximalen Ladeleistung zur Verfuegung.
- Unterhalb der Reserve wird die maximale Ladeleistung als fester Wert angeboten.
- Bei vollem Speicher wird der durchgaengige Bereich von der maximalen
Entladeleistung bis 0 W angeboten.
- Im Peakbetrieb wird oberhalb der Reserve der aus Netz- und aktueller
Batterieleistung berechnete Entladewert angeboten.
- Innerhalb der Reserve-Hysterese und unterhalb des Mindestladezustands werden
Laden und Entladen wie bisher eingeschraenkt.
## Manager und Zeitverhalten
Das Modul verwendet den gemeinsamen Enelix-Nachrichtenvertrag 4.0.
Betriebsart ist PV oder Peak. Ein Paket mit Sollleistung_W=null
kuendigt nur die Betriebsart an und loest die Neuberechnung des Angebots aus.
Aenderungssperre ist die ereignisbasierte Entsprechung des frueheren
Idle-Counters. Nach einer echten Sollwertaenderung meldet das Modul
AenderungMoeglich=false. Ein einmaliger Timer hebt die Sperre nach Ablauf
der konfigurierten Sekunden wieder auf.
VorgabeTimeout setzt die Register auf 0, wenn der Manager eine Vorgabe
nicht rechtzeitig erneuert. Meldeintervall erzeugt nur eine periodische
Vollmeldung und fuehrt keinen unabhaengigen Regelzyklus aus.
## Sichtbare Variablen
Immer vorhanden:
- Aktiv: lokale EMS-Freigabe
- Ladestatus: 0 unbekannt, 1 ruhend, 2 laden, 3 entladen
Bei aktivierten Diagnosevariablen werden zusaetzlich unter anderem
Istleistung, Sollleistung, Ladezustand, Messwertgueltigkeit, Hysterese,
Leistungsgrenzen, Energie, Leistungsangebot und der letzte Registerbefehl
angezeigt.
LoggingEin aktiviert zusaetzliche Meldungen im IP-Symcon-Debugprotokoll.
Die Schaltflaeche **Messwerte und Angebot aktualisieren** loest eine sofortige
ereignisartige Aktualisierung aus. **Register auf sicheren Zustand setzen**
verwirft die aktuelle Vorgabe und schreibt eine Leistung von 0.
## Inbetriebnahme
1. Batterietyp und Steuerung auswaehlen.
2. Alle fuenf Messwertvariablen zuordnen.
3. Die automatisch eingeblendeten Registervariablen zuordnen.
4. Reserve, Mindestladezustand und Hysterese pruefen.
5. Diagnosevariablen und Logging fuer die Erstinbetriebnahme aktivieren.
6. Die Batterie im Manager zuordnen.
7. Aktiv einschalten.
8. Unter Aufsicht je einen Lade-, Entlade- und Nullsollwert senden.
9. Registercodes, Vorzeichen, Einheiten und reale Wechselrichterreaktion
kontrollieren.
10. Abschliessend die Wechselrichtersteuerung waehlen und pruefen, dass alle
Leistungsregister auf 0 sowie das Managementregister auf Automatik gehen.
## Fehlerbilder
- **Konfiguration ungueltig:** Variablentypen, Aktionszuordnung und die fuer
den Batterietyp erforderlichen Register pruefen.
- **Messwerte fehlen oder sind veraltet:** Variablenaktualisierung und
MesswertMaxAlter kontrollieren.
- **Register konnten nicht geschrieben werden:** Standard- oder
benutzerdefinierte Aktion der Zielvariablen sowie die Geraetekommunikation
pruefen.
- **Sollwert wird abgewiesen:** Der Wert muss im zuletzt gemeldeten Angebot
enthalten und die Aenderungssperre muss abgelaufen sein.
## Tests
~~~bash
composer test -- --filter 'Batterie(Regler|Modulstruktur)Test'
composer symcon:single -- Batterie
composer check
~~~
Der Symcon-Test prueft die Modulinstanz, direkte GoodWe-Registerbefehle,
Laden und Entladen, Messwertereignisse sowie die Rueckgabe an die
Wechselrichtersteuerung.
## Weiterfuehrende Dokumentation
- [Ausfuehrliche Modulbeschreibung](../docs/module/Batterie/README.md)
- [Manager-Verbraucher-Schnittstelle](../docs/Schnittstelle.md)
- [Migration von Enelix 1](../docs/migration/Batterie.md)
+229
View File
@@ -0,0 +1,229 @@
{
"elements": [
{
"type": "ExpansionPanel",
"caption": "Manager und Zeitverhalten",
"expanded": true,
"items": [
{
"type": "NumberSpinner",
"name": "PrioritaetPV",
"caption": "Prioritaet PV",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "PrioritaetPeak",
"caption": "Prioritaet Peak",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Meldeintervall",
"caption": "Meldeintervall",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "VorgabeTimeout",
"caption": "Vorgabe-Timeout",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "Aenderungssperre",
"caption": "Sperrzeit nach Leistungsaenderung",
"suffix": " s",
"minimum": 0
}
]
},
{
"type": "ExpansionPanel",
"caption": "Batterie und Messwerte",
"expanded": true,
"items": [
{
"type": "Select",
"name": "Batterietyp",
"caption": "Batterietyp",
"onChange": "IPS_RequestAction($id, 'FormBatterietyp', $Batterietyp);",
"options": [
{"caption": "Unkonfiguriert", "value": 0},
{"caption": "Herstellerunabhaengig", "value": 1},
{"caption": "GoodWe", "value": 2},
{"caption": "SolarEdge", "value": 3},
{"caption": "Sigenergy", "value": 4}
]
},
{
"type": "Select",
"name": "Batteriemanagement",
"caption": "Steuerung der Batterie",
"options": [
{"caption": "Wechselrichter", "value": 1},
{"caption": "Enelix Manager", "value": 2}
]
},
{
"type": "SelectVariable",
"name": "MaxLadeleistungVariableID",
"caption": "Maximale Ladeleistung",
"validVariableTypes": [1, 2]
},
{
"type": "SelectVariable",
"name": "MaxEntladeleistungVariableID",
"caption": "Maximale Entladeleistung",
"validVariableTypes": [1, 2]
},
{
"type": "SelectVariable",
"name": "LadezustandVariableID",
"caption": "Ladezustand",
"validVariableTypes": [1, 2]
},
{
"type": "SelectVariable",
"name": "NetzleistungVariableID",
"caption": "Netzleistung",
"validVariableTypes": [1, 2]
},
{
"type": "SelectVariable",
"name": "IstleistungVariableID",
"caption": "Aktuelle Batterieleistung",
"validVariableTypes": [1, 2]
},
{
"type": "NumberSpinner",
"name": "MesswertMaxAlter",
"caption": "Maximales Messwertalter",
"suffix": " s",
"minimum": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Ladezustandsgrenzen",
"expanded": true,
"items": [
{
"type": "NumberSpinner",
"name": "ReserveLadezustand",
"caption": "Reserve fuer Peakshaving",
"suffix": " %",
"minimum": 0,
"maximum": 100,
"digits": 1
},
{
"type": "NumberSpinner",
"name": "MindestLadezustand",
"caption": "Minimaler Ladezustand",
"suffix": " %",
"minimum": 0,
"maximum": 100,
"digits": 1
},
{
"type": "NumberSpinner",
"name": "LadezustandHysterese",
"caption": "Reserve-Hysterese",
"suffix": " %",
"minimum": 0,
"maximum": 100,
"digits": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Batterieregister",
"expanded": true,
"items": [
{
"type": "Label",
"caption": "Es werden nur die zum gewaehlten Batterietyp passenden Register angezeigt."
},
{
"type": "SelectVariable",
"name": "ManagementRegisterVariableID",
"caption": "Managementregister",
"validVariableTypes": [1, 2],
"visible": false
},
{
"type": "SelectVariable",
"name": "ModusRegisterVariableID",
"caption": "Lade-/Entlademodus",
"validVariableTypes": [1, 2],
"visible": false
},
{
"type": "SelectVariable",
"name": "LeistungsRegisterVariableID",
"caption": "Leistungsregister",
"validVariableTypes": [1, 2],
"visible": false
},
{
"type": "SelectVariable",
"name": "LadeleistungRegisterVariableID",
"caption": "Ladeleistungsregister",
"validVariableTypes": [1, 2],
"visible": false
},
{
"type": "SelectVariable",
"name": "EntladeleistungRegisterVariableID",
"caption": "Entladeleistungsregister",
"validVariableTypes": [1, 2],
"visible": false
}
]
},
{
"type": "ExpansionPanel",
"caption": "Darstellung und Diagnose",
"items": [
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Einstellungen in der Visualisierung anzeigen"
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Diagnoseprotokoll aktivieren"
}
]
}
],
"actions": [
{
"type": "Button",
"caption": "Messwerte und Angebot aktualisieren",
"onClick": "IPS_RequestAction($id, 'Aktualisieren', true);"
},
{
"type": "Button",
"caption": "Register auf sicheren Zustand setzen",
"onClick": "IPS_RequestAction($id, 'SichererZustand', true);"
}
],
"status": [
{"code": 102, "icon": "active", "caption": "Aktiv"},
{"code": 201, "icon": "error", "caption": "Konfiguration ungueltig"},
{"code": 202, "icon": "inactive", "caption": "Messwerte fehlen oder sind veraltet"},
{"code": 203, "icon": "error", "caption": "Batterieregister konnten nicht geschrieben werden"}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}",
"name": "Batterie",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Batteriespeicher"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Batterie"
}
+1176
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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.
+11
View File
@@ -0,0 +1,11 @@
# Easee Gateway
Gemeinsames IP-Symcon-Splittermodul fuer ein Easee-Nutzerkonto. Es verwaltet
Anmeldung, Token, Ereignisverbindung und REST-Stromvorgaben fuer mehrere
Ladestationen.
Konfiguration, Betrieb und Fehlerbehandlung:
[Moduldokumentation](../docs/module/Easee-Gateway/README.md)
Kind-Gateway-Vertrag:
[Schnittstellenbeschreibung](../docs/Schnittstelle-Easee-Gateway.md)
+52
View File
@@ -0,0 +1,52 @@
{
"elements": [
{
"type": "CheckBox",
"name": "Active",
"caption": "Gateway aktiv"
},
{
"type": "ValidationTextBox",
"name": "Username",
"caption": "Easee Benutzername"
},
{
"type": "PasswordTextBox",
"name": "Password",
"caption": "Easee Passwort"
},
{
"type": "CheckBox",
"name": "VerifyCertificate",
"caption": "TLS-Zertifikat pruefen"
},
{
"type": "Label",
"caption": "Ein Gateway wird von allen Easee-Ladestationen desselben Kontos gemeinsam verwendet."
}
],
"actions": [
{
"type": "Button",
"caption": "Verbindung neu aufbauen",
"onClick": "IPS_RequestAction($id, \"Reconnect\", false);"
}
],
"status": [
{
"code": 201,
"icon": "error",
"caption": "Easee-Zugangsdaten fehlen"
},
{
"code": 202,
"icon": "error",
"caption": "Easee-Anmeldung fehlgeschlagen"
},
{
"code": 203,
"icon": "error",
"caption": "Easee-Ereignisverbindung fehlgeschlagen"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"id": "{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}",
"name": "EaseeGateway",
"type": 2,
"vendor": "Enelix",
"aliases": [
"Easee Gateway"
],
"parentRequirements": [
"{79827379-F36E-4ADA-8A95-5F8D1DC92FA9}"
],
"childRequirements": [
"{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}"
],
"implemented": [
"{018EF6B5-AB94-40C6-AA53-46943E824ACF}",
"{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}"
],
"prefix": "ENELIXEASEE",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/EaseeGateway"
}
+791
View File
@@ -0,0 +1,791 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/EaseeGatewayProtokoll.php';
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
class EaseeGateway extends IPSModule
{
private const WEBSOCKET_MODULE_ID = '{D68FD31F-0E90-7019-F16C-1949BD3079EF}';
private const SIMPLE_RX_DATA_ID = '{018EF6B5-AB94-40C6-AA53-46943E824ACF}';
private const SIMPLE_TX_DATA_ID = '{79827379-F36E-4ADA-8A95-5F8D1DC92FA9}';
private const CHILD_REQUEST_DATA_ID = '{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}';
private const CHILD_EVENT_DATA_ID = '{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}';
private const SIGNALR_BASE_URL = 'https://streams.easee.com/hubs/chargers';
private const API_BASE_URL = 'https://api.easee.com';
private const RECORD_SEPARATOR = "\x1e";
public function Create(): void
{
parent::Create();
$this->RegisterPropertyBoolean('Active', true);
$this->RegisterPropertyString('Username', '');
$this->RegisterPropertyString('Password', '');
$this->RegisterPropertyBoolean('VerifyCertificate', true);
// Nur fuer den automatisierten IP-Symcon-Funktionstest.
$this->RegisterPropertyBoolean('Testmodus', false);
$this->RegisterAttributeString('ObservationCache', '{}');
$this->RegisterVariableBoolean('Connected', 'Easee verbunden', '~Switch', 10);
$this->RegisterVariableInteger(
'SubscriptionCount',
'Angemeldete Ladestationen',
'',
20
);
$this->RegisterVariableString('LastError', 'Letzter Fehler', '', 30);
$this->RegisterTimer(
'MaintainConnectionTimer',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'MaintainConnection', false);"
);
$this->RegisterTimer(
'TokenRefreshTimer',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'RefreshToken', false);"
);
$this->RequireParent(self::WEBSOCKET_MODULE_ID);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->SetTimerInterval('MaintainConnectionTimer', 0);
$this->SetTimerInterval('TokenRefreshTimer', 0);
$this->SetBuffer('SignalRReady', '0');
$this->SetBuffer('ReceiveBuffer', '');
$this->SetBuffer('Subscriptions', '{}');
$this->SetBuffer('SubscribedThisConnection', '{}');
$this->SetBuffer('PendingInvocations', '{}');
$this->SetValue('SubscriptionCount', 0);
$this->setzeVerbunden(false);
if (!$this->ReadPropertyBoolean('Active')) {
$this->SetStatus(104);
return;
}
if ($this->ReadPropertyBoolean('Testmodus')) {
$this->SetStatus(102);
$this->setzeLetztenFehler('');
$this->setzeVerbunden(true);
return;
}
if (
trim($this->ReadPropertyString('Username')) === ''
|| $this->ReadPropertyString('Password') === ''
) {
$this->SetStatus(201);
$this->setzeLetztenFehler('Easee-Benutzername oder Passwort fehlt.');
return;
}
if (!$this->stelleWebSocketParentSicher()) {
$this->SetStatus(203);
return;
}
$this->SetTimerInterval('MaintainConnectionTimer', 10000);
$this->SetTimerInterval('TokenRefreshTimer', 1800000);
if (!$this->erneuereZugangsdaten(false)) {
$this->SetStatus(202);
return;
}
$this->verbindeSignalR();
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'MaintainConnection':
$this->pflegeVerbindung();
return;
case 'RefreshToken':
if ($this->erneuereZugangsdaten(false)) {
$this->verbindeSignalR();
}
return;
case 'Reconnect':
$this->SetBuffer('AccessToken', '');
$this->SetBuffer('RefreshToken', '');
if (
$this->ReadPropertyBoolean('Testmodus')
|| $this->erneuereZugangsdaten(false)
) {
$this->verbindeSignalR();
}
return;
case 'TestObservation':
if (!$this->ReadPropertyBoolean('Testmodus') || !is_string($wert)) {
throw new InvalidArgumentException('TestObservation ist nur im Testmodus zulaessig.');
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException('TestObservation erwartet ein JSON-Objekt.');
}
$this->veroeffentlicheBeobachtung(
EaseeGatewayProtokoll::seriennummer((string) ($daten['serialNumber'] ?? '')),
(int) ($daten['id'] ?? 0),
$daten['value'] ?? null
);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
public function GetConfigurationForParent(): string
{
return json_encode([
'Active' => $this->ReadPropertyBoolean('Active')
&& !$this->ReadPropertyBoolean('Testmodus'),
'URL' => $this->GetBuffer('WebSocketURL'),
'VerifyCertificate' => $this->ReadPropertyBoolean('VerifyCertificate'),
'Headers' => '[]',
], JSON_THROW_ON_ERROR);
}
public function ReceiveData($jsonString): void
{
$paket = json_decode((string) $jsonString, true);
if (!is_array($paket) || !isset($paket['Buffer'])) {
return;
}
$this->SetBuffer('LastReceive', (string) time());
$puffer = $this->GetBuffer('ReceiveBuffer') . (string) $paket['Buffer'];
$rahmen = explode(self::RECORD_SEPARATOR, $puffer);
$this->SetBuffer('ReceiveBuffer', (string) array_pop($rahmen));
foreach ($rahmen as $eintrag) {
if ($eintrag !== '') {
$this->verarbeiteSignalRRahmen($eintrag);
}
}
}
public function ForwardData($jsonString): string
{
$paket = json_decode((string) $jsonString, true);
if (!is_array($paket) || !isset($paket['Buffer'])) {
return json_encode([
'success' => false,
'error' => 'Ungueltiges Datenpaket.',
], JSON_THROW_ON_ERROR);
}
return $this->ProcessStationRequest((string) $paket['Buffer']);
}
public function ProcessStationRequest($jsonString): string
{
$anfrage = json_decode((string) $jsonString, true);
if (!is_array($anfrage) || !isset($anfrage['action'])) {
return json_encode([
'success' => false,
'error' => 'Ungueltige Gateway-Anfrage.',
], JSON_THROW_ON_ERROR);
}
$seriennummer = EaseeGatewayProtokoll::seriennummer(
(string) ($anfrage['serialNumber'] ?? '')
);
switch ($anfrage['action']) {
case 'Subscribe':
case 'GetState':
if ($seriennummer === '') {
return json_encode([
'success' => false,
'error' => 'Seriennummer fehlt.',
], JSON_THROW_ON_ERROR);
}
$this->registriereAbonnement($seriennummer);
return $this->erstelleStatusantwort($seriennummer);
case 'SetDynamicChargerCurrent':
return json_encode($this->setzeDynamischenLadestrom(
$seriennummer,
(float) ($anfrage['amps'] ?? -1)
), JSON_THROW_ON_ERROR);
}
return json_encode([
'success' => false,
'error' => 'Unbekannte Gateway-Aktion.',
], JSON_THROW_ON_ERROR);
}
private function stelleWebSocketParentSicher(): bool
{
$instanz = IPS_GetInstance($this->InstanceID);
$parentID = (int) $instanz['ConnectionID'];
if ($parentID <= 0 && !$this->RequireParent(self::WEBSOCKET_MODULE_ID)) {
$this->setzeLetztenFehler('WebSocket-Client konnte nicht erstellt werden.');
return false;
}
$instanz = IPS_GetInstance($this->InstanceID);
$parentID = (int) $instanz['ConnectionID'];
if ($parentID <= 0 || !IPS_InstanceExists($parentID)) {
$this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.');
return false;
}
$parent = IPS_GetInstance($parentID);
if ($parent['ModuleInfo']['ModuleID'] !== self::WEBSOCKET_MODULE_ID) {
$this->setzeLetztenFehler('Ungueltige WebSocket-Schnittstelle.');
return false;
}
return true;
}
private function pflegeVerbindung(): void
{
if (!$this->ReadPropertyBoolean('Active') || $this->ReadPropertyBoolean('Testmodus')) {
return;
}
$jetzt = time();
$letzteAushandlung = (int) $this->GetBuffer('LastNegotiation');
$letzterEmpfang = (int) $this->GetBuffer('LastReceive');
$bereit = $this->GetBuffer('SignalRReady') === '1';
if ($bereit) {
$this->sendeSignalRRahmen(['type' => 6]);
if ($letzterEmpfang > 0 && ($jetzt - $letzterEmpfang) <= 90) {
return;
}
$this->SetBuffer('SignalRReady', '0');
$this->setzeVerbunden(false);
}
if (($jetzt - $letzteAushandlung) >= 45) {
$this->verbindeSignalR();
return;
}
$this->sendeHandshake();
}
private function verbindeSignalR(): bool
{
if ($this->ReadPropertyBoolean('Testmodus')) {
$this->SetStatus(102);
$this->setzeVerbunden(true);
return true;
}
if (!$this->stelleAccessTokenSicher()) {
$this->SetStatus(202);
return false;
}
$this->SetBuffer('LastNegotiation', (string) time());
$token = $this->GetBuffer('AccessToken');
$antwort = $this->httpAnfrage(
'POST',
self::SIGNALR_BASE_URL . '/negotiate?negotiateVersion=1',
'',
$token
);
if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) {
$token = $this->GetBuffer('AccessToken');
$antwort = $this->httpAnfrage(
'POST',
self::SIGNALR_BASE_URL . '/negotiate?negotiateVersion=1',
'',
$token
);
}
if (!$antwort['success']) {
$this->SetStatus(203);
$this->setzeLetztenFehler(
'Easee-Ereignisverbindung fehlgeschlagen: HTTP '
. $antwort['httpCode']
. ($antwort['error'] !== '' ? ' / ' . $antwort['error'] : '')
);
return false;
}
$daten = json_decode($antwort['body'], true);
$verbindungstoken = is_array($daten)
? (string) ($daten['connectionToken'] ?? $daten['connectionId'] ?? '')
: '';
if ($verbindungstoken === '') {
$this->SetStatus(203);
$this->setzeLetztenFehler('Easee liefert kein Verbindungstoken.');
return false;
}
$url = 'wss://streams.easee.com/hubs/chargers?id='
. rawurlencode($verbindungstoken)
. '&access_token=' . rawurlencode($token);
$this->SetBuffer('WebSocketURL', $url);
$this->SetBuffer('SignalRReady', '0');
$this->SetBuffer('ReceiveBuffer', '');
$this->SetBuffer('SubscribedThisConnection', '{}');
$this->SetBuffer('PendingInvocations', '{}');
$this->WriteAttributeString('ObservationCache', '{}');
$this->setzeVerbunden(false);
if (!$this->konfiguriereWebSocketParent($url)) {
$this->SetStatus(203);
return false;
}
$this->sendeHandshake();
return true;
}
private function konfiguriereWebSocketParent(string $url): bool
{
$parentID = (int) IPS_GetInstance($this->InstanceID)['ConnectionID'];
if ($parentID <= 0) {
$this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.');
return false;
}
IPS_SetProperty($parentID, 'Active', true);
IPS_SetProperty($parentID, 'URL', $url);
IPS_SetProperty(
$parentID,
'VerifyCertificate',
$this->ReadPropertyBoolean('VerifyCertificate')
);
IPS_SetProperty($parentID, 'Headers', '[]');
IPS_ApplyChanges($parentID);
return true;
}
private function sendeHandshake(): void
{
$this->sendeRohdaten(
json_encode(['protocol' => 'json', 'version' => 1], JSON_THROW_ON_ERROR)
. self::RECORD_SEPARATOR
);
}
/** @param array<string, mixed> $rahmen */
private function sendeSignalRRahmen(array $rahmen): void
{
$this->sendeRohdaten(
json_encode($rahmen, JSON_THROW_ON_ERROR) . self::RECORD_SEPARATOR
);
}
private function sendeRohdaten(string $nutzdaten): void
{
if ($this->ReadPropertyBoolean('Testmodus')) {
return;
}
$this->SendDataToParent(json_encode([
'DataID' => self::SIMPLE_TX_DATA_ID,
'Buffer' => $nutzdaten,
], JSON_THROW_ON_ERROR));
}
private function verarbeiteSignalRRahmen(string $rahmen): void
{
if ($rahmen === '{}') {
$this->SetBuffer('SignalRReady', '1');
$this->SetStatus(102);
$this->setzeLetztenFehler('');
$this->setzeVerbunden(true);
$this->sendeAlleAbonnements();
return;
}
$nachricht = json_decode($rahmen, true);
if (!is_array($nachricht)) {
$this->setzeLetztenFehler('Ungueltige Easee-Ereignisnachricht.');
return;
}
if (isset($nachricht['error'])) {
$this->SetStatus(203);
$this->setzeLetztenFehler('Easee-Ereignisfehler: ' . $nachricht['error']);
return;
}
$typ = (int) ($nachricht['type'] ?? 0);
if ($typ === 6) {
return;
}
if ($typ === 7) {
$this->SetBuffer('SignalRReady', '0');
$this->setzeVerbunden(false);
$this->setzeLetztenFehler('Easee-Ereignisverbindung wurde beendet.');
return;
}
$seriennummer = '';
if ($typ === 3 && isset($nachricht['invocationId'])) {
$offen = $this->lesePufferArray('PendingInvocations');
$aufrufID = (string) $nachricht['invocationId'];
$seriennummer = (string) ($offen[$aufrufID] ?? '');
unset($offen[$aufrufID]);
$this->SetBuffer(
'PendingInvocations',
json_encode($offen, JSON_THROW_ON_ERROR)
);
}
foreach (['arguments', 'result'] as $feld) {
if (!isset($nachricht[$feld])) {
continue;
}
$abonnements = array_keys($this->lesePufferArray('Subscriptions'));
foreach (EaseeGatewayProtokoll::extrahiereBeobachtungen(
$nachricht[$feld],
$abonnements,
$seriennummer
) as $beobachtung) {
$this->veroeffentlicheBeobachtung(
$beobachtung['Seriennummer'],
$beobachtung['ID'],
$beobachtung['Wert']
);
}
}
}
private function registriereAbonnement(string $seriennummer): void
{
$abonnements = $this->lesePufferArray('Subscriptions');
if (!isset($abonnements[$seriennummer])) {
$abonnements[$seriennummer] = true;
$this->SetBuffer(
'Subscriptions',
json_encode($abonnements, JSON_THROW_ON_ERROR)
);
$this->SetValue('SubscriptionCount', count($abonnements));
}
if ($this->GetBuffer('SignalRReady') === '1') {
$this->sendeAbonnement($seriennummer);
}
}
private function sendeAlleAbonnements(): void
{
foreach (array_keys($this->lesePufferArray('Subscriptions')) as $seriennummer) {
$this->sendeAbonnement((string) $seriennummer);
}
}
private function sendeAbonnement(string $seriennummer): void
{
$gesendet = $this->lesePufferArray('SubscribedThisConnection');
if (isset($gesendet[$seriennummer])) {
return;
}
$aufrufID = (string) (((int) $this->GetBuffer('InvocationID')) + 1);
$this->SetBuffer('InvocationID', $aufrufID);
$offen = $this->lesePufferArray('PendingInvocations');
$offen[$aufrufID] = $seriennummer;
$this->SetBuffer(
'PendingInvocations',
json_encode($offen, JSON_THROW_ON_ERROR)
);
$this->sendeSignalRRahmen([
'type' => 1,
'invocationId' => $aufrufID,
'target' => 'SubscribeWithCurrentState',
'arguments' => [$seriennummer, true],
]);
$gesendet[$seriennummer] = true;
$this->SetBuffer(
'SubscribedThisConnection',
json_encode($gesendet, JSON_THROW_ON_ERROR)
);
}
private function veroeffentlicheBeobachtung(
string $seriennummer,
int $id,
$wert
): void {
if (
$seriennummer === ''
|| !in_array($id, EaseeGatewayProtokoll::BEOBACHTUNGEN, true)
) {
return;
}
$cache = $this->leseAttributArray('ObservationCache');
if (!isset($cache[$seriennummer]) || !is_array($cache[$seriennummer])) {
$cache[$seriennummer] = [];
}
if ($id === 109 && (int) $wert === 1) {
foreach ([110, 120, 182, 183, 184, 185] as $sessionID) {
$cache[$seriennummer][(string) $sessionID] = 0;
}
}
$cache[$seriennummer][(string) $id] = $wert;
$cache[$seriennummer]['updated'] = time();
$this->WriteAttributeString(
'ObservationCache',
json_encode($cache, JSON_THROW_ON_ERROR)
);
$this->SendDataToChildren(json_encode([
'DataID' => self::CHILD_EVENT_DATA_ID,
'Buffer' => json_encode([
'type' => 'Observation',
'serialNumber' => $seriennummer,
'id' => $id,
'value' => $wert,
'timestamp' => time(),
], JSON_THROW_ON_ERROR),
], JSON_THROW_ON_ERROR));
}
private function erstelleStatusantwort(string $seriennummer): string
{
$cache = $this->leseAttributArray('ObservationCache');
return json_encode([
'success' => true,
'connected' => $this->GetBuffer('SignalRReady') === '1'
|| $this->ReadPropertyBoolean('Testmodus'),
'state' => $cache[$seriennummer] ?? [],
], JSON_THROW_ON_ERROR);
}
/** @return array<string, mixed> */
private function setzeDynamischenLadestrom(string $seriennummer, float $ampere): array
{
if ($seriennummer === '') {
return ['success' => false, 'error' => 'Seriennummer fehlt.'];
}
if (
!is_finite($ampere)
|| floor($ampere) !== $ampere
|| $ampere < 0
|| $ampere > 32
|| ($ampere > 0 && $ampere < 6)
) {
return [
'success' => false,
'error' => 'Strom muss 0 A oder eine ganze Zahl zwischen 6 und 32 A sein.',
];
}
if ($this->ReadPropertyBoolean('Testmodus')) {
$this->SetBuffer('LetzterTestbefehl', json_encode([
'serialNumber' => $seriennummer,
'amps' => $ampere,
], JSON_THROW_ON_ERROR));
return ['success' => true, 'httpCode' => 200, 'body' => '{}'];
}
return $this->autorisierteApiAnfrage(
'POST',
'/api/chargers/' . rawurlencode($seriennummer)
. '/commands/set_dynamic_charger_current',
json_encode([
'amps' => (int) round($ampere),
'minutes' => 0,
], JSON_THROW_ON_ERROR)
);
}
/** @return array<string, mixed> */
private function autorisierteApiAnfrage(
string $methode,
string $pfad,
string $inhalt
): array {
if (!$this->stelleAccessTokenSicher()) {
return ['success' => false, 'error' => 'Kein Easee-Access-Token.'];
}
$antwort = $this->httpAnfrage(
$methode,
self::API_BASE_URL . $pfad,
$inhalt,
$this->GetBuffer('AccessToken')
);
if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) {
$antwort = $this->httpAnfrage(
$methode,
self::API_BASE_URL . $pfad,
$inhalt,
$this->GetBuffer('AccessToken')
);
}
if (!$antwort['success']) {
return [
'success' => false,
'error' => $antwort['error'] !== ''
? $antwort['error']
: 'Easee-HTTP-Fehler ' . $antwort['httpCode'] . '.',
'httpCode' => $antwort['httpCode'],
];
}
return [
'success' => true,
'httpCode' => $antwort['httpCode'],
'body' => $antwort['body'],
];
}
private function erneuereZugangsdaten(bool $neuVerbinden = true): bool
{
$tokenpaar = null;
$refreshToken = $this->GetBuffer('RefreshToken');
if ($refreshToken !== '') {
$tokenpaar = $this->fordereTokenpaarAn(
self::API_BASE_URL . '/api/accounts/refresh_token',
['refreshToken' => $refreshToken]
);
}
if ($tokenpaar === null) {
$tokenpaar = $this->fordereTokenpaarAn(
self::API_BASE_URL . '/api/accounts/login',
[
'userName' => $this->ReadPropertyString('Username'),
'password' => $this->ReadPropertyString('Password'),
]
);
}
if ($tokenpaar === null || !isset($tokenpaar['accessToken'])) {
$this->setzeLetztenFehler('Easee-Anmeldung oder Token-Erneuerung fehlgeschlagen.');
$this->SetStatus(202);
return false;
}
$this->SetBuffer('AccessToken', (string) $tokenpaar['accessToken']);
if (isset($tokenpaar['refreshToken'])) {
$this->SetBuffer('RefreshToken', (string) $tokenpaar['refreshToken']);
}
if ($neuVerbinden) {
return $this->verbindeSignalR();
}
return true;
}
private function stelleAccessTokenSicher(): bool
{
return $this->GetBuffer('AccessToken') !== ''
|| $this->erneuereZugangsdaten(false);
}
/** @param array<string, string> $nutzdaten
* @return array<string, mixed>|null
*/
private function fordereTokenpaarAn(string $url, array $nutzdaten): ?array
{
$antwort = $this->httpAnfrage(
'POST',
$url,
json_encode($nutzdaten, JSON_THROW_ON_ERROR)
);
if (!$antwort['success']) {
return null;
}
$daten = json_decode($antwort['body'], true);
return is_array($daten) ? $daten : null;
}
/**
* @return array{success: bool, body: string, error: string, httpCode: int}
*/
private function httpAnfrage(
string $methode,
string $url,
string $inhalt = '',
string $bearerToken = ''
): array {
$kopf = ['Accept: application/json', 'Content-Type: application/json'];
if ($bearerToken !== '') {
$kopf[] = 'Authorization: Bearer ' . $bearerToken;
}
$curl = curl_init($url);
if ($curl === false) {
return [
'success' => false,
'body' => '',
'error' => 'HTTP-Anfrage konnte nicht initialisiert werden.',
'httpCode' => 0,
];
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $methode,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 30,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HTTPHEADER => $kopf,
CURLOPT_POSTFIELDS => $inhalt,
CURLOPT_SSL_VERIFYPEER => $this->ReadPropertyBoolean('VerifyCertificate'),
CURLOPT_SSL_VERIFYHOST => $this->ReadPropertyBoolean('VerifyCertificate') ? 2 : 0,
]);
$antwort = curl_exec($curl);
$fehler = curl_error($curl);
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
return [
'success' => $antwort !== false
&& $fehler === ''
&& $httpStatus >= 200
&& $httpStatus < 300,
'body' => $antwort === false ? '' : (string) $antwort,
'error' => $fehler,
'httpCode' => $httpStatus,
];
}
private function setzeVerbunden(bool $verbunden): void
{
$this->SetValue('Connected', $verbunden);
$this->SendDataToChildren(json_encode([
'DataID' => self::CHILD_EVENT_DATA_ID,
'Buffer' => json_encode([
'type' => 'GatewayStatus',
'connected' => $verbunden,
], JSON_THROW_ON_ERROR),
], JSON_THROW_ON_ERROR));
}
private function setzeLetztenFehler(string $nachricht): void
{
$this->SetValue('LastError', $nachricht);
}
/** @return array<string, mixed> */
private function leseAttributArray(string $name): array
{
$wert = json_decode($this->ReadAttributeString($name), true);
return is_array($wert) ? $wert : [];
}
/** @return array<string, mixed> */
private function lesePufferArray(string $name): array
{
$wert = json_decode($this->GetBuffer($name), true);
return is_array($wert) ? $wert : [];
}
}
+11
View File
@@ -0,0 +1,11 @@
# Ladestation Gateway
Eventbasiertes Enelix-EMS-Verbrauchermodul fuer eine Easee-Ladestation. Das
Modul liest Fahrzeugstatus und aktive 1-/3-Phasenladung direkt aus Easee
Observations und sendet Stromvorgaben ueber das verbundene Easee Gateway.
Konfiguration, Betriebsarten und Tests:
[Moduldokumentation](../docs/module/Ladestation-Gateway/README.md)
Gateway-Vertrag:
[Schnittstellenbeschreibung](../docs/Schnittstelle-Easee-Gateway.md)
+116
View File
@@ -0,0 +1,116 @@
{
"elements": [
{
"type": "Select",
"name": "Betriebsmodus",
"caption": "Variante",
"options": [
{
"caption": "Easee",
"value": 0
},
{
"caption": "Easee - Nur Solarladen",
"value": 1
}
]
},
{
"type": "ValidationTextBox",
"name": "Ladestationskennung",
"caption": "Easee Seriennummer"
},
{
"type": "NumberSpinner",
"name": "MaximalerLadestrom",
"caption": "Maximaler Ladestrom",
"minimum": 6,
"maximum": 32,
"suffix": " A"
},
{
"type": "CheckBox",
"name": "Ladefreigabe",
"caption": "Ladefreigabe beim Start"
},
{
"type": "CheckBox",
"name": "Solarladen",
"caption": "Solarladen beim Start"
},
{
"type": "NumberSpinner",
"name": "PrioritaetPV",
"caption": "Prioritaet PV",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "PrioritaetPeak",
"caption": "Prioritaet Peak",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Meldeintervall",
"caption": "Meldeintervall",
"minimum": 1,
"suffix": " s"
},
{
"type": "NumberSpinner",
"name": "VorgabeTimeout",
"caption": "Vorgabe-Timeout",
"minimum": 1,
"suffix": " s"
},
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Einstellungen in Visualisierung"
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Diagnoseprotokoll aktivieren"
},
{
"type": "Label",
"caption": "Easee-Zugangsdaten werden ausschliesslich im verbundenen Easee Gateway gespeichert."
}
],
"actions": [
{
"type": "Button",
"caption": "Status neu anfordern",
"onClick": "IPS_RequestAction($id, \"StatusAnfordern\", false);"
}
],
"status": [
{
"code": 201,
"icon": "error",
"caption": "Konfiguration ungueltig"
},
{
"code": 202,
"icon": "error",
"caption": "Easee Gateway nicht verbunden"
},
{
"code": 203,
"icon": "inactive",
"caption": "Warte auf Easee-Status"
},
{
"code": 204,
"icon": "error",
"caption": "Easee Ladestation meldet Fehler"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"id": "{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}",
"name": "LadestationGateway",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Ladestation Gateway"
],
"parentRequirements": [
"{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}"
],
"childRequirements": [],
"implemented": [
"{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}"
],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/LadestationGateway"
}
+985
View File
@@ -0,0 +1,985 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/LadestationRegler.php';
require_once __DIR__ . '/../libs/EaseeLadestatus.php';
require_once __DIR__ . '/../libs/EaseeGatewayProtokoll.php';
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
use Belevo\EnelixEMS\EaseeLadestatus;
use Belevo\EnelixEMS\LadestationRegler;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
{
use VerbraucherBasisTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const EASEE_GATEWAY_MODULE_ID = '{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}';
private const GATEWAY_REQUEST_DATA_ID = '{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}';
private const STATUS_AKTIV = 102;
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
private const STATUS_GATEWAY_GETRENNT = 202;
private const STATUS_WARTET_AUF_DATEN = 203;
private const STATUS_LADEFEHLER = 204;
/** @var list<string> */
private const DIAGNOSE_VARIABLEN = [
'Istleistung',
'Leistungsquelle',
'Sollleistung',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
'Stoertext',
'GatewayVerbunden',
'ApiMaximalstrom',
'LetzterGeraetebefehl',
'LeistungsangebotDiagnose',
];
public function Create(): void
{
parent::Create();
$this->registriereVerbraucherBasis();
$this->RegisterPropertyInteger('Betriebsmodus', EaseeLadestatus::BETRIEBSMODUS_EASEE);
$this->RegisterPropertyString('Ladestationskennung', '');
$this->RegisterPropertyInteger('MaximalerLadestrom', 16);
$this->RegisterPropertyBoolean('Ladefreigabe', 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);
}
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->RegisterPropertyString('Passwort', '');
$this->RegisterPropertyInteger('MaximalerLadestrom', 16);
$this->RegisterPropertyInteger('Abfrageintervall', 5);
$this->RegisterPropertyBoolean('Ladefreigabe', false);
$this->RegisterPropertyBoolean('Ladefreigabe', true);
$this->RegisterPropertyBoolean('Solarladen', true);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
+536 -7
View File
@@ -26,6 +26,13 @@
"name": "LizenzPruefen",
"caption": "Lizenz pruefen und binden",
"onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);"
},
{
"type": "Button",
"name": "LizenzVerwalten",
"caption": "Lizenz verwalten",
"link": true,
"onClick": "echo 'https://license.enelix.ch';"
}
]
},
@@ -120,6 +127,24 @@
],
"values": []
},
{
"type": "Label",
"name": "PeakShavingTitel",
"caption": "Peak Shaving am Netzanschlusspunkt"
},
{
"type": "CheckBox",
"name": "EinspeisebegrenzungAktiv",
"caption": "Anlagenweite Einspeisebegrenzung aktivieren"
},
{
"type": "NumberSpinner",
"name": "Einspeisegrenze",
"caption": "Maximale Einspeisung der Gesamtanlage",
"suffix": " W",
"minimum": 0.0,
"digits": 1
},
{
"type": "CheckBox",
"name": "AutomatischeSuche",
@@ -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",
"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",
"caption": "Prognose / Forecast",
"items": [
{
"type": "ValidationTextBox",
"name": "PrognoseAnschluss",
"caption": "Konfiguration als JSON"
"type": "CheckBox",
"name": "PrognoseAktiv",
"caption": "Prognosen aktivieren",
"onChange": "IPS_RequestAction($id, 'FormPrognoseAktiv', $PrognoseAktiv);"
},
{
"type": "Label",
"name": "PrognoseLizenzInformation",
"caption": "Prognoselizenz wird geprueft."
},
{
"type": "Label",
"caption": "Forecast verwendet dieselben Messquellen aus der Anlagentopologie und den daraus berechneten Hausverbrauch."
},
{
"type": "CheckBox",
"name": "NetzfahrplanAktiv",
"caption": "Intelligenten Netzfahrplan zur Vermeidung von PV-Abregelung verwenden"
},
{
"type": "NumberSpinner",
"name": "PrognoseSendeintervall",
"caption": "Aktuelle Messwerte an den Prognosedienst senden alle",
"minimum": 60,
"maximum": 3600,
"suffix": " Sekunden"
}
]
},
@@ -278,9 +795,21 @@
"caption": "Stoerueberwachung",
"items": [
{
"type": "ValidationTextBox",
"name": "StoermeldeAnschluss",
"caption": "Konfiguration als JSON"
"type": "CheckBox",
"name": "StoerueberwachungAktiv",
"caption": "Stoerungen sicher an license.enelix.ch uebertragen"
},
{
"type": "NumberSpinner",
"name": "StoerungsSendeintervall",
"caption": "Heartbeat-Intervall",
"minimum": 60,
"maximum": 3600,
"suffix": " Sekunden"
},
{
"type": "Label",
"caption": "Die Freischaltung als jaehrlich erneuerbare Lizenz ist vorbereitet und wird spaeter aktiviert."
}
]
}
@@ -296,6 +825,6 @@
{"code": 102, "icon": "active", "caption": "Aktiv"},
{"code": 201, "icon": "inactive", "caption": "Netzleistungsmessung fehlt oder ist veraltet"},
{"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"},
{"code": 203, "icon": "error", "caption": "Lizenz fehlt oder ist abgelaufen"}
{"code": 203, "icon": "error", "caption": "Lizenz nicht freigegeben oder Verbraucherkontingent ueberschritten"}
]
}
+2024 -51
View File
File diff suppressed because it is too large Load Diff
+58 -11
View File
@@ -4,28 +4,65 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
## Status
Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die
Verbraucherbasis, der Manager, der mehrstufige Warmwassererwaermer, der
1-stufige Verbraucher und die Ladestation Stand-Alone sind implementiert. Die
weiteren Module sind als Diskussionsentwürfe dokumentiert.
Version 0.1 Build 1 ist die erste Beta fuer kontrollierte Anlagen- und
Feldtests. Der gemeinsame Nachrichtenvertrag, die Verbraucherbasis, der Manager
und alle unten aufgefuehrten Module sind implementiert. Produktiver Einsatz
setzt eine anlagenspezifische Pruefung, Sicherung und Rueckfallplanung voraus.
## Geplante Module
## Module
- Manager (implementiert)
- Batterie
- Batterie (implementiert)
- Wassererwaermer (implementiert)
- Pufferspeicher
- Pufferspeicher (implementiert)
- Verbraucher 1-Stufig (implementiert)
- Waermepumpe
- Waermepumpe (implementiert)
- Ladestation Stand-Alone (implementiert)
- Ladestation Gateway
- Easee Gateway
- Ladestation Gateway (implementiert)
- Easee Gateway (implementiert)
Die Properties, Variablen, Zustandsdaten und offenen Punkte stehen in der
[Modulübersicht](docs/module/README.md). Gemeinsame Felder werden dort nicht
abweichend neu definiert, sondern verweisen auf den zentralen Vertrag.
Die Verbraucher kommunizieren mit dem Manager ueber den gemeinsamen Vertrag in `libs/`. Der Manager waehlt die Verbraucher aus; in einem Verbraucher gibt es keine Manager-ID-Property.
Die Verbraucher kommunizieren mit dem Manager ueber den gemeinsamen Vertrag
in `libs/`. Der Manager waehlt die Verbraucher aus; in einem Verbraucher gibt
es keine Manager-ID-Property.
## 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
@@ -60,13 +97,23 @@ der Uebernahme in einen Freigabebranch erfolgreich sein.
## Dokumentation
- [Changelog](CHANGELOG.md)
- [Zentrale Liste offener Teamentscheidungen](docs/Offene-Punkte.md)
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
- [Batterieschnittstelle](docs/Schnittstelle-Batterie.md)
- [ADR 0004: Betriebsartabhaengige Leistungsangebote](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md)
- [ADR 0005: Anlagentopologie fuer Prognosen](docs/adr/0005-anlagentopologie-fuer-prognosen.md)
- [Manager-Modul inklusive Lizenzierung](docs/module/Manager/README.md)
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md)
- [Pufferspeicher-Modul](docs/module/Pufferspeicher/README.md)
- [Batteriemodul](docs/module/Batterie/README.md)
- [Verbraucher-1-Stufig-Modul](docs/module/Verbraucher-1-Stufig/README.md)
- [Ladestation-Stand-Alone-Modul](docs/module/Ladestation-Stand-Alone/README.md)
- [Ladestation-Gateway-Modul](docs/module/Ladestation-Gateway/README.md)
- [Easee-Gateway-Modul](docs/module/Easee-Gateway/README.md)
- [Easee-Gateway-Schnittstelle](docs/Schnittstelle-Easee-Gateway.md)
- [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md)
- [Migration Verbraucher 1-Stufig](docs/migration/Verbraucher-1-Stufig.md)
- [Migration Batterie](docs/migration/Batterie.md)
- [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md)
- [Modulübersicht](docs/module/README.md)
+166
View File
@@ -0,0 +1,166 @@
{
"elements": [
{
"type": "ExpansionPanel",
"caption": "Manager und Zeitverhalten",
"expanded": true,
"items": [
{"type": "NumberSpinner", "name": "PrioritaetPV", "caption": "Prioritaet PV", "minimum": 0},
{"type": "NumberSpinner", "name": "PrioritaetPeak", "caption": "Prioritaet Peak", "minimum": 0},
{"type": "NumberSpinner", "name": "Meldeintervall", "caption": "Meldeintervall", "suffix": " s", "minimum": 1},
{"type": "NumberSpinner", "name": "VorgabeTimeout", "caption": "Vorgabe-Timeout", "suffix": " s", "minimum": 1}
]
},
{
"type": "ExpansionPanel",
"caption": "Waermepumpe und Kontakte",
"expanded": true,
"items": [
{
"type": "Select",
"name": "Kontaktart",
"caption": "Kontaktart",
"options": [
{"caption": "Sperre und Erhoehung", "value": 0},
{"caption": "SG Ready", "value": 1}
]
},
{
"type": "SelectVariable",
"name": "Kontakt1VariableID",
"caption": "Kontakt 1 (Sperre / SG Ready 1)",
"validVariableTypes": [0]
},
{
"type": "CheckBox",
"name": "Kontakt1Invertiert",
"caption": "Kontakt 1 invertieren"
},
{
"type": "SelectVariable",
"name": "Kontakt2VariableID",
"caption": "Kontakt 2 (Erhoehung / SG Ready 2)",
"validVariableTypes": [0]
},
{
"type": "CheckBox",
"name": "Kontakt2Invertiert",
"caption": "Kontakt 2 invertieren"
},
{
"type": "NumberSpinner",
"name": "Nennleistung",
"caption": "Nennleistung",
"suffix": " W",
"minimum": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Betriebsrueckmeldung",
"expanded": true,
"items": [
{
"type": "Select",
"name": "Rueckmeldungsart",
"caption": "Rueckmeldungsart",
"options": [
{"caption": "Gemessene Leistung", "value": 0},
{"caption": "Betriebsstatus", "value": 1}
]
},
{
"type": "SelectVariable",
"name": "IstleistungVariableID",
"caption": "Leistung oder Betriebsstatus",
"validVariableTypes": [0, 1, 2]
},
{
"type": "NumberSpinner",
"name": "Laufschwelle",
"caption": "Laufschwelle bei Leistungsmessung",
"suffix": " W",
"minimum": 0,
"digits": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Schutzzeiten",
"expanded": true,
"items": [
{
"type": "NumberSpinner",
"name": "Anlaufwartezeit",
"caption": "Anlaufwartezeit",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Wiederholsperre",
"caption": "Wiederholsperre nach erfolglosem Anlauf",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Mindestlaufzeit",
"caption": "Mindestlaufzeit",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Mindestsperrzeit",
"caption": "Mindestsperrzeit",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "MaximaleSperrzeit",
"caption": "Maximale Sperrzeit am Stueck",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "MaximaleSperrzeit24h",
"caption": "Maximale Sperrzeit in 24 Stunden",
"suffix": " s",
"minimum": 0,
"maximum": 86400
}
]
},
{
"type": "ExpansionPanel",
"caption": "Darstellung und Diagnose",
"items": [
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Lokale Einstellungen in der Visualisierung anzeigen"
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Diagnoseprotokoll aktivieren"
}
]
}
],
"status": [
{"code": 102, "icon": "active", "caption": "Aktiv"},
{"code": 201, "icon": "error", "caption": "Konfiguration ungueltig"},
{"code": 202, "icon": "error", "caption": "Schalt- oder Rueckmeldefehler"}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}",
"name": "VerbraucherWaermepumpe",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Waermepumpe"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Waermepumpe"
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,6 +2,7 @@
"name": "belevo/enelix-ems",
"description": "Energiemanagement und steuerbare Verbraucher fuer IP-Symcon",
"type": "library",
"license": "proprietary",
"require": {
"php": ">=8.0"
},
+47 -21
View File
@@ -1,34 +1,60 @@
# Obere Anschlüsse des Managers
> Status: Lizenzierung implementiert; die übrigen Anbieterformate sind noch
> nicht festgelegt.
> Status: Lizenzierung, Prognose und Stoerueberwachung sind an
> `license.enelix.ch` angebunden. SDL/VGT bleibt als optionaler Anschluss
> vorbereitet.
Prognose, SDL/VGT und Stoerueberwachung sind optional. Die Lizenzierung ist fuer
den Betrieb des Managers erforderlich. Sie werden im Manager konfiguriert und erzeugen
keine Abhängigkeit zwischen Enelix EMS und Enelix Utils.
Die Anschluesse werden ausschliesslich im Manager konfiguriert. Sie erzeugen
keine Abhaengigkeit zwischen Enelix EMS und Enelix Utils.
| Anschluss | Aufgabe | Gegenstelle |
| --- | --- | --- |
| SDL/VGT | Zeitlich gültige Leistungsaufträge und Rückmeldungen | Optional das unabhängige Utils-Modul VGT-Schnittstelle |
| Prognose / Forecast | Vorhersage für Produktion und bei Bedarf Verbrauch | Externer Anbieter; kein Repo-Modul |
| SDL/VGT | Zeitlich gueltige Leistungsauftraege und Rueckmeldungen | Optional das unabhaengige Utils-Modul VGT-Schnittstelle |
| Prognose / Forecast | Topologie, Telemetrie und Netzfahrplan | `license.enelix.ch/api/v1/installations/{id}/prognosis/*` |
| Lizenzierung | Pruefung freigeschalteter Manager-Funktionen | `POST https://license.enelix.ch/api/v1/licenses/activate` |
| 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.
- Anbieterformate werden an diesem Anschluss übersetzt und gelangen nicht in
1. Jedes Geraetemodul meldet seinen Zustand ueber den bestehenden
Verbraucher-Nachrichtenvertrag an den Manager.
2. Der Manager sammelt aktive Eintraege mit `Art = Stoerung`, fuegt eigene
Regelungsstoerungen hinzu und normalisiert sie.
3. Der Manager speichert lokal einen deterministisch sortierten Vollsnapshot.
4. Bei Zustandsaenderung oder spaetestens im konfigurierten Heartbeat-Intervall
sendet er den Snapshot mit dem vorhandenen Geraete-Bearer-Token.
5. Ein leerer Snapshot loest zuvor aktive Stoerungen im Portal auf.
Jede Meldung besteht aus `sourceType`, `sourceId`, `sourceName`, `code`,
`severity` und `message`. Die stabile Kombination aus `sourceId` und
`code` identifiziert ein Ereignis ueber mehrere Snapshots hinweg.
Fehler der externen Uebertragung blockieren die lokale EMS-Regelung nicht. Der
Manager wiederholt mit exponentiellem Abstand zwischen 30 und 900 Sekunden.
Nach HTTP 401 oder 403 verwirft er den Geraetezugang und fordert ihn bei der
naechsten Lizenzaktivierung neu an.
## Lizenzvorbereitung
Die Stoerueberwachung ist technisch als separat schaltbare Manager-Funktion
gekapselt. Das Portal kann spaeter eine Berechtigung mit dem Katalogschluessel
`fault_monitoring`, einer Laufzeit von einem Jahr und einem erneuerten
`validUntil` ausliefern. In der ersten Ausbaustufe wird diese zusaetzliche
Jahresberechtigung noch nicht erzwungen; Voraussetzung bleibt eine gueltige
Managerlizenz samt Geraetezugang.
## Gemeinsame Grundsaetze
- Anbieterformate werden am Manageranschluss uebersetzt und gelangen nicht in
die Verbraucher-Schnittstelle.
- Jeder Anschluss meldet im Manager `NichtVerwendet`, `Verbunden` oder `Fehler`.
- Fehlende optionale Anschlüsse dürfen die lokale EMS-Grundfunktion nicht
- Jeder Anschluss meldet `NichtVerwendet`, `Wartet`, `Verbunden` oder
`Fehler`.
- Fehlende optionale Anschluesse duerfen die lokale EMS-Grundfunktion nicht
blockieren.
- Eine fehlende oder abgelaufene Lizenz blockiert die Managerregelung. Eine
bestaetigte Entwicklungs-Lease bleibt bei Verbindungsunterbruch bis
`offlineUntil` gueltig.
- Zugangstoken werden weder als Property noch in Diagnosevariablen oder Logs
ausgegeben.
## Noch festzulegen
- Anbieter und Datenformat für Prognose und Störüberwachung
- Aufbau, Gültigkeitszeitraum und Rückmeldung eines SDL-Auftrags
- Authentisierung der noch optionalen Anschlüsse
- Ablösung der Entwicklungs-Lease durch den signierten produktiven Lizenzvertrag
- Aufbau, Gueltigkeitszeitraum und Rueckmeldung eines SDL-Auftrags
- Produkt- und Zahlungsmodell der jaehrlichen Stoerueberwachungslizenz
- Verhalten nach Ablauf einer spaeter aktivierten Stoerueberwachungslizenz
+24 -27
View File
@@ -47,25 +47,13 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
| ID | Status | Bereich | Kurzthema |
| --- | --- | --- | --- |
| 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-004 | Offen | Lizenzierung | Lizenzmodell des Verbrauchskostenreports |
| OP-004 | Entschieden | Lizenzierung | Lizenzmodell des Verbrauchskostenreports |
| OP-005 | Offen | Teststrategie | Standort fuer Feldtests |
## Offene Punkte
### OP-002: Generator und kostenpflichtige Ersteinrichtung
- **Entscheidungsfrage:** Soll der automatische Generator fuer die
Grundeinrichtung zugleich die kostenpflichtige Ersteinrichtung abbilden?
- **Kontext:** Der vorgesehene automatische Generator fuer die Grundeinrichtung
muss gegenueber der kostenpflichtigen Ersteinrichtung abgegrenzt werden.
- **Auswirkung:** Die Entscheidung legt fest, ob beide als derselbe Vorgang oder
als getrennte Leistungen behandelt werden.
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
### OP-003: VGT-Integration in license.enelix.ch
- **Entscheidungsfrage:** Soll die VGT-Anwendung als Bestandteil in die
@@ -78,19 +66,6 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
### OP-004: Lizenzmodell des Verbrauchskostenreports
- **Entscheidungsfrage:** Soll der Verbrauchskostenreport nach dem Lizenzmodell
des Managers oder mit einer einzigen, von der Anzahl angeschlossener Zaehler
unabhaengigen Modullizenz angeboten werden?
- **Kontext:** Fuer den Verbrauchskostenreport muss zwischen dem Lizenzmodell des
Managers und einer zaehlerunabhaengigen Modullizenz entschieden werden.
- **Auswirkung:** Die Entscheidung legt fest, ob die Anzahl angeschlossener
Zaehler den Lizenzumfang des Verbrauchskostenreports beeinflusst.
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
### OP-005: Standort fuer Feldtests
- **Entscheidungsfrage:** An welchem Standort koennen die Module aus Enelix EMS
@@ -113,6 +88,28 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
- **Ergebnis/Verweis:** [Obere Anschluesse](Obere-Anschluesse.md) und
[Manager](module/Manager/README.md)
### OP-002: Generator und kostenpflichtige Ersteinrichtung
- **Entscheidung:** Der automatische Systemgenerator und die kostenpflichtige
Ersteinrichtung sind derselbe Vorgang.
- **Abrechnung:** Eine Bestellung aus dem Systemkonfigurator enthaelt
automatisch die noch nicht bezahlten Einrichtungskosten fuer den Manager und
die konfigurierten Verbrauchermodule. Bereits bezahlte Einrichtungsmengen
werden je Anlage angerechnet. Direkte Lizenzbestellungen enthalten keine
Einrichtungskosten.
- **Status:** Erledigt
- **Ergebnis/Verweis:** Umsetzung im Lizenzportal unter
`https://license.enelix.ch`.
### OP-004: Lizenzmodell des Verbrauchskostenreports
- **Entscheidung:** Der Verbrauchskostenreport verwendet eine Grundlizenz sowie
getrennte Kontingente fuer Stromzaehler und Nebenzaehler. Die Anzahl der
konfigurierten Zaehler wird bei der Lizenzpruefung beruecksichtigt.
- **Status:** Entschieden und implementiert
- **Ergebnis/Verweis:** Enelix Utils, Commit `1f945be` und
`Verbrauchskostenreport/module.php`.
## Vorlage fuer neue Punkte
```markdown
+112
View File
@@ -0,0 +1,112 @@
# Schnittstelle Batterie
Diese Beschreibung ergaenzt den allgemeinen
[Manager-Verbraucher-Vertrag](Schnittstelle.md) fuer das Batteriemodul.
Vertragsversion ist 4.0.
## Manager an Batterie
Die Batterie empfaengt den unveraenderten gemeinsamen Datensatz:
~~~json
{
"Kopf": {
"Version": "4.0",
"AbsenderID": 10001,
"EmpfaengerID": 20001,
"Zeitpunkt": 1788825600
},
"Betriebsart": "PV",
"Sollleistung_W": -1500
}
~~~
Sollleistung_W verwendet folgende Semantik:
- positiver Wert: Batterie laden
- negativer Wert: Batterie entladen
- 0: Leistungsregister auf 0 setzen
- null: nur Betriebsart synchronisieren und neues Angebot anfordern
Ein konkreter Wert muss im zuletzt fuer dieselbe Betriebsart gemeldeten
Einzelwert oder Leistungsbereich enthalten sein. Waehrend der Aenderungssperre
wird nur die Wiederholung des aktuellen Sollwerts akzeptiert.
## Batterie an Manager
Die Batterie verwendet alle Pflichtfelder des gemeinsamen Vertrags und
ergaenzt folgende Zustandseintraege:
| Kennung | Art | Typ | Einheit | Bedeutung |
| --- | --- | --- | --- | --- |
| Sollleistung_W | Sollwert | Integer oder null | W | Aktuell gueltige Manager-Vorgabe |
| Ladezustand_Prozent | Istwert | Float oder null | % | Physischer SoC |
| HystereseAktiv | Status | Boolean | - | Reserve-Hysterese ist aktiv |
| Batteriesteuerung | Status | Integer | - | 1 Wechselrichter, 2 Enelix |
| Messwertfehler | Stoerung | Boolean | - | Pflichtmesswert fehlt oder ist ungueltig |
| Registerfehler | Stoerung | Boolean | - | Mindestens ein Schreibbefehl ist fehlgeschlagen |
Beispiel:
~~~json
{
"Kopf": {
"Version": "4.0",
"AbsenderID": 20001,
"EmpfaengerID": 10001,
"Zeitpunkt": 1788825602
},
"Betriebsart": "PV",
"PrioritaetPV": 0,
"PrioritaetPeak": 0,
"Leistungswerte_W": [{"Von_W": -5000, "Bis_W": 5000}],
"AenderungMoeglich": true,
"Verfuegbar": true,
"Istleistung_W": -250.0,
"Leistungsquelle": 2,
"Zustand": [
{
"Kennung": "Sollleistung_W",
"Art": "Sollwert",
"Wert": -537,
"Einheit": "W"
},
{
"Kennung": "Ladezustand_Prozent",
"Art": "Istwert",
"Wert": 54.2,
"Einheit": "%"
}
]
}
~~~
Ist die Leistungsmessung ungueltig, wird Istleistung_W als null und
Leistungsquelle als 0 gemeldet. Bei einem gueltigen Messwert ist
Leistungsquelle 2. Frei regelbare Lade- und Entladeangebote werden als
inklusive Bereiche in ganzen Watt gemeldet. Feste Schutz- und Peakvorgaben
bleiben einzelne Leistungswerte.
## Ereignisse
Das Modul registriert VM_UPDATE fuer:
- maximale Ladeleistung
- maximale Entladeleistung
- Ladezustand
- Netzleistung
- aktuelle Batterieleistung
Jede Aktualisierung berechnet Zustand und Angebot neu und plant eine
gebuendelte Rueckmeldung. Registerwerte werden nur geschrieben, wenn sich der
resultierende Befehl geaendert hat.
## Registerausgang
Der technische Ausgang besteht aus ausgewaehlten numerischen
IP-Symcon-Variablen. Das Modul verwendet RequestAction und setzt diese
Variablen nicht mit SetValue. Damit bleibt der jeweilige Modbus-, Skript- oder
Geraeteadapter fuer die konkrete Registerkommunikation verantwortlich.
Die genaue Herstellerabbildung steht in der
[Moduldokumentation](module/Batterie/README.md).
+103
View File
@@ -0,0 +1,103 @@
# Easee-Gateway-Schnittstelle
Stand: 2026-09-22
Die Schnittstelle verbindet das kontobezogene Splittermodul `EaseeGateway`
mit beliebig vielen Kindinstanzen `LadestationGateway`.
## IP-Symcon-Daten-IDs
| Richtung | DataID |
| --- | --- |
| Ladestation an Gateway | `{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}` |
| Gateway an Ladestation | `{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}` |
Die aeussere IP-Symcon-Nachricht enthaelt `DataID` und `Buffer`. `Buffer`
ist wiederum ein JSON-Objekt.
## Anfragen an das Gateway
### Subscribe und GetState
```json
{
"action": "Subscribe",
"serialNumber": "EH123456"
}
```
`GetState` besitzt dasselbe Format. Beide Aktionen registrieren die
Seriennummer und liefern den Cache:
```json
{
"success": true,
"connected": true,
"state": {
"109": 3,
"110": 30,
"120": 11.0,
"updated": 1790053200
}
}
```
### SetDynamicChargerCurrent
```json
{
"action": "SetDynamicChargerCurrent",
"serialNumber": "EH123456",
"amps": 13
}
```
Zulaessig sind `0 A` oder ganzzahlige Werte von `6 A` bis `32 A`.
Das Gateway ruft
`POST /api/chargers/{serialNumber}/commands/set_dynamic_charger_current`
mit `{"amps":13,"minutes":0}` auf.
## Ereignisse an Kindinstanzen
### Observation
```json
{
"type": "Observation",
"serialNumber": "EH123456",
"id": 110,
"value": 30,
"timestamp": 1790053200
}
```
Verteilt werden die IDs `47`, `48`, `100`, `104`, `109`, `110`,
`119`, `120`, `182` bis `185` und `250`. Jede Kindinstanz verwirft
Ereignisse anderer Seriennummern.
### GatewayStatus
```json
{
"type": "GatewayStatus",
"connected": false
}
```
Bei `false` setzt die Ladestation Verfuegbarkeit und Aenderbarkeit sofort
zurueck. Bei `true` fordert sie den aktuellen Zustand erneut an.
## Zustandsabbildung
Observation `109` wird gemaess Easee OpMode abgebildet: `0` offline,
`1` getrennt, `2/6/7/8` bereit, `3` laedt, `4` geladen und `5` Fehler.
Unbekannte Werte geben die Regelung nicht frei. Observation `110` liefert die
aktive Ausgangsphase: `10..15` einphasig und `30` dreiphasig. Es gibt keine
leistungsbasierte Phasenschaetzung.
## Fehlervertrag
Gateway-Antworten enthalten immer `success`. Bei `false` folgt ein
menschenlesbares Feld `error`; optional wird `httpCode` ergaenzt.
Zugangsdaten, Access Token und Refresh Token duerfen weder in Antworten noch
in Ereignissen oder Diagnosevariablen vorkommen.
+16
View File
@@ -132,6 +132,22 @@ Symcon-Datenpunkte registriert `VerbraucherBasisTrait`.
`Leistungswerte_W`, `Betriebsart` und `Zustand` werden intern gehalten
und direkt in die Nachricht geschrieben.
## Batteriespezifische Erweiterung
Die Batterie verwendet denselben Vertrag 4.0 und ergaenzt Zustandseintraege
fuer Ladezustand, Hysterese, Steuerungsmodus, Messwertfehler und
Registerfehler. Positive Leistung bedeutet Laden, negative Leistung
Entladen. Die vollstaendige Semantik und Registeranbindung beschreibt die
[Schnittstelle Batterie](Schnittstelle-Batterie.md).
## Easee-Gateway-Transport
Der technische JSON-Vertrag zwischen `EaseeGateway` und
`LadestationGateway` ist getrennt vom fachlichen Managervertrag dokumentiert:
[Easee-Gateway-Schnittstelle](Schnittstelle-Easee-Gateway.md). Die
Ladestation uebersetzt Gateway-Ereignisse in den hier beschriebenen
Verbrauchervertrag `4.0`.
## Zeitverhalten
- Rueckmeldung nach Start, relevanten Aenderungen und alle `Meldeintervall`
@@ -0,0 +1,76 @@
# ADR 0005: Anlagentopologie als Manager-Stammdaten
## Kontext
Die Prognose benoetigt technische Stammdaten zu PV-Flaechen,
Wechselrichtern und Batteriespeichern. Diese Daten beschreiben die reale
Installation und werden auch von der lokalen Regelung benoetigt. Eine
unabhaengige Pflege im Prognoseportal wuerde zwei konkurrierende Wahrheiten
erzeugen.
Hybridwechselrichter benoetigen eine ausdrueckliche Topologie. PV und Batterie
duerfen nicht als zwei unabhaengige AC-Quellen mit jeweils voller
Wechselrichterleistung behandelt werden.
## Entscheidung
Der Enelix-Manager ist die fuehrende Quelle fuer die technische
Anlagentopologie. Er speichert drei getrennte Listen:
- Wechselrichter mit Typ, AC-Nennleistung und optionalen AC-Grenzen,
- PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichterbezug,
- Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und
Wechselrichterbezug.
Jede Komponente besitzt eine innerhalb ihrer Liste eindeutige, stabile ID.
PV-Flaechen und Batterien referenzieren einen Wechselrichter ueber diese ID.
Die AC-Nennleistung gehoert zum Wechselrichter. Die DC-Leistung gehoert zur
PV-Flaeche. Modulanzahl und Modulleistung sind optionale Detailangaben und
muessen gemeinsam gepflegt werden.
Bei einem Hybridwechselrichter bleiben PV-Flaechen und Batterie getrennte
Komponenten. Der Exportvertrag liefert zusaetzlich eine gemeinsame AC-Grenze,
die beide Seiten demselben Wechselrichter zuordnet. Damit darf ihre kombinierte
AC-Leistung die Wechselrichtergrenze nicht unabhaengig mehrfach ausschoepfen.
Der Manager exportiert die normalisierte Struktur mit Vertragsversion '1.0'
und seiner stabilen Lizenz-Installations-ID. Eine aggregierte Zusammenfassung
erleichtert die schrittweise Anbindung bestehender Prognoseberechnungen.
Tarife, Prognosevarianten und rein prognosespezifische Annahmen bleiben im
Prognoseportal. Dort werden die vom Manager gelieferten technischen Stammdaten
standardmaessig nur angezeigt.
## Alternativen
### Vollstaendige Pflege im Prognoseportal
Diese Variante waere fuer den Prognosedienst einfach, erzeugt aber
Doppelpflege und kann von der lokal tatsaechlich installierten Anlage
abweichen.
### Batterie und PV als ein Hybridobjekt speichern
Ein einzelnes Objekt waere kompakt, bildet mehrere PV-Flaechen, MPPT-Eingaenge
und spaetere Erweiterungen jedoch schlecht ab. Ausserdem gingen die getrennten
Kapazitaets- und Leistungsgrenzen verloren.
### AC- und DC-Leistung an jeder PV-Flaeche speichern
Das wuerde bei mehreren Flaechen an einem Wechselrichter die AC-Leistung
mehrfach zaehlen. Deshalb wird AC am Wechselrichter und DC an der Flaeche
gespeichert.
## Folgen
- Bestehende Installationen bleiben mit drei leeren Listen gueltig.
- Fehlerhafte Referenzen oder unplausible technische Grenzen sperren die
Manager-Konfiguration kontrolliert.
- Ein Hybridwechselrichter kann mehrere PV-Flaechen und Batteriespeicher
verbinden, besitzt aber nur eine gemeinsame AC-Nennleistung.
- Das Prognoseportal muss die technischen Stammdaten aus dem Managervertrag
lesen und eigene Eingaben dafuer als schreibgeschuetzt behandeln.
- Fuer den automatischen Upload ist ein eigener, widerrufbarer
Installationszugang erforderlich. Der Lizenzcode selbst wird nicht als
dauerhaftes API-Secret verwendet.
+67
View File
@@ -0,0 +1,67 @@
# Migration Batterie aus Enelix 1
## Ziel
Die bisherige Batterie wird als Enelix-2-Verbraucher auf Vertrag 4.0
umgestellt. Das alte Repository bleibt unveraendert und dient nur als
Verhaltensreferenz.
## Uebernommen
- positive Sollleistung fuer Laden und negative Sollleistung fuer Entladen
- dynamische maximale Lade- und Entladeleistung aus Variablen
- Ladezustand, Netzleistung und aktuelle Batterieleistung als Eingaben
- Reserve-, Mindestladezustands- und Hystereselogik
- getrennte Angebote fuer PV und Peak
- Wechselrichter- oder Enelix-Steuerung
- Registercodes fuer generisch, GoodWe, SolarEdge und Sigenergy
- Aufsummierung der bezogenen Energie
## Angepasst
| Enelix 1 | Enelix 2 |
| --- | --- |
| zyklischer Timer Do_UserCalc | Messwert- und Managerereignisse |
| IdleCounter in Zyklen | Aenderungssperre in Sekunden mit Einmaltimer |
| PowerSteps als Modulvariable | Leistungswerte_W direkt im Vertrag |
| 250-W-Grundraster mit 50-W-Feinwerten | inklusive Leistungsbereiche in ganzen Watt |
| interne Stellwertvariablen | ausgewaehlte Registervariablen mit RequestAction |
| fest verdrahtete Herstellerhilfsvariablen | dynamisch sichtbare Registerauswahl |
| fest codierte 2-%-Hysterese | Property LadezustandHysterese |
| berechnete Istleistung | gemessene Istleistung, Leistungsquelle 2 |
| Interval-basierte Energie | Zeitintegration zwischen Ereignissen und Vollmeldungen |
| Is_Peak_Shaving als Variable | Betriebsart PV oder Peak im Vertrag 4.0 |
## Neu implementiert
- Validierung aller Mess- und Registervariablen
- Pflichtaktion auf jeder Zielregistervariable
- Messwertalter und sicherer Zustand bei ungueltigen Messwerten
- Sollwert-Timeout
- Diagnosevariablen, Registerfehlerstatus und Debug-Logging
- standardisierte PHPUnit- und Symcon-8.0-Laufzeittests
- explizite Schnittstellenbeschreibung
## Verworfen
- direkter SetValue-Aufruf auf internen Leistungs- und Modusvariablen
- zyklische Regelung mit frei konfigurierbarem Interval
- doppelte Peak-/PV-Codepfade mit identischer Registerabbildung
- ungenutzte Batteriespannungsproperty
- CheckIdle mit globalem GetValue ohne Objekt-ID
## Umstellung einer Anlage
1. Bestehende Batterieinstanz und alle bisherigen Variablen-IDs dokumentieren.
2. Sicherung der IP-Symcon-Konfiguration erstellen.
3. Neue Enelix-2-Batterieinstanz anlegen.
4. Fuenf Messwertvariablen zuordnen.
5. Batterietyp waehlen und die eingeblendeten Registervariablen zuordnen.
6. Reserve, Mindestladezustand und Aenderungssperre uebertragen.
7. Neue Batterie im Enelix-2-Manager zuordnen.
8. Diagnose und Logging aktivieren.
9. Laden, Entladen, 0 W und Wechselrichtermodus unter Aufsicht testen.
10. Alte Instanz erst nach erfolgreicher Feldpruefung deaktivieren.
Ein automatisches Loeschen oder Ueberschreiben der alten Instanz findet nicht
statt.
+113 -47
View File
@@ -1,59 +1,125 @@
# Batterie
> Status: Diskussionsentwurf. Positiv bedeutet Laden, negativ Entladen.
> Status: implementiert. Zielplattform ist IP-Symcon ab Version 8.0,
> Nachrichtenvertrag 4.0.
Die Auswahl des Batterietyps bestimmt den Adapter und die sichtbaren
Anschlussfelder. Ohne Logging bleibt von den zusätzlichen Variablen nur
`Ladestatus` sichtbar.
## Verantwortung
## Zusätzliche Variablen
Das Modul bildet einen Batteriespeicher als bidirektionalen Enelix-Verbraucher
ab. Es berechnet das betriebsart- und ladezustandsabhaengige
Leistungsangebot, empfaengt Sollleistungen vom Manager und uebersetzt sie in
herstellerspezifische Registerwerte.
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Ladestatus` | Integer / Anzeige | `0` unbekannt, `1` ruhend, `2` laden, `3` entladen. |
| `Ladezustand` | Float / Logging | Physischer Ladezustand in %. |
| `LadezustandGueltig` | Boolean / Logging | Gültigkeit unabhängig von der Leistungsmessung. |
| `Steuerungsmodus` | Integer / lokal bedienbar | `0` Batterie, `1` Manager, `2` Virtuelle Batterie führt. |
| `NotnachladungAktiv` | Boolean / Logging | Aus Ladezustand ermittelter lokaler Bedarf. |
| `LadeleistungVerfuegbar` | Float / Logging | Aktuell nutzbare Ladeleistung in W. |
| `EntladeleistungVerfuegbar` | Float / Logging | Aktuell nutzbare Entladeleistung als positiver Betrag in W. |
| `MaxLadeleistung` | Float / lokal bedienbar | Wirksamer Parameter in W. |
| `MaxEntladeleistung` | Float / lokal bedienbar | Wirksamer Parameter in W. |
| `Mindestladezustand` | Float / lokal bedienbar | Wirksamer Parameter in %. |
| `Maximalladezustand` | Float / lokal bedienbar | Wirksamer Parameter in %. |
| `Nachladeschwelle` | Float / lokal bedienbar | Wirksamer Parameter in %. |
| `Nachladehysterese` | Float / lokal bedienbar | Wirksamer Parameter in %. |
| `Notnachladeleistung` | Float / lokal bedienbar | `0` deaktiviert; sonst Leistung in W. |
Positive Leistung bedeutet Laden, negative Leistung Entladen. Die
Geraeteanbindung erfolgt ausschliesslich ueber vom Benutzer ausgewaehlte
numerische IP-Symcon-Variablen. Schreibziele muessen eine Aktion besitzen.
## Zusätzliche Properties
## Ereignismodell
| Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `MaxLadeleistung` | Float | `0*` W; einzige einstellbare obere Ladegrenze. |
| `MaxEntladeleistung` | Float | `0` W; einzige einstellbare obere Entladegrenze. |
| `Mindestladezustand` | Float | `0*` %; Bereich 0–100. |
| `Maximalladezustand` | Float | `100` %; grösser als Mindestladezustand. |
| `Nachladeschwelle` | Float | `0*` %; zwischen Mindest- und Maximalladezustand. |
| `Nachladehysterese` | Float | `2` %; Schwelle plus Hysterese höchstens Maximalladezustand. |
| `Notnachladeleistung` | Float | `0` W; sonst höchstens MaxLadeleistung. |
| `Batterietyp` | Auswahl | `Unkonfiguriert`, `Herstellerunabhaengig`, `Goodwe`, `SolarEdge`, `SigEnergy`. |
| `BatterieInstanzID` | Integer | `0`; für kompatible Herstelleradapter. |
| `LadezustandVariableID` | Integer | `0`; nur herstellerunabhängig. |
| `IstleistungVariableID` | Integer | `0`; nur herstellerunabhängig. |
| `SollleistungVariableID` | Integer | `0`; nur herstellerunabhängig, vorzeichenbehaftet in W. |
| `LadezustandMaxAlter` | Integer | `60` s; Aktualität des Ladezustands. |
| `VirtuelleBatterieID` | Integer | `0`; Pflicht bei Steuerungsmodus 2. |
Fachliche Neuberechnungen werden ausgeloest durch:
Anschlussart, Batteriespannung, Gerätezuordnung, Leistungsschritt,
Sollwertquelle und externe Sollwerte entfallen.
- Aktualisierung eines der fuenf Messwerte
- Managerdaten mit Betriebsart oder Sollleistung
- lokale Aenderung der Variablen Aktiv
- Ablauf von Vorgabe-Timeout oder Aenderungssperre
- manuelle Aktualisierung in der Konfigurationsmaske
## Zustand
Der Meldezyklus verschickt eine Vollmeldung und dient nicht als Regelzyklus.
Ein periodischer Berechnungstimer oder Idle-Counter existiert nicht.
`Ladezustand_Prozent` als Istwert; `Steuerungsmodus` und
`NotnachladungAktiv` als Status; `LadezustandFehlt` und `Geraetefehler` als
Störungen.
## Properties
## Offene Punkte
### Gemeinsame Verbraucherproperties
- Michis konkrete Fehlerliste nachliefern und gegen den Entwurf prüfen.
- Datenpunkte und Leistungsgrenzen der drei Herstelleradapter verifizieren.
PrioritaetPV, PrioritaetPeak, Meldeintervall, VorgabeTimeout,
EinstellungenInVisu und LoggingEin stammen aus VerbraucherBasisTrait.
### Batteriespezifische Properties
| Property | Typ | Standard | Bedeutung |
| --- | --- | ---: | --- |
| Batterietyp | Integer | 0 | 0 unkonfiguriert, 1 generisch, 2 GoodWe, 3 SolarEdge, 4 Sigenergy |
| Batteriemanagement | Integer | 1 | 1 Wechselrichter, 2 Enelix |
| MaxLadeleistungVariableID | Integer | 0 | Dynamische Ladegrenze in W |
| MaxEntladeleistungVariableID | Integer | 0 | Dynamische Entladegrenze in W |
| LadezustandVariableID | Integer | 0 | SoC in Prozent |
| NetzleistungVariableID | Integer | 0 | Positiv Netzbezug, negativ Einspeisung |
| IstleistungVariableID | Integer | 0 | Positiv Laden, negativ Entladen |
| ManagementRegisterVariableID | Integer | 0 | Steuerungsquelle des Wechselrichters |
| ModusRegisterVariableID | Integer | 0 | Laden-/Entladen-Code |
| LeistungsRegisterVariableID | Integer | 0 | GoodWe-Leistungsregister in W |
| LadeleistungRegisterVariableID | Integer | 0 | Ladeleistung in W beziehungsweise kW |
| EntladeleistungRegisterVariableID | Integer | 0 | Entladeleistung in W beziehungsweise kW |
| ReserveLadezustand | Float | 20 | Peakshaving-Reserve in Prozent |
| MindestLadezustand | Float | 10 | Untere Entladegrenze in Prozent |
| LadezustandHysterese | Float | 2 | Hysterese oberhalb der Reserve |
| MesswertMaxAlter | Integer | 60 | Maximales Messwertalter in Sekunden |
| Aenderungssperre | Integer | 4 | Sperrzeit nach Sollwertaenderung |
| DiagnosevariablenAnzeigen | Boolean | false | Technische Variablen einblenden |
## Registeradapter
| Typ | WR-Management | Enelix-Management | Laden | Entladen |
| --- | ---: | ---: | --- | --- |
| Generisch | 0 | 1 | Modus 0, Ladeleistung W | Modus 1, Entladeleistung W |
| GoodWe | 1 | 2 | Modus 11, Betrag W | Modus 12, Betrag W |
| SolarEdge | 1 | 4 | Modus 3, Ladeleistung W | Modus 4, Entladeleistung W |
| Sigenergy | 0 | 1 | Modus 3, Ladeleistung kW | Modus 6, Entladeleistung kW |
Beim Wechsel in die Wechselrichtersteuerung werden zuerst die zum Typ
gehoerenden Leistungsregister auf 0 gesetzt und danach der Automatikcode
geschrieben. Im Enelix-Modus werden zuerst die Leistungswerte, dann Modus und
zuletzt Management geschrieben.
## Leistungsangebot
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
+64 -22
View File
@@ -1,35 +1,77 @@
# Easee Gateway
> Status: Diskussionsentwurf. Kommunikationsmodul, kein Verbraucher und keine
> Verwendung der Verbraucherbasis.
> Status: implementiert fuer IP-Symcon 8 und die Easee Cloud API.
Das bestehende Gateway wird übernommen. Anzeigen werden deutsch; technische
Alt-Idents bleiben zur Kompatibilität erhalten. Eine passende vorhandene
Verbindung soll bei der Instanziierung wiederverwendet werden.
Das Modul stellt pro Easee-Nutzerkonto genau eine gemeinsame Verbindung bereit.
Benutzername, Passwort, Access Token und Refresh Token verbleiben im Gateway.
Mehrere Instanzen von **Ladestation Gateway** koennen denselben Elternknoten
verwenden.
## Variablen
## Funktionsumfang
| Technischer Ident / Anzeige | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Connected` / Verbunden | Boolean / Anzeige | SignalR-Verbindungsstatus. |
| `SubscriptionCount` / Angemeldete Ladestationen | Integer / Anzeige | Anzahl registrierter Geräte. |
| `LastError` / Letzter Fehler | String / Anzeige | Diagnose ohne Zugangsdaten. |
- Anmeldung mit Easee-Benutzerkonto und automatische Token-Erneuerung,
- gemeinsame Ereignisverbindung zu `streams.easee.com`,
- Abonnement mehrerer Ladestationen mit aktuellem Zustand,
- Verteilung der Easee-Observations an die passenden Kindinstanzen,
- Stromvorgabe ueber `set_dynamic_charger_current`,
- Wiederverbindung und erneute Anmeldung aller Stationen nach Unterbrechungen,
- TLS-Zertifikatspruefung standardmaessig aktiv.
## Properties
| 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. |
| `Username` / Benutzername | String | leer; Easee-Konto. |
| `Password` / Passwort | String | leer; vertraulich. |
| `VerifyCertificate` / Zertifikat prüfen | Boolean | `true`; TLS-Prüfung. |
| `Connected` | Boolean | Ereignisverbindung ist betriebsbereit. |
| `SubscriptionCount` | Integer | Anzahl angemeldeter Seriennummern. |
| `LastError` | String | Letzter Fehler ohne Zugangsdaten oder Tokens. |
## Verhalten
## API und Ereignisse
Nach einer Wiederverbindung werden Gerätezustände erst nach neuer gültiger
Rückmeldung verwendet. Das Gateway sendet keine EMS-Verbrauchermeldung.
Der Gateway-Transport ist in
[Easee-Gateway-Schnittstelle](../../Schnittstelle-Easee-Gateway.md)
vollstaendig beschrieben. Fuer Fahrzeug- und Phasenstatus werden insbesondere
Observation `109` und `110` verteilt. Nach jeder Wiederverbindung wird der
Cache verworfen und durch `SubscribeWithCurrentState` neu aufgebaut.
## Offene Punkte
## Fehlerbehandlung
- Instanziierung und Wiederverwendung bestehender Verbindungen testen.
- Aktuelle Easee-Endpunkte und Ereignisfelder vor Übernahme verifizieren.
| Status | Bedeutung |
| ---: | --- |
| `102` | Verbindung aktiv. |
| `104` | Gateway deaktiviert. |
| `201` | Benutzername oder Passwort fehlt. |
| `202` | Anmeldung oder Token-Erneuerung fehlgeschlagen. |
| `203` | Ereignisverbindung fehlgeschlagen. |
REST-Aufrufe verwenden 5 Sekunden Verbindungs- und 30 Sekunden
Gesamt-Timeout. HTTP-401 fuehrt einmalig zu einer Token-Erneuerung und
Wiederholung. Tokens werden nie als Variable oder Debugtext ausgegeben.
## Inbetriebnahme
1. Eine Instanz **Easee Gateway** erstellen.
2. Benutzername und Passwort des Easee-Kontos eintragen.
3. TLS-Pruefung aktiviert lassen.
4. Speichern und Status `102` sowie `Connected=true` abwarten.
5. Fuer jede Station eine Kindinstanz **Ladestation Gateway** anlegen.
6. Bei mehreren Konten je Konto eine eigene Gateway-Instanz verwenden.
## Tests
`composer check` prueft Syntax und Unit-Tests. Der Funktionstest laeuft mit:
```bash
tests/Symcon/bin/run-symcon-tests.sh single EaseeGateway
```
+100 -29
View File
@@ -1,41 +1,112 @@
# Ladestation Gateway
> Status: Diskussionsentwurf. Eigenständiges Verbrauchermodul mit zugeordnetem
> Easee Gateway; keine gemeinsame Ladestations-Basisklasse.
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
## 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. |
| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` nicht verbunden, `2` bereit, `3` lädt, `4` voll, `5` Fehler. |
| `Ladestrom` | Float / Anzeige | Aktueller Ladestrom in A. |
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. |
| `Ladefreigabe` | Boolean / lokal bedienbar | Lokale Ladeerlaubnis zusätzlich zu `Aktiv`. |
| `Solarladen` | Boolean / lokal bedienbar | Überschussorientiertes Laden ein/aus. |
| PV | ein | `[0, ...Ladestufen]` |
| PV | aus | nur maximale Ladestufe |
| Peak | ein | `[0]` |
| Peak | aus | `[0, ...Ladestufen]` |
## 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 |
| --- | --- | --- |
| `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. |
## Managerkommunikation
Es gibt hier keine Zugangsdaten; sie liegen ausschliesslich im Easee Gateway.
Ladefreigabe und Solarladen werden über `EinstellungenInVisu` eingeblendet.
Das Modul implementiert den
[EMS-Nachrichtenvertrag `4.0`](../../Schnittstelle.md). Es wird im Manager
wie die Stand-Alone-Ladestation unter dem Lizenzkatalog `ev_charger`
gefuehrt. Es gibt keine Manager-ID-Property; die Zuordnung erfolgt nur im
Manager.
## Zustand
## Inbetriebnahme
`Fahrzeugstatus`, `FahrzeugVerbunden`, `Ladefreigabe`, `Solarladen`,
`Ladestrom_A`, `Ladefehler` und `Gatewayfehler`.
1. Ein konfiguriertes Easee Gateway mit Status `102` bereitstellen.
2. Darunter **Ladestation Gateway** erstellen.
3. Variante, Seriennummer und elektrische Maximalgrenze konfigurieren.
4. Ladefreigabe und Solarladen festlegen.
5. Die Instanz im Enelix Manager aktiv zuordnen.
6. Ohne Fahrzeug Status `1` und Angebot `[0]` pruefen.
7. Fahrzeug verbinden und die API-Werte fuer Status und Phasenzahl kontrollieren.
8. Erst danach `Aktiv` einschalten und eine kleine Vorgabe testen.
## Offene Punkte
## Tests
- Reaktion aller zugeordneten Stationen bei Gatewayausfall im Praxistest festlegen.
- Lokalen Mindestladebedarf bei deaktiviertem Solarladen genau festlegen.
```bash
composer check
tests/Symcon/bin/run-symcon-tests.sh single LadestationGateway
```
+276 -43
View File
@@ -1,63 +1,296 @@
# Ladestation Stand-Alone
> Status: Implementiert. Eigenstaendiges Verbrauchermodul mit direkter
> Geraeteanbindung; keine Beziehung zur Ladestation Gateway.
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
Das Modul bindet eine einzelne Ladestation direkt an Enelix EMS an. Es liest
den Fahrzeug- und Ladezustand ueber die Geraete-API, ermittelt die Phasenzahl
wie Enelix 1 und setzt den vom Manager gewaehlten Ladestrom. Es benoetigt keine
Gateway-Instanz und hat keine technische Beziehung zum separaten Modul
**Ladestation Gateway**.
## Funktionsumfang
- direkte Statusabfrage und Steuerung der unterstuetzten Ladestationen,
- Fahrzeug-, Lade- und Phasenerkennung nach der Enelix-1-Logik,
- Leistungsangebote fuer PV- und Peakbetrieb,
- lokale Freigabe und Umschaltung zwischen Solar- und Normalbetrieb,
- Kommunikation mit dem Enelix Manager ueber den Nachrichtenvertrag `4.0`,
- optional sichtbare Diagnosevariablen und schaltbares Debug-Logging,
- automatisierte Tests mit Fake-HTTP-Transport ohne reale Ladestation.
## Unterstuetzte Geraete
| Geraetetyp | 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` |
| go-e Charger Gemini / Gemini flex | `GET /api/status` | `frc` und `amp` |
| smart-me Pico | Pico-Charging-API | Load-Management-Current-API mit Basic Auth |
| PV | ein | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum |
| PV | aus | ausschliesslich die maximale Ladeleistung |
| Peak | ein | ausschliesslich `[0]`; die Ladestation wird gestoppt |
| Peak | aus | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum |
Die Routen, Leistungsfaktoren und Erkennung wurden gezielt aus Enelix 1
uebernommen. Zugangsdaten werden weder als Variable noch im Diagnoseprotokoll
ausgegeben.
Die festen ein- und dreiphasigen Leistungsstufen sowie deren Rundung wurden aus
Enelix 1 uebernommen. Ohne gueltige Manager-Vorgabe faehrt das Modul bei
aktivem Solarladen mit `0 A`; bei ausgeschaltetem Solarladen verwendet es die
maximale angebotene Leistung.
## Verhalten
## Bedienung
- `FahrzeugVerbunden` entspricht bei go-e `car != 1` und bei Pico `State != 1`.
- Die Phasenerkennung verwendet wie Enelix 1 die gemessene Ladeleistung:
ueber 7500 W sind dreiphasig, sonst einphasig.
- Ein bereits verbundenes Fahrzeug gilt bei einem ermittelten Maximalstrom
unter 6 A als geladen.
- PV mit Solarladen bietet `0 W` und alle Ladestufen von 6 A bis zum
konfigurierten Maximum an.
- PV ohne Solarladen bietet ausschliesslich die Maximalleistung an.
- Peak mit Solarladen bietet nur `[0]` an und stoppt die Ladestation.
- Peak ohne Solarladen bietet `[0, ...Ladestufen]` an, damit der Manager die
Ladeleistung wie in Enelix 1 stufenweise reduzieren kann.
- Ohne gueltige Manager-Vorgabe wird bei Solarladen mit 0 A und ohne
Solarladen mit maximaler Leistung gefahren.
| Ident | Wirkung |
| --- | --- |
| `Aktiv` | Gemeinsame lokale EMS-Freigabe. `false` setzt das Angebot auf `[0]` und stoppt die Ladestation. |
| `Ladefreigabe` | Lokale Freigabe fuer das Laden. Diese Variable ist nur mit `EinstellungenInVisu=true` sichtbar. |
| `Solarladen` | Schaltet das oben beschriebene PV-/Peak-Verhalten um. Diese Variable ist nur mit `EinstellungenInVisu=true` sichtbar. |
Die Properties `Ladefreigabe` und `Solarladen` definieren die Startwerte. Eine
Aenderung der jeweiligen Property wird beim Anwenden der Konfiguration in den
lokalen Zustand uebernommen. Eine Bedienung der Variablen verwirft eine noch
gueltige Manager-Vorgabe und berechnet das Leistungsangebot sofort neu.
## Konfiguration
### Gemeinsame EMS-Einstellungen
| Property | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `PrioritaetPV` | Integer | `0` | Prioritaet des Verbrauchers im PV-Betrieb. |
| `PrioritaetPeak` | Integer | `0` | Prioritaet des Verbrauchers im Peakbetrieb. |
| `Meldeintervall` | Integer | `10 s` | Intervall fuer die periodische Vollmeldung an zugeordnete Manager. |
| `VorgabeTimeout` | Integer | `120 s` | Gueltigkeitsdauer einer Manager-Vorgabe ohne Erneuerung. |
| `EinstellungenInVisu` | Boolean | `false` | Blendet `Ladefreigabe` und `Solarladen` als bedienbare Variablen ein. |
| `LoggingEin` | Boolean | `false` | Aktiviert das laufende Debug-Protokoll des Moduls. |
### Ladestation und Diagnose
| Property | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `Geraetetyp` | Integer | `0` | `1` go-e alt, `2` go-e Gemini, `3` smart-me Pico; `0` ist nicht konfiguriert. |
| `Geraeteadresse` | String | leer | IP-Adresse oder Hostname fuer beide go-e-Varianten. |
| `GeraeteID` | String | leer | Geraete-ID fuer die Pico-Statusabfrage. |
| `Seriennummer` | String | leer | Seriennummer fuer die Pico-Stromvorgabe. |
| `Benutzername` | String | leer | Benutzername fuer die Pico-API. |
| `Passwort` | String | leer | Passwort fuer die Pico-API; im Formular als Passwortfeld dargestellt. |
| `MaximalerLadestrom` | Integer | `16 A` | Obergrenze der angebotenen Ladestufen; zulaessig sind 6 bis 32 A. |
| `Abfrageintervall` | Integer | `5 s` | Intervall der Geraetestatusabfrage. |
| `Ladefreigabe` | Boolean | `false` | Startwert der lokalen Ladefreigabe. |
| `Solarladen` | Boolean | `true` | Startwert der lokalen Solarlogik. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Blendet gemeinsame und ladestationsspezifische Diagnosevariablen ein. |
Die internen Properties `Testmodus` und `Testantwort` gehoeren ausschliesslich
zum automatisierten Symcon-Funktionstest und erscheinen nicht im
Konfigurationsformular.
## Variablen
Immer sichtbar:
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `FahrzeugVerbunden` | Boolean / Anzeige | Geraet meldet ein verbundenes Fahrzeug. |
| `FahrzeugGeladen` | Boolean / Anzeige | Enelix-1-Erkennung unter 6 A. |
| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` getrennt, `2` bereit, `3` laedt, `4` geladen. |
| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Strom in A. |
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. |
| `Ladefreigabe` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; lokale Ladeerlaubnis. |
| `Solarladen` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; variable Ladestufen ein/aus. |
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; der Initialwert ist `false`. |
| `FahrzeugVerbunden` | Boolean / Anzeige | Zeigt, ob die API ein verbundenes Fahrzeug meldet. |
| `FahrzeugGeladen` | Boolean / Anzeige | Ergebnis der kompatiblen Enelix-1-Voll-Erkennung. |
| `Fahrzeugstatus` | Integer / Anzeige | Normalisierter Status von `0` bis `4`. |
| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Ladestrom in A. |
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig oder `3` dreiphasig. |
## Properties
Nur mit `EinstellungenInVisu=true` sichtbar und bedienbar:
Neben den gemeinsamen Verbraucher-Properties werden Geraetetyp, Adresse,
Pico-ID, Seriennummer, Zugangsdaten, maximaler Ladestrom, Abfrageintervall
sowie die Startwerte fuer Ladefreigabe und Solarladen konfiguriert.
- `Ladefreigabe`
- `Solarladen`
Die gemeinsamen Verbraucher- und Diagnosevariablen entsprechen der
EMS-Schnittstelle Version 4.0. `LoggingEin` steuert ausschliesslich das
laufende Debug-Protokoll.
Nur mit `DiagnosevariablenAnzeigen=true` sichtbar:
| Ident | Beschreibung |
| --- | --- |
| `Istleistung` | Von der Ladestation gemessene Leistung in W. |
| `Leistungsquelle` | Immer `2` fuer eine gemessene Leistung. |
| `Sollleistung` | Letzte angenommene Manager-Vorgabe in W. |
| `SollwertGueltig` | Zeigt, ob die Vorgabe noch gueltig und im aktuellen Angebot enthalten ist. |
| `Verfuegbar` | Zeigt, ob die Ladestation grundsaetzlich Leistung aufnehmen kann. |
| `AenderungMoeglich` | Zeigt, ob das aktuelle Angebot mehr als eine Leistungsstufe enthaelt. |
| `Stoerung` | Sammelstatus fuer Konfigurations- und Kommunikationsfehler. |
| `Stoertext` | Letzte verstaendliche Fehlerbeschreibung. |
| `LetzterGeraetebefehl` | Letzte HTTP-Methode und URL ohne Zugangsdaten. |
| `LeistungsangebotDiagnose` | Aktuelles Leistungsangebot als JSON-Liste in W. |
Die Regellogik speichert ihren Zustand unabhaengig von der Sichtbarkeit der
Diagnosevariablen. Das Ein- oder Ausblenden veraendert daher nicht das
Regelverhalten.
## Managerkommunikation
Das Modul implementiert
`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet
[ausschliesslich den Nachrichtenvertrag `4.0`](../../Schnittstelle.md). Eine
separate Manager-ID wird nicht konfiguriert. Das Modul akzeptiert Vorgaben nur
von einem Manager, in dessen manueller oder automatischer
Verbraucherzuordnung die Ladestation aktiv eingetragen ist.
An den Manager werden neben den gemeinsamen Feldern diese Zustaende gemeldet:
- `Sollleistung_W`
- `FahrzeugVerbunden`
- `FahrzeugGeladen`
- `Fahrzeugstatus`
- `Phasenzahl`
- `Ladestrom_A`
- `Ladefreigabe`
- `Solarladen`
- `Ladefehler` mit Stoertext
Eine Sollleistung wird nur angenommen, wenn sie im zuletzt berechneten
Leistungsangebot enthalten ist. Nach `VorgabeTimeout` ohne Erneuerung wird sie
ungueltig. Statusaenderungen werden kurz verzoegert und zusaetzlich alle
`Meldeintervall` Sekunden an alle zugeordneten Manager gemeldet.
## Instanzstatus und Fehlerbehandlung
| Status | Bedeutung |
| ---: | --- |
| `102` | Konfiguration und letzte Geraetekommunikation sind gueltig. |
| `201` | Konfiguration ungueltig, beispielsweise fehlende Adresse oder Pico-Zugangsdaten. |
| `202` | Geraetekommunikation oder Antwortauswertung fehlgeschlagen. |
Bei einem Fehler meldet das Modul `Verfuegbar=false`,
`AenderungMoeglich=false` und setzt `Stoerung` sowie `Stoertext`. HTTP-Anfragen
verwenden 5 Sekunden Verbindungs- und 10 Sekunden Gesamt-Timeout. Antworten ab
HTTP-Status 400 sowie unvollstaendige oder ungueltige JSON-Antworten gelten als
Kommunikationsfehler.
## Installation und Inbetriebnahme
1. Im IP-Symcon Module Control den Testing-Branch `develop` der Bibliothek
`https://git.belevo.ch/ENELIX/Enelix-EMS.git` installieren oder
aktualisieren.
2. Unter **Instanz hinzufuegen** nach **Ladestation Stand-Alone** suchen und
eine Instanz anlegen.
3. Den Geraetetyp auswaehlen und die dazugehoerigen Verbindungsdaten eintragen:
bei go-e nur IP-Adresse oder Hostname, bei Pico Geraete-ID, Seriennummer,
Benutzername und Passwort.
4. Den maximal zulaessigen Ladestrom der Installation zwischen 6 und 32 A
einstellen. Diese Grenze ersetzt keine elektrische Absicherung.
5. `Abfrageintervall`, `Meldeintervall`, `VorgabeTimeout` sowie die beiden
Prioritaeten festlegen.
6. Die gewuenschten Startwerte fuer `Ladefreigabe` und `Solarladen` setzen.
7. Fuer die Erstinbetriebnahme `EinstellungenInVisu`,
`DiagnosevariablenAnzeigen` und bei Bedarf `LoggingEin` aktivieren.
8. Die Instanz im Manager manuell aktiv zuordnen oder bei automatischer Suche
in der gefundenen Liste aktivieren.
9. Zuerst ohne Fahrzeug kontrollieren, ob die Statusabfrage fehlerfrei ist.
Danach unter Aufsicht ein Fahrzeug verbinden und `Aktiv` einschalten.
Fuer go-e muss IP-Symcon das Geraet im lokalen Netz per HTTP erreichen koennen.
Fuer Pico ist ausgehender HTTPS-Zugriff auf `api.smart-me.com` erforderlich.
Passwoerter gehoeren ausschliesslich in das dafuer vorgesehene Passwortfeld und
niemals in Repository-Dateien, Skripte oder Screenshots.
## Abnahmecheckliste
- Die Instanz erreicht Status `102`, und `Stoerung` bleibt `false`.
- Ohne Fahrzeug sind `FahrzeugVerbunden=false`, `Phasenzahl=0` und das
Leistungsangebot `[0]`.
- Nach dem Anstecken wird das Fahrzeug erkannt und die gemessene Leistung
plausibel in `Istleistung` und `Ladestrom` abgebildet.
- Bei einer Ladeleistung bis 7500 W wird eine Phase, darueber werden drei
Phasen angezeigt.
- `Aktiv=false` oder `Ladefreigabe=false` stoppt die Ladestation.
- PV mit `Solarladen=true` bietet `0 W` und die Ladestufen an.
- PV mit `Solarladen=false` bietet nur die maximale Leistung an.
- Peak mit `Solarladen=true` stoppt die Ladestation und bietet nur `[0]` an.
- Peak mit `Solarladen=false` bietet `0 W` und die Ladestufen an.
- Eine Manager-Vorgabe ausserhalb des gemeldeten Angebots wird abgewiesen.
- Nach Ablauf des Vorgabe-Timeouts gilt wieder das lokale Ersatzverhalten.
- Im Debug-Protokoll und in `LetzterGeraetebefehl` erscheinen keine
Zugangsdaten.
## Fehlersuche
- Status `201`: Geraetetyp und Pflichtfelder kontrollieren. Bei go-e darf die
Adresse kein Protokoll, keinen Pfad und keine Parameter enthalten. Bei Pico
muessen alle vier Zugangsfelder befuellt sein.
- Status `202`: Erreichbarkeit, DNS, lokale Firewall und API-Antwort pruefen.
`Stoertext` enthaelt den konkreten Kommunikations- oder JSON-Fehler.
- Fahrzeug wird nicht erkannt: Rohstatus der Geraete-API kontrollieren. Der
Adapter erwartet bei go-e `car` und `nrg[11]`, bei Pico `State` und
`ActiveChargingPower`.
- Falsche Phasenzahl bei sehr kleiner oder stehender Ladung: Die kompatible
Enelix-1-Erkennung basiert auf der momentanen Leistung. Fuer eine sichere
Dreiphasenerkennung muss waehrend der Erkennung mehr als 7500 W anliegen.
- Manager-Vorgabe wird abgewiesen: aktive Zuordnung im Manager sowie
`LeistungsangebotDiagnose` und die Betriebsart kontrollieren.
## Tests
Die Adaptertests verwenden einen injizierten Fake-HTTP-Transport. Dadurch
werden fuer alle drei Geraetevarianten Statusantworten, URL, HTTP-Methode,
Authentisierung und Steueraufrufe geprueft, ohne ein reales Geraet anzusprechen.
Der Symcon-Funktionstest nutzt den internen, nicht im Konfigurationsformular
sichtbaren `Testmodus` und prueft zusaetzlich Fahrzeug-/Phasenerkennung,
die vier PV-/Peak-Angebote sowie die Umschaltung ueber den `Solarladen`-Button.
Die Unit- und Adaptertests verwenden einen injizierten Fake-HTTP-Transport.
Damit werden fuer go-e alt, go-e Gemini und smart-me Pico Statusantworten,
URL, HTTP-Methode, Authentisierung und Steueraufrufe ohne reale Hardware
geprueft.
Der Symcon-Funktionstest verwendet den internen `Testmodus` mit simulierten
API-Antworten. Er prueft alle drei Geraetevarianten, Fahrzeug- und
Phasenerkennung, die Leistungsangebote in PV und Peak sowie die Umschaltung
ueber den `Solarladen`-Button.
PHP-Syntax, Struktur- und Unit-Tests des gesamten Repositorys:
```bash
composer check
```
Funktionstest nur fuer die Ladestation gegen IP-Symcon 8:
```bash
tests/Symcon/bin/run-symcon-tests.sh single LadestationStandAlone
```
Vollstaendiger Symcon-Modultestlauf:
```bash
tests/Symcon/bin/run-symcon-tests.sh all
```
Aufbau, Testvertrag und Ergebnisdateien sind unter
[`docs/testing/README.md`](../../testing/README.md) beschrieben.
+159 -8
View File
@@ -8,15 +8,28 @@ kommen aus den Verbrauchermeldungen.
## 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 |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Regelung ein/aus; Start `false`. |
| `Betriebsart` | String / Anzeige | `Inaktiv`, `PV` oder `Peak`. |
| `Netzleistung` | Float / Anzeige | Aktuelle Netzleistung in W; positiv Bezug, negativ Einspeisung. |
| `PVLeistungArchiv` | Float / Logging | Normalisierte PV-Leistung in W. |
| `HausverbrauchLeistungArchiv` | Float / Logging | Normalisierter Hausverbrauch in W. |
| `NetzleistungArchiv` | Float / Logging | Normalisierte Netzleistung in W; positiv Bezug, negativ Einspeisung. |
| `BatterieleistungArchiv` | Float / Logging | Batterieleistung in W; positiv Laden, negativ Entladen. |
| `PVEnergie` | Float / Zaehler | Integrierte PV-Erzeugung in kWh. |
| `Hausenergie` | Float / Zaehler | Integrierter Hausverbrauch in kWh. |
| `NetzbezugEnergie` | Float / Zaehler | Integrierter Netzbezug in kWh. |
| `EinspeisungEnergie` | Float / Zaehler | Integrierte Einspeisung in kWh. |
| `BatterieLadenEnergie` | Float / Zaehler | Integrierte Batterieladung in kWh. |
| `BatterieEntladenEnergie` | Float / Zaehler | Integrierte Batterieentladung in kWh. |
| `NetzleistungGueltig` | Boolean / Logging | Messquelle vorhanden und aktuell. |
| `WirksameLastspitzengrenze` | Float / Logging | Aktuelle feste oder monatliche 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. |
| `VerbraucherAnzahl` | Integer / Logging | Anzahl zugeordneter 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. |
| `SollwertSolarladen` | Float | `0` W; gewünschte Netzleistung im Solarladebetrieb. |
| `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. |
| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. |
| `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. |
| `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. |
| `LoggingEin` | Boolean | `false`; laufende Meldungen im Debug-Fenster ausgeben. |
| `EnergieaufzeichnungAktiv` | Boolean | `true`; managergefuehrte Leistungs- und Energieaufzeichnung aktivieren. |
| `MesswerteAnzeigen` | Boolean | `false`; einzelne Manager-Messwerte in der Objektstruktur anzeigen. |
| `EnergyPieAnzeigen` | Boolean | `false`; Energy Pie unter dem Manager anlegen oder entfernen. |
| `EnergiediagrammeAnzeigen` | Boolean | `false`; native Leistungs- und Energiediagramme anlegen oder entfernen. |
| `FunFactsAnzeigen` | Boolean | `false`; responsive Energy Facts anlegen oder entfernen. |
| `EnergieflussAnzeigen` | Boolean | `false`; native Energy Distribution anlegen oder entfernen. |
| `LeistungsaufzeichnungMinuten` | Auswahl | `1`; Verdichtung auf 1 Minute, 5 Minuten oder 1 Stunde. |
| `LeistungLoeschenMonate` | Integer | `12`; Leistungswerte nach zwoelf Monaten loeschen, `0` deaktiviert die Loeschung. |
| `EnergieaufzeichnungMinuten` | Auswahl | `5`; Verdichtung auf 1 Minute, 5 Minuten oder 1 Stunde. |
| `EnergieVerdichtenMonate` | Integer | `12`; Energiezaehler nach zwoelf Monaten auf Tageswerte verdichten. |
| `EnergieLoeschenMonate` | Integer | `0`; Energiezaehler nicht loeschen. |
Es gibt keine Sollwertquellenauswahl und keine zweite Prioritätseinstellung im
Manager. Im Modus `Aus` bleibt Solarladen aktiv, nur die Peak-Begrenzung entfällt.
@@ -60,6 +95,115 @@ Die Monatsgrenzen werden in den Grundeinstellungen über eine Schaltfläche
ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut
ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren.
## Energieaufzeichnung und Visualisierung
Sobald Netzleistung und mindestens eine PV-Istleistung in der Anlagentopologie
konfiguriert sind, tastet der Manager die Quellen minuetlich ab. PV-Leistung ist
die Summe der den PV-Flaechen zugeordneten Wechselrichter. Batterieleistung und
der kapazitaetsgewichtete SOC stammen aus den Batteriespeichern. Positive
Batterieleistung bedeutet Laden, negative Entladen. Der Hausverbrauch wird als
`PV + Netz - Batterie` bilanziert. Sind Netzbezugs-, Einspeise-, PV-Erzeugungs-
sowie gegebenenfalls Batterie-Lade- und Entladezaehler vollstaendig hinterlegt,
verwendet der Manager deren Deltas fuer die Energieaufzeichnung. Die Hausenergie
wird dann als `PV + Netzbezug + Batterieentladung - Einspeisung - Batterieladung`
berechnet. Ohne vollstaendigen Zaehlerdatensatz integriert der Manager weiterhin
die Leistungswerte. Dieselben Quellen versorgen ohne zweite Eingabe auch die
Prognosetelemetrie. Ausfallluecken ueber fuenf Minuten werden bei der
Leistungsintegration nicht nachberechnet.
Batteriespeicher bleiben als physische Komponenten samt Messquellen in der
Anlagentopologie. Ein zusaetzliches Batterieverbrauchermodul beschreibt dagegen
Regelung, Leistungsangebot und Betriebszustand; beide Rollen sind bewusst
getrennt.
Die eigenen Variablen werden automatisch im ersten Archive Control aktiviert.
Leistungen verwenden die Standardaggregation, Energievariablen den Zaehlermodus.
Standardmaessig werden Leistungen auf eine Minute verdichtet und nach zwoelf
Monaten geloescht. Energie wird auf fuenf Minuten verdichtet, nach zwoelf
Monaten auf Tageswerte reduziert und nie geloescht. Aenderungen an diesen
Regeln werden idempotent ueber die Archiv-API gesetzt und reaggregiert.
Vier Schalter im Bereich `Energieaufzeichnung` verwalten die
Visualisierungen idempotent. Beim Einschalten werden die Objekte direkt unter
dem Manager angelegt oder aktualisiert. Beim Ausschalten werden ausschliesslich
die vom Manager anhand ihrer festen Kennung und ihres Typs erkannten Objekte
entfernt beziehungsweise die native Energy Distribution ausgeblendet:
- Energy Pie aus Enelix Utils mit den vier relevanten Energiezaehlern,
- zwei native IP-Symcon-Diagrammmedien fuer Leistungen und Energien,
- responsive Energy Facts mit Solar-, Netz-, Haus- und Vergleichswerten,
- native Energy Distribution fuer PV, Netz, Haus und Batterie.
## Anlagenweite Einspeisebegrenzung
Die Einspeisebegrenzung ist Bestandteil von Peak Shaving und arbeitet auf der
Messung am Netzanschlusspunkt. Fuer jeden regelbaren PV- oder Hybridwechselrichter
werden die Istleistungsvariable und eine bedienbare Begrenzungsvariable
konfiguriert. Der Stellwert kann als Prozent der AC-Nennleistung oder als
absolute Leistung in Watt ausgegeben werden.
Der Manager berechnet eine einzige Grenze fuer die Gesamtanlage. Beim Abregeln
verteilt er sie nach der tatsaechlichen Erzeugung, beim Freigeben nach der
AC-Nennleistung auf alle angebotenen Wechselrichter. Jede gemessene Abweichung
wird stufenlos nachgefuehrt; ein zusaetzliches Toleranzfenster wird nicht
verwendet. Beim Abschalten des Managers oder der Funktion werden zuvor gesetzte
Grenzen kontrolliert auf die jeweilige
Nennleistung zurueckgesetzt. Wechselrichter ohne beide Register werden nicht
geregelt; sobald eines der beiden Register gesetzt ist, muessen beide gueltig
sein und das Begrenzungsregister eine IP-Symcon-Aktion besitzen.
Der intelligente Netzfahrplan verwendet die PV-, Verbrauchs- und
Netzleistungsprognose sowie Tarife und Batteriespeicher. Liegt im
Prognosezeitraum mehr PV-Ertrag als zulaessige Einspeisung vor, wird dieses
Potenzial als flexibler Verbrauch beziehungsweise Speicherladung eingeplant.
Damit werden Verbraucher in ertragreiche Zeitfenster verschoben, bevor der
harte Anlagenregler die Wechselrichter reduziert. Ein fehlender oder
abgelaufener Fahrplan fuehrt automatisch zum konfigurierten
`SollwertSolarladen` zurueck; die harte Einspeisebegrenzung bleibt unabhaengig
davon aktiv.
## Anlagentopologie und Prognoseexport
Der Manager ist die fuehrende Quelle fuer die technischen Stammdaten. Die
Konfiguration trennt Wechselrichter, PV-Flaechen und Batteriespeicher. AC-Leistung
wird am Wechselrichter, DC-Leistung an der PV-Flaeche gepflegt. Individuelle
AC-Einspeise- und Bezugsgrenzen werden nicht erfasst, weil die Begrenzung
anlagenweit am Netzanschlusspunkt erfolgt. Modulanzahl und Modulleistung sind
optional und muessen gemeinsam gesetzt werden.
PV-Flaechen und Batterien referenzieren ihren Wechselrichter ueber dessen stabile
ID. Bei `hybrid` koennen beide denselben Wechselrichter verwenden. Der Export
`ENELIX_AnlagentopologieExportieren($InstanzID)` weist dann eine gemeinsame
AC-Grenze aus, damit PV und Batterie die Nennleistung nicht unabhaengig doppelt
beanspruchen.
Der JSON-Export enthaelt die Vertragsversion `1.0`, die Lizenz-Installations-ID,
die drei Komponentenlisten, gemeinsame AC-Nennleistungen und aggregierte Summen
fuer die schrittweise Prognoseanbindung. Interne Symcon-Variablen-IDs werden
nicht exportiert. Tarif- und Variantenparameter bleiben im Prognoseportal. Bei
aktivierter Prognose wird eine nichtleere Topologie beim Speichern automatisch ueber
einen separaten, widerrufbaren Installationszugang synchronisiert. Der
Lizenzserver liefert diesen Zugang nur auf ausdrueckliche Geraeteanforderung;
der Manager entfernt ihn vor dem Speichern aus der Lease und haelt ihn in einem
internen Attribut. Der Lizenzcode wird nicht als API-Token verwendet. Ein
Synchronisationsfehler erscheint im `Prognosestatus`, blockiert die lokale
EMS-Regelung aber nicht.
Der Forecast-Schalter zeigt direkt an, ob fuer die Manager-ID eine passende
Kombination `forecast_pv` plus `forecast_load` oder `grid_schedule` vorhanden ist. Nach der Aktivierung sendet der
Manager PV-Leistung, berechneten Hausverbrauch, Netzleistung und Batterie-SOC
mit UTC-Zeitstempel im eingestellten Sendeintervall. Dieses Intervall bestimmt
also, wie oft aktuelle Messwerte zum Prognosedienst hochgeladen werden; es ist
kein Regelintervall. Das Minimum von 60 Sekunden
passt zum Rate-Limit des Lizenzportals. Die Uebertragung nutzt denselben
widerrufbaren Installationszugang wie die Topologie; Lizenzcode und Geraet Token
werden weder als Telemetriefelder noch im Debug-Log ausgegeben. Bei HTTP 401
oder 403 verwirft der Manager den Geraetezugang und fordert ihn bei der naechsten
Lizenzpruefung neu an.
Die Entscheidung und ihre Alternativen sind in
[ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert.
## Lizenzierung
Der Manager arbeitet nur mit einer gueltigen Manager-Lizenz. Das Lizenzfeld steht
@@ -94,8 +238,11 @@ wird vom Lizenzserver abgelehnt.
| Berechtigung | Freigegebene Funktion |
| --- | --- |
| `manager_standard` | Manager-Grundregelung ohne Lastspitzenmodus. |
| `manager_peak` | Manager-Grundregelung einschliesslich konstantem oder monatlichem Lastspitzenmodus. |
| `manager_standard` | Manager-Grundregelung ohne Peak Shaving. |
| `manager_peak` | Bezugs- und Einspeisebegrenzung am Netzanschlusspunkt. |
| `forecast_pv` | PV-Ertragsprognose; interne Modellvarianten bleiben verborgen. |
| `forecast_load` | Verbrauchsprognose; interne Modellvarianten bleiben verborgen. |
| `grid_schedule` | Intelligenter Netzfahrplan inklusive PV- und Verbrauchsprognose sowie Vermeidung von Abregelung. |
Wird mit `manager_standard` ein Lastspitzenmodus aktiviert, bleibt der Manager
mit dem Hinweis `Peak Shaving ist nicht lizenziert` gesperrt. `manager_peak`
@@ -111,11 +258,15 @@ bis `offlineUntil` verwendbar. Der aktuelle Entwicklungsvertrag setzt diesen
Zeitpunkt ungefaehr 14 Tage nach Ausstellung. Danach sperrt der Manager die
Regelung, bis der Lizenzserver wieder eine gueltige Antwort liefert.
Die Installations-ID und die Lease liegen in internen Instanzattributen. Bei
einer Migration muss deshalb die vollstaendige Manager-Instanz mitsamt ihren
Die Installations-ID und die Lease liegen in internen Instanzattributen. Die
ID wird erst nach dem Laden bestehender Attribute initialisiert und bleibt bei
Modulupdates, Modul-Neuladen und einem normalen Neustart unveraendert. Bei einer
Migration muss trotzdem die vollstaendige Manager-Instanz mitsamt ihren
Attributen uebernommen werden. Eine neu erzeugte Instanz erhaelt eine andere
Installations-ID und kann einen bereits gebundenen Code nicht selbststaendig
uebertragen.
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
+7 -6
View File
@@ -1,8 +1,9 @@
# EMS-Module und Modulentwürfe
> Manager, Warmwassererwaermer, Verbraucher 1-Stufig und Ladestation
> Stand-Alone sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren
> Ordner enthalten Besprechungsgrundlagen.
> Manager, Warmwassererwaermer, Pufferspeicher, Batterie, Verbraucher 1-Stufig,
> Waermepumpe, Ladestation Stand-Alone, Easee Gateway und Ladestation Gateway
> sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren Ordner
> enthalten Besprechungsgrundlagen.
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
@@ -11,14 +12,14 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte.
| Modul | Rolle |
| --- | --- |
| [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse (implementiert) |
| [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers |
| [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers (implementiert) |
| [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung (implementiert) |
| [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve |
| [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) |
| [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready |
| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung (implementiert) |
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Ladestation am Easee Gateway |
| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation |
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Eventbasierte Ladestation am Easee Gateway (implementiert) |
| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation (implementiert) |
## Review-Regel
+76 -34
View File
@@ -1,47 +1,89 @@
# Wärmepumpe
> Status: Diskussionsentwurf.
> Status: Implementiert.
Die Kontaktwirkung ist als Sperrkontakte oder SG Ready konfigurierbar. Die
elektrische Leistung kann fehlen, berechnet oder gemessen werden.
Das Modul bindet eine Wärmepumpe über zwei schaltbare Boolean-Ausgänge in den
Enelix-Manager ein. Die Ausgänge können als getrennte Kontakte für Sperre und
Erhöhung oder als SG-Ready-Eingänge verwendet werden. Die Regelung arbeitet mit
dem Nachrichtenvertrag 4.0 und meldet für PV und Peak ein
zustandsabhängiges Leistungsangebot.
## 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. |
| `SperreAktiv` | Boolean / Anzeige | Aktuelle Sperranforderung. |
| `ErhoehungAktiv` | Boolean / Anzeige | Aktuelle Erhöhungsanforderung. |
| `SGReadyZustand` | Integer / Anzeige | Bei SG Ready Zustand 0–3. |
| `Laufzeit` | Integer / Anzeige | Kumulierte bestätigte Laufzeit in Sekunden. |
| `Waermepumpenzustand` | Integer | `1` normal/aus, `2` normal/laufend, `3` erhöht, `4` gesperrt |
| `WaermepumpeLaeuft` | Boolean | Bestätigter Betriebsstatus |
| `SperreAktiv` | Boolean | Sperrkontakt ist aktiv |
| `ErhoehungAktiv` | Boolean | Erhöhungskontakt ist aktiv |
| `SGReadyZustand` | Integer | Bitwert der beiden logischen Kontakte |
| `Laufzeit` | Integer | Kumulierte bestätigte Laufzeit in Sekunden |
## 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 |
| --- | --- | --- |
| `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. |
## Adaption aus Enelix 1
Zeit- und wetterabhängiges Sperren beziehungsweise Erhöhen aus dem Altmodul
entfällt.
| Bereich | Behandlung in Enelix 2 |
| --- | --- |
| Grundzustände Normal, Sperre und Erhöhung | angepasst übernommen |
| Zwei physische Kontakte | angepasst und mit Invertierung sowie Rücklesekontrolle umgesetzt |
| Leistungsangebot an den Manager | neu nach Nachrichtenvertrag 4.0 implementiert |
| Mindestlauf- und Sperrzeiten | neu als ereignisbasierte Schutzlogik implementiert |
| Wetter-, Wolken- und Sonnenaufgangslogik | nicht uebernommen |
| Warmwasser-Schwellwert im Wärmepumpenmodul | nicht uebernommen |
| Fester zyklischer Fünf-Sekunden-Regler | nicht uebernommen; Ereignisse und Einmal-Timer |
## Zustand
## Lizenzierung und Manager
`Waermepumpenzustand`, `SperreAktiv`, `ErhoehungAktiv` beziehungsweise
`SGReadyZustand` als Status, `Laufzeit_s` als Istwert und
`Kommunikationsfehler` als Störung.
Der Manager erkennt das Modul über die Modul-ID
`{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}`. Es verwendet das bestehende
Lizenzkontingent `consumer_single`, das im Portal als einstufiger Verbraucher
mit Wärmepumpe als Beispiel angeboten wird.
## Offene Punkte
## Sicherheitshinweise
- Der Formularbutton „Wassererwärmer über Wärmepumpe betreiben“ bleibt ohne
Regelwirkung, bis Anschluss und Verriegelung gemeinsam definiert sind.
Die beiden Ausgänge müssen unterschiedliche, schaltbare Boolean-Variablen sein.
Die Rückmeldung ist verpflichtend: entweder eine numerische Leistungsmessung
oder ein Boolean-Betriebsstatus. Bei ungültiger Konfiguration, fehlgeschlagenem
Schalten oder widersprüchlicher Rücklesung setzt das Modul seinen Status auf
Fehler und meldet sich dem Manager als nicht verfügbar.
+11 -9
View File
@@ -42,7 +42,9 @@ werden, wenn sie vor dem Lauf nicht existierten.
- `single`: genau die als Auswahl übergebenen 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.
@@ -83,15 +85,15 @@ Zusätzlich schreibt der Runner die Zusammenfassung in das IP-Symcon-Log.
## CI-Regeln
Bei jedem Push laufen sämtliche PHPUnit-Tests. Auf `develop` werden zusätzlich
die von den geänderten Pfaden betroffenen Symcon-Modultests ausgeführt. Bei
gemeinsamen Framework- oder Vertragsänderungen läuft die vollständige Suite.
Für `beta` ist immer `all` vorgeschrieben.
Bei jedem Push laufen sämtliche PHPUnit-Tests und die PHP-Syntaxprüfung. Die
Symcon-Modultests werden bis zur Verfügbarkeit eines geschützten Runners auf
der isolierten IP-Symcon-8.0-Instanz des Agent-Servers ausgeführt. Vor jeder
Übernahme nach `beta` ist ein erfolgreicher Lauf im Modus `all` verbindlich.
Der erzeugte JSON- und JUnit-Bericht gehört zum Freigabenachweis.
Die Symcon-Jobs benötigen einen geschützten Runner mit dem Label `symcon-8`,
lokalem Zugriff auf eine isolierte IP-Symcon-8.x-Instanz und den installierten
Repository-Stand. Ohne diesen Runner ist die Richtlinie dokumentiert, aber
nicht technisch durchsetzbar.
Eine spätere CI-Automatisierung benötigt einen geschützten Runner mit dem Label
`symcon-8`, lokalem Zugriff auf die isolierte IP-Symcon-Instanz und den exakt
zu prüfenden Repository-Stand.
## Checkliste für neue Module
+60 -17
View File
@@ -2,13 +2,22 @@
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),
- einen einstufigen Entfeuchter (0,9 kW),
- simulierte PV-Erzeugung, Grundlast, Netzleistung und Boilertemperaturen,
- native Symcon-Instanz `Energy Distribution` für den Energiefluss,
- eigene Kachelansichten mit allen Laufzeitparametern je Boiler,
- kombinierten Verlauf für PV-Leistung, Hausverbrauch und Netzleistung,
- einen zweistufigen Pufferspeicher (1,8/3,6 kW) mit Heizkurve,
- einen bidirektionalen Batteriespeicher (10 kWh, +/-3,5 kW),
- eine technische Anlagentopologie mit Hybridwechselrichter, PV-Fläche und
Batteriespeicher für die Prognoseanbindung,
- simulierte Istleistungs- und Leistungsbegrenzungsregister des Wechselrichters,
- eine aktive anlagenweite Einspeisebegrenzung auf 3.000 W mit 100 W Toleranz,
- Prognosetelemetrie aus PV, Hausverbrauch, Netzleistung und Batterie-SOC im
60-Sekunden-Intervall,
- simulierte PV-Erzeugung, variable Bewölkung, Tageslastgang, Zusatzlast,
Außentemperatur, Wärmeverluste, Netzleistung und Speicherzustände,
- native Symcon-Instanz `Energy Distribution` für acht Energieflussknoten,
- eigene Kachelansichten mit Laufzeitparametern für Boiler, Puffer und Batterie,
- kombinierten Verlauf für PV-Leistung, Hausverbrauch, EMS-Leistung und Netzleistung,
- kombinierten Verlauf der kumulierten PV-, Haus-, Bezugs- und Einspeiseenergie,
- Archivierung der Leistungs-, Energie- und Temperaturwerte.
@@ -27,26 +36,60 @@ print_r($result);
Ein weiterer Lauf aktualisiert dieselbe Anlage anhand stabiler Idents und erzeugt keine Duplikate.
Bestehende, nicht zur Demo gehörende Objekte werden nicht verändert oder gelöscht.
Der Bootstrap hinterlegt absichtlich keinen Lizenzcode und überschreibt eine
bereits konfigurierte Manager-Lizenz nicht. Bei einer neuen Anlage wird die
Struktur vollständig erstellt; der Manager bleibt bis zur regulären
Lizenzaktivierung im Konfigurationsformular auf Status 203. Solange der Manager
nicht freigegeben ist, bleiben seine Zuordnung und die Verbraucher deaktiviert,
damit keine Lizenzfehler protokolliert werden. Nach der Aktivierung den
Bootstrap erneut ausführen; er ordnet dann alle fünf Teilnehmer zu und startet
die Regelung.
Die Demo übernimmt die einstellbare PV-Spitzenleistung als AC- und
DC-Anlagenleistung. Für die feste Beispieltopologie gelten 30 Grad Neigung und
Südausrichtung. Der 10-kWh-Speicher nutzt den gemeinsamen Hybridwechselrichter;
seine dynamischen Lade- und Entladegrenzen werden beim Bootstrap in die
technischen Stammdaten übernommen. Bei aktiver Manager-Lizenz uebertraegt der
Manager zusaetzlich PV-Leistung, Hausverbrauch, Netzleistung und Batterie-SOC
alle 60 Sekunden an den Enelix-Prognosedienst. Die Demo verwendet ein absolutes
Wechselrichter-Leistungsregister in Watt. Der Manager begrenzt die tatsächliche
PV-Produktion so, dass die Gesamtanlage höchstens 3.000 W einspeist; innerhalb
einer Toleranz von 100 W bleibt der zuletzt gesetzte Stellwert bestehen. Der
intelligente Netzfahrplan bleibt deaktiviert, solange dessen Backendfassung nur
als ausstehende Version bereitliegt.
## Bedienung
In der Kachelvisualisierung öffnet die Startkategorie `Enelix 2 Demoanlage`.
Unter `Simulation` lassen sich Tageslauf, Tageszeit, Geschwindigkeit,
PV-Spitzenleistung, Bewölkung und Haus-Grundlast verändern. Der Manager verteilt
den verfügbaren PV-Überschuss nach Priorität auf beide Boiler und den Entfeuchter.
dynamische Profile, PV-Spitzenleistung, mittlere Bewölkung, Haus-Grundlast und
eine ungeregelte Zusatzlast verändern. Bei aktivierten dynamischen Profilen
entstehen reproduzierbare Morgen- und Abendspitzen, ziehende Bewölkung und ein
Tagesgang der Außentemperatur. Der Manager verteilt den verfügbaren
PV-Überschuss nach Priorität auf beide Boiler, Entfeuchter, Pufferspeicher und
Batterie. Bei hoher PV-Erzeugung ist die Abregelung an den technischen Variablen
`PV-Wechselrichter Istleistung` und `PV-Wechselrichter Leistungsgrenze` sowie an
der auf etwa 3.000 W begrenzten Netzeinspeisung sichtbar.
`Übersicht` zeigt die native Symcon-Energieverteilung. Die Kacheln `Boiler 1`
und `Boiler 2` öffnen direkt sämtliche Bedien-, Status- und Diagnosevariablen,
ohne zuerst das Instanz-Konfigurationsformular zu zeigen. Unter `Leistung und
Energie` liegen die beiden kombinierten Zeitdiagramme und die Energiezähler.
Die Energiesummen verwenden die beschleunigte Simulationszeit.
`Übersicht` zeigt die native Symcon-Energieverteilung. Die Kacheln `Boiler 1`,
`Boiler 2`, `Pufferspeicher` und `Batteriespeicher` öffnen direkt die jeweiligen
Bedien-, Status- und Diagnosevariablen, ohne zuerst das
Instanz-Konfigurationsformular zu zeigen. Positive Batterieleistung bedeutet
Laden, negative Leistung Entladen. Unter `Leistung und Energie` liegen die
beiden kombinierten Zeitdiagramme und die Energiezähler. Die Energiesummen,
Temperaturmodelle und der Ladezustand verwenden die beschleunigte
Simulationszeit.
## Validierung
- Alle vier Enelix-Instanzen müssen Status 102 besitzen.
- Der Manager muss drei Verbraucher melden.
- Bei ausreichendem PV-Überschuss müssen Verbraucher schalten.
- Netzleistung, PV-Leistung, Hausverbrauch, Verbraucherleistungen, Energiesummen und Temperaturen werden archiviert.
- Die native Energieverteilung muss sechs Knoten enthalten.
- Die fünf Verbraucher müssen Status 102 besitzen; der Manager nach Lizenzaktivierung ebenfalls (vorher erwarteter Status 203).
- Der Manager muss fünf Teilnehmer aus vier unterschiedlichen Modultypen melden.
- Bei ausreichendem PV-Überschuss müssen thermische Verbraucher schalten und die Batterie laden.
- Bei höherem verbleibendem PV-Überschuss muss das Wechselrichterregister die PV-Leistung so begrenzen, dass ungefähr 3.000 W ins Netz eingespeist werden.
- Wechselrichter-Istleistung und Leistungsgrenze müssen in der technischen Kategorie vorhanden und im Manager zugeordnet sein.
- Puffertemperatur, Außentemperatur und Batterie-Ladezustand müssen sich über den Tageslauf verändern.
- Netzleistung, PV-Leistung, Hausverbrauch, EMS-Leistung, Einzelverbräuche, Speicherleistung, Energiesummen und Temperaturen werden archiviert.
- Die native Energieverteilung muss acht Knoten enthalten.
- Beide Diagrammkacheln müssen mindestens drei beschriftete Zeitreihen anzeigen.
- Ein erneuter Installationslauf muss dieselbe Root-ID zurückgeben.
+414 -79
View File
@@ -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_BOILER_MODULE = '{B7C54AF4-AD7D-4FE4-B75D-203693906251}';
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_TILE_MODULE = '{B5B875BB-9B76-45FD-4E67-2607E45B3AC4}';
const ENELIX_DEMO_ARCHIVE_MODULE = '{43192F0B-135B-4CE7-A0A7-1475603F3060}';
@@ -28,15 +30,17 @@ function enelixDemoInstall(): array
enelixDemoEnsureProfiles();
$legacyFlow = enelixDemoEnsureVariable($dashboard, 'Energiefluss', 'Energiefluss (Legacy)', 3, '~HTMLBox', '', 90);
$legacyFlow = enelixDemoEnsureVariable($dashboard, 'Energiefluss', 'Energiefluss (Legacy)', 3, '~HTMLBox', '', 900);
IPS_SetHidden($legacyFlow, true);
$pvDisplay = enelixDemoEnsureVariable($dashboard, 'PVLeistung', 'PV-Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 20);
$houseDisplay = enelixDemoEnsureVariable($dashboard, 'Hausverbrauch', 'Hausverbrauch gesamt', 2, 'ENELIX.Demo.PowerPositive', 0.0, 30);
$loadDisplay = enelixDemoEnsureVariable($dashboard, 'Verbraucherleistung', 'Geregelte Verbraucher', 2, 'ENELIX.Demo.PowerPositive', 0.0, 40);
$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);
$boiler1Power = enelixDemoEnsureVariable($dashboard, 'Boiler1Leistung', 'Boiler 1 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 60);
$boiler2Power = enelixDemoEnsureVariable($dashboard, 'Boiler2Leistung', 'Boiler 2 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 70);
$dryerPower = enelixDemoEnsureVariable($dashboard, 'EntfeuchterLeistung', 'Entfeuchter Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80);
$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);
$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);
$hour = enelixDemoEnsureVariable($simulation, 'Tageszeit', 'Simulierte Tageszeit', 2, 'ENELIX.Demo.Hour', 8.0, 20);
$speed = enelixDemoEnsureVariable($simulation, 'Geschwindigkeit', 'Minuten je Schritt', 1, 'ENELIX.Demo.Speed', 10, 30);
$peak = enelixDemoEnsureVariable($simulation, 'PVSpitze', 'PV-Spitzenleistung', 2, 'ENELIX.Demo.PowerPositive', 7000.0, 40);
$cloud = enelixDemoEnsureVariable($simulation, 'Bewoelkung', 'Bewölkung', 1, 'ENELIX.Demo.Percent', 20, 50);
$base = enelixDemoEnsureVariable($simulation, 'Grundlast', 'Haus-Grundlast', 2, 'ENELIX.Demo.PowerPositive', 900.0, 60);
$dynamic = enelixDemoEnsureVariable($simulation, 'DynamischeProfile', 'Dynamische Tagesprofile', 0, '~Switch', true, 40);
$peak = enelixDemoEnsureVariable($simulation, 'PVSpitze', 'PV-Spitzenleistung', 2, 'ENELIX.Demo.PowerPositive', 9000.0, 50);
$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(
$technical,
@@ -61,6 +67,13 @@ function enelixDemoInstall(): array
"<?php\nSetValue((int) \$_IPS['VARIABLE'], (bool) \$_IPS['VALUE']);\n",
10
);
$registerScript = enelixDemoEnsureScript(
$technical,
'RegisterAktion',
'Simulierte Register',
"<?php\nSetValue((int) \$_IPS['VARIABLE'], \$_IPS['VALUE']);\n",
15
);
$tickScript = enelixDemoEnsureScript(
$technical,
'SimulationTick',
@@ -76,7 +89,7 @@ function enelixDemoInstall(): array
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);
}
@@ -84,36 +97,59 @@ function enelixDemoInstall(): array
$temp1 = enelixDemoEnsureVariable($technical, 'Boiler1Temperatur', 'Boiler 1 Temperatur', 2, '~Temperature', 49.0, 100);
$temp2 = enelixDemoEnsureVariable($technical, 'Boiler2Temperatur', 'Boiler 2 Temperatur', 2, '~Temperature', 47.0, 110);
$boiler11 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe1', 'Boiler 1 Stufe 1', 0, '~Switch', false, 120);
$boiler12 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe2', 'Boiler 1 Stufe 2', 0, '~Switch', false, 130);
$boiler21 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe1', 'Boiler 2 Stufe 1', 0, '~Switch', false, 140);
$boiler22 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe2', 'Boiler 2 Stufe 2', 0, '~Switch', false, 150);
$dryContact = enelixDemoEnsureVariable($technical, 'EntfeuchterKontakt', 'Entfeuchter Kontakt', 0, '~Switch', false, 160);
$chartUpdated = enelixDemoEnsureVariable($technical, 'LetztesDiagrammUpdate', 'Letztes Diagramm-Update', 1, '', 0, 170);
$bufferTemp = enelixDemoEnsureVariable($technical, 'PufferTemperatur', 'Puffertemperatur', 2, '~Temperature', 36.0, 120);
$outsideTemp = enelixDemoEnsureVariable($technical, 'AussenTemperatur', 'Aussentemperatur', 2, '~Temperature', 6.0, 130);
$boiler11 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe1', 'Boiler 1 Stufe 1', 0, '~Switch', false, 200);
$boiler12 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe2', 'Boiler 1 Stufe 2', 0, '~Switch', false, 210);
$boiler21 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe1', 'Boiler 2 Stufe 1', 0, '~Switch', false, 220);
$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);
$flowPv = enelixDemoEnsureVariable($technical, 'EnergieflussPVkW', 'PV-Leistung', 2, '~Power', 0.0, 300);
$flowGrid = enelixDemoEnsureVariable($technical, 'EnergieflussNetzkW', 'Netzleistung', 2, '~Power', 0.0, 310);
$flowBase = enelixDemoEnsureVariable($technical, 'EnergieflussGrundlastkW', 'Haus-Grundlast', 2, '~Power', 0.0, 320);
$flowBoiler1 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler1kW', 'Boiler 1', 2, '~Power', 0.0, 330);
$flowBoiler2 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler2kW', 'Boiler 2', 2, '~Power', 0.0, 340);
$flowDryer = enelixDemoEnsureVariable($technical, 'EnergieflussEntfeuchterkW', 'Entfeuchter', 2, '~Power', 0.0, 350);
$flowHouse = enelixDemoEnsureVariable($technical, 'DiagrammHausverbrauchkW', 'Hausverbrauch gesamt', 2, '~Power', 0.0, 360);
$flowLoads = enelixDemoEnsureVariable($technical, 'DiagrammVerbraucherkW', 'Geregelte Verbraucher', 2, '~Power', 0.0, 370);
$flowPv = enelixDemoEnsureVariable($technical, 'EnergieflussPVkW', 'PV-Leistung', 2, '~Power', 0.0, 500);
$flowGrid = enelixDemoEnsureVariable($technical, 'EnergieflussNetzkW', 'Netzleistung', 2, '~Power', 0.0, 510);
$flowBase = enelixDemoEnsureVariable($technical, 'EnergieflussGrundlastkW', 'Haus-Grundlast', 2, '~Power', 0.0, 520);
$flowBoiler1 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler1kW', 'Boiler 1', 2, '~Power', 0.0, 530);
$flowBoiler2 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler2kW', 'Boiler 2', 2, '~Power', 0.0, 540);
$flowDryer = enelixDemoEnsureVariable($technical, 'EnergieflussEntfeuchterkW', 'Entfeuchter', 2, '~Power', 0.0, 550);
$flowBuffer = enelixDemoEnsureVariable($technical, 'EnergieflussPufferkW', 'Pufferspeicher', 2, '~Power', 0.0, 560);
$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 ([
$flowPv, $flowGrid, $flowBase, $flowBoiler1,
$flowBoiler2, $flowDryer, $flowHouse, $flowLoads,
$flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2,
$flowDryer, $flowBuffer, $flowBattery, $flowHouse, $flowLoads,
] as $flowVariableId) {
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);
}
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);
$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);
$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, [
'Boilerfuehler_PT1' => $temp1,
@@ -157,6 +193,52 @@ function enelixDemoInstall(): array
'Mindestausschaltdauer' => 2,
'Mindestlaufzeit' => 0,
'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,
'Meldeintervall' => 5,
'VorgabeTimeout' => 30,
@@ -178,6 +260,8 @@ function enelixDemoInstall(): array
enelixDemoEnergyNode($flowBoiler1, 'Boiler 1', 'consumer', $temp1),
enelixDemoEnergyNode($flowBoiler2, 'Boiler 2', 'consumer', $temp2),
enelixDemoEnergyNode($flowDryer, 'Entfeuchter', 'consumer'),
enelixDemoEnergyNode($flowBuffer, 'Pufferspeicher', 'consumer', $bufferTemp),
enelixDemoEnergyNode($flowBattery, 'Batteriespeicher', 'battery', $batterySoc),
]);
IPS_SetHidden($energyDistribution, false);
$oldEnergyDistribution = enelixDemoFind($dashboard, 'Energieverteilung');
@@ -188,34 +272,105 @@ function enelixDemoInstall(): array
foreach ([$boiler1, $boiler2] as $boilerId) {
IPS_RequestAction($boilerId, 'Mindesttemperatur', 42.0);
IPS_RequestAction($boilerId, 'Maximaltemperatur', 62.0);
IPS_RequestAction($boilerId, 'Aktiv', true);
}
IPS_RequestAction($dryer, 'Aktiv', true);
$manager = enelixDemoEnsureInstance($ems, 'Manager', 'Enelix Manager', ENELIX_DEMO_MANAGER_MODULE, 10);
$assignment = array_map(
static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true],
[$boiler1, $boiler2, $dryer]
[$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,
'Netzleistungsfaktor' => 1.0,
'MesswerteAnzeigen' => true,
'EnergyPieAnzeigen' => true,
'EnergiediagrammeAnzeigen' => true,
'FunFactsAnzeigen' => true,
'EnergieflussAnzeigen' => true,
'PrognoseSendeintervall' => 60,
'MesswertMaxAlter' => 10,
'AutomatischeSuche' => false,
'SuchbereichID' => $root,
'VerbraucherZuordnung' => json_encode($assignment, JSON_THROW_ON_ERROR),
'VerbraucherZuordnung' => '[]',
'KeepAlive' => 5,
'VerbraucherTimeout' => 15,
'SollwertSolarladen' => 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,
'LoggingEin' => false,
]);
foreach ([$boiler1, $boiler2, $dryer] as $consumerId) {
IPS_RequestAction($consumerId, 'Melden', true);
];
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;
}
IPS_RequestAction($manager, 'Aktiv', true);
if (!$managerReady && IPS_GetProperty($manager, 'VerbraucherZuordnung') !== '[]') {
$managerProperties['VerbraucherZuordnung'] = '[]';
enelixDemoConfigure($manager, $managerProperties, [203]);
}
$consumerIds = [$boiler1, $boiler2, $dryer, $buffer, $battery];
foreach ($consumerIds as $consumerId) {
IPS_RequestAction($consumerId, 'Aktiv', $managerReady);
if ($managerReady) {
IPS_RequestAction($consumerId, 'Melden', true);
}
}
IPS_RequestAction($manager, 'Aktiv', $managerReady);
IPS_RequestAction($manager, 'MesswerteAufzeichnen', true);
$boiler1List = enelixDemoEnsureInstance(
$dashboard,
@@ -231,14 +386,32 @@ function enelixDemoInstall(): array
ENELIX_DEMO_DUMMY_MODULE,
120
);
enelixDemoConfigure($boiler1List, []);
enelixDemoConfigure($boiler2List, []);
$bufferList = enelixDemoEnsureInstance(
$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($boiler2List, $boiler2, $temp2);
enelixDemoLinkInstanceVariables($bufferList, $buffer, $bufferTemp, 'Puffertemperatur', 'SensorPuffertemperatur');
enelixDemoLinkInstanceVariables($batteryList, $battery, $batterySoc, 'Ladezustand', 'SensorLadezustand');
IPS_SetHidden($boiler1View, true);
IPS_SetHidden($boiler2View, true);
IPS_SetHidden($boiler1, true);
IPS_SetHidden($boiler2, true);
foreach ([$boiler1, $boiler2, $buffer, $battery] as $hiddenInstanceId) {
IPS_SetHidden($hiddenInstanceId, true);
}
foreach (['Boiler1Link', 'Boiler2Link'] as $oldLinkIdent) {
$oldLink = enelixDemoFind($dashboard, $oldLinkIdent);
@@ -248,22 +421,25 @@ function enelixDemoInstall(): array
}
enelixDemoEnsureLink($dashboard, 'ManagerLink', 'Manager', $manager, 100);
enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 130);
enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 70);
enelixDemoEnsureLink($simulation, 'Boiler2TemperaturLink', 'Boiler 2 Temperatur', $temp2, 80);
enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 150);
enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 100);
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([
$pvDisplay, $houseDisplay, $loadDisplay, $grid,
$boiler1Power, $boiler2Power, $dryerPower,
$boiler1Power, $boiler2Power, $dryerPower, $bufferPower, $batteryPower,
$pvEnergy, $houseEnergy, $gridImportEnergy, $gridExportEnergy,
$temp1, $temp2,
$temp1, $temp2, $bufferTemp, $outsideTemp, $batterySoc, $batteryActual,
$flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2, $flowDryer,
$flowHouse, $flowLoads,
$flowBuffer, $flowBattery, $flowHouse, $flowLoads,
]);
enelixDemoEnsureChart($history, 'LeistungsdiagrammNativ', 'Leistungsverlauf', [
enelixDemoChartDataset($flowPv, 'PV', '#f4c542', '~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'),
], 10);
enelixDemoEnsureChart($history, 'EnergiediagrammNativ', 'Kumulierte Energien', [
@@ -279,7 +455,7 @@ function enelixDemoInstall(): array
'rootId' => $root,
'visualizationId' => enelixDemoFirstInstance(ENELIX_DEMO_TILE_MODULE),
'managerId' => $manager,
'consumerIds' => [$boiler1, $boiler2, $dryer],
'consumerIds' => $consumerIds,
'tickScriptId' => $tickScript,
];
}
@@ -301,13 +477,24 @@ function enelixDemoTick(string $source = 'Manual'): void
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'));
$cloud = (int) GetValue(enelixDemoRequiredId($simulation, 'Bewoelkung'));
$base = (float) GetValue(enelixDemoRequiredId($simulation, 'Grundlast'));
$sun = ($hour >= 6.0 && $hour <= 18.0)
? max(0.0, sin(M_PI * ($hour - 6.0) / 12.0))
: 0.0;
$pv = $peak * $sun * (1.0 - (min(100, max(0, $cloud)) * 0.0075));
$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 = [
'boiler11' => enelixDemoRequiredId($technical, 'Boiler1Stufe1'),
@@ -315,15 +502,46 @@ function enelixDemoTick(string $source = 'Manual'): void
'boiler21' => enelixDemoRequiredId($technical, 'Boiler2Stufe1'),
'boiler22' => enelixDemoRequiredId($technical, 'Boiler2Stufe2'),
'dryer' => enelixDemoRequiredId($technical, 'EntfeuchterKontakt'),
'buffer1' => enelixDemoRequiredId($technical, 'PufferStufe1'),
'buffer2' => enelixDemoRequiredId($technical, 'PufferStufe2'),
'battery' => enelixDemoRequiredId($technical, 'BatterieIstleistung'),
];
$powerBefore = enelixDemoControlledPower($contacts);
$temp1 = enelixDemoRequiredId($technical, 'Boiler1Temperatur');
$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') {
enelixDemoUpdateTemperature($temp1, (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0), $minutes);
enelixDemoUpdateTemperature($temp2, (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0), $minutes);
enelixDemoUpdateTemperature(
$temp1,
(bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0),
$minutes
);
enelixDemoUpdateTemperature(
$temp2,
(bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0),
$minutes
);
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');
SetValue($gridId, round($base + $powerBefore - $pv, 1));
@@ -331,38 +549,60 @@ function enelixDemoTick(string $source = 'Manual'): void
enelixDemoRequiredId($ems, 'Boiler1'),
enelixDemoRequiredId($ems, 'Boiler2'),
enelixDemoRequiredId($ems, 'Entfeuchter'),
enelixDemoRequiredId($ems, 'Pufferspeicher'),
enelixDemoRequiredId($ems, 'Batterie'),
];
foreach ($consumers as $consumerId) {
IPS_RequestAction($consumerId, 'Melden', true);
}
$manager = enelixDemoRequiredId($ems, 'Manager');
IPS_RequestAction($manager, 'Regeln', true);
$managerReady = (int) IPS_GetInstance($manager)['InstanceStatus'] === 102;
if ($managerReady) {
foreach ($consumers as $consumerId) {
IPS_RequestAction($consumerId, 'Melden', 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);
$boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0);
$dryer = (bool) GetValue($contacts['dryer']) ? 900 : 0;
$power = $boiler1 + $boiler2 + $dryer;
$house = $base + $power;
$grid = $house - $pv;
$buffer = (bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0);
$loads = $boiler1 + $boiler2 + $dryer + $buffer;
$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($pvId, round($pv, 1));
SetValue($houseId, round($house, 1));
SetValue($loadId, (float) $power);
SetValue(enelixDemoRequiredId($dashboard, 'PVLeistung'), round($pv, 1));
SetValue(enelixDemoRequiredId($dashboard, 'Hausverbrauch'), round($house, 1));
SetValue(enelixDemoRequiredId($dashboard, 'Verbraucherleistung'), (float) $emsPower);
SetValue(enelixDemoRequiredId($dashboard, 'Boiler1Leistung'), (float) $boiler1);
SetValue(enelixDemoRequiredId($dashboard, 'Boiler2Leistung'), (float) $boiler2);
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, 'EnergieflussNetzkW'), round($grid / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussGrundlastkW'), round($base / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler1kW'), round($boiler1 / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler2kW'), round($boiler2 / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussEntfeuchterkW'), round($dryer / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, '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, 'DiagrammVerbraucherkW'), round($power / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($emsPower / 1000.0, 3));
$history = enelixDemoRequiredId($root, 'Historie');
$energyIds = [
@@ -378,7 +618,11 @@ function enelixDemoTick(string $source = 'Manual'): void
enelixDemoAddEnergy($energyIds['import'], 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
@@ -390,9 +634,15 @@ function enelixDemoRenderFlow(array $state): string
$gridLabel = $grid < -25 ? 'Einspeisung' : ($grid > 25 ? 'Netzbezug' : 'Ausgeglichen');
$gridArrow = $grid < -25 ? '&rarr;' : ($grid > 25 ? '&larr;' : '&harr;');
$gridClass = abs($grid) <= 250 ? 'good' : ($grid < 0 ? 'export' : 'import');
$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' : '';
$b2Class = (float) $state['boiler2'] > 0 ? 'on' : '';
$dryClass = (float) $state['dryer'] > 0 ? 'on' : '';
$bufferClass = $buffer > 0 ? 'on' : '';
$batteryClass = abs($battery) > 25 ? 'on' : '';
return '<style>
.enelix-flow{box-sizing:border-box;font-family:Inter,Segoe UI,Arial,sans-serif;background:#10151c;color:#f4f7fa;padding:18px;min-height:420px}
@@ -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.solar{border-color:#b99628}.enelix-node.bus{border-color:#2786ac}.enelix-node.good{border-color:#2bb673}
.enelix-node.export{border-color:#38a3db}.enelix-node.import{border-color:#e95d5d}.enelix-arrow{text-align:center;font-size:30px;color:#9dacbd}
.enelix-loads{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:18px}
.enelix-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.on{border-left-color:#2bb673}.enelix-load b{display:block;font-size:17px;margin:4px 0}.enelix-load span{color:#9dacbd;font-size:12px}
.enelix-foot{display:flex;justify-content:space-between;gap:12px;margin-top:16px;padding-top:12px;border-top:1px solid #2a3541;color:#9dacbd;font-size:12px}
@media(max-width:640px){.enelix-line{grid-template-columns:1fr}.enelix-arrow{transform:rotate(90deg)}.enelix-loads{grid-template-columns:1fr}.enelix-head{align-items:start;flex-direction:column;gap:4px}}
</style><div class="enelix-flow"><div class="enelix-head"><div><div class="enelix-title">Enelix 2 Energiefluss</div><div class="enelix-sub">Manager regelt drei flexible Verbraucher</div></div><div class="enelix-sub">Simulation ' . $time . ' Uhr</div></div>
<div class="enelix-line"><div class="enelix-node solar"><small>PV-Erzeugung</small><b>' . $format((float) $state['pv']) . ' W</b></div><div class="enelix-arrow">&rarr;</div><div class="enelix-node bus"><small>Haus + flexible Lasten</small><b>' . $format((float) $state['base'] + (float) $state['boiler1'] + (float) $state['boiler2'] + (float) $state['dryer']) . ' W</b></div><div class="enelix-arrow">' . $gridArrow . '</div><div class="enelix-node ' . $gridClass . '"><small>' . $gridLabel . '</small><b>' . $format(abs($grid)) . ' W</b></div></div>
<div class="enelix-loads"><div class="enelix-load ' . $b1Class . '"><span>Priorität 1</span><b>Boiler 1 · ' . $format((float) $state['boiler1']) . ' W</b><span>' . number_format((float) $state['temp1'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $b2Class . '"><span>Priorität 2</span><b>Boiler 2 · ' . $format((float) $state['boiler2']) . ' W</b><span>' . number_format((float) $state['temp2'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $dryClass . '"><span>Priorität 3</span><b>Entfeuchter · ' . $format((float) $state['dryer']) . ' W</b><span>einstufiger Verbraucher</span></div></div>
</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">&rarr;</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 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>';
}
@@ -418,7 +668,65 @@ function enelixDemoControlledPower(array $contacts): int
{
$boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0);
$boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0);
return $boiler1 + $boiler2 + ((bool) GetValue($contacts['dryer']) ? 900 : 0);
$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
@@ -439,6 +747,8 @@ function enelixDemoValidateRuntime(): void
ENELIX_DEMO_MANAGER_MODULE,
ENELIX_DEMO_BOILER_MODULE,
ENELIX_DEMO_SINGLE_MODULE,
ENELIX_DEMO_BUFFER_MODULE,
ENELIX_DEMO_BATTERY_MODULE,
ENELIX_DEMO_DUMMY_MODULE,
ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE,
] as $moduleId) {
@@ -450,10 +760,11 @@ function enelixDemoValidateRuntime(): void
function enelixDemoEnsureProfiles(): void
{
enelixDemoEnsureProfile('ENELIX.Demo.Power', 2, -12000, 12000, 100, 0, ' W');
enelixDemoEnsureProfile('ENELIX.Demo.PowerPositive', 2, 0, 12000, 100, 0, ' W');
enelixDemoEnsureProfile('ENELIX.Demo.Power', 2, -20000, 20000, 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.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.Energy', 2, 0, 1000000, 0.01, 2, ' kWh');
}
@@ -628,15 +939,34 @@ function enelixDemoEnsureCyclicEvent(int $scriptId, string $ident, int $seconds)
return $id;
}
function enelixDemoConfigure(int $instanceId, array $properties): void
function enelixDemoConfigure(
int $instanceId,
array $properties,
array $allowedStatuses = [102]
): void
{
foreach ($properties as $name => $value) {
IPS_SetProperty($instanceId, (string) $name, $value);
}
IPS_ApplyChanges($instanceId);
if ((int) IPS_GetInstance($instanceId)['InstanceStatus'] !== 102) {
throw new RuntimeException('Instanz ist nach der Konfiguration nicht aktiv: ' . IPS_GetName($instanceId));
$deadline = microtime(true) + 2.0;
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
@@ -713,7 +1043,7 @@ function enelixDemoRemove(bool $confirmed = false): void
IPS_ApplyChanges($visualization);
}
enelixDemoDeleteTree($root);
foreach (['ENELIX.Demo.Power', 'ENELIX.Demo.PowerPositive', 'ENELIX.Demo.Hour', 'ENELIX.Demo.Percent', 'ENELIX.Demo.Speed', 'ENELIX.Demo.Energy'] as $profile) {
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)) {
IPS_DeleteVariableProfile($profile);
}
@@ -771,9 +1101,14 @@ function enelixDemoConfigureEnergyDistribution(int $instanceId, array $nodes): v
]);
}
function enelixDemoLinkInstanceVariables(int $categoryId, int $instanceId, int $sensorId): void
{
enelixDemoEnsureLink($categoryId, 'SensorTemperatur', 'Gemessene Temperatur', $sensorId, 5);
function enelixDemoLinkInstanceVariables(
int $categoryId,
int $instanceId,
int $sensorId,
string $sensorName = 'Gemessene Temperatur',
string $sensorIdent = 'SensorTemperatur'
): void {
enelixDemoEnsureLink($categoryId, $sensorIdent, $sensorName, $sensorId, 5);
$position = 10;
foreach (IPS_GetChildrenIDs($instanceId) as $childId) {
if ((int) IPS_GetObject($childId)['ObjectType'] !== 2) {
+2 -2
View File
@@ -7,6 +7,6 @@
"version": "8.0"
},
"version": "0.1",
"build": 0,
"date": 0
"build": 1,
"date": 1790662541
}
+406
View File
@@ -0,0 +1,406 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
use JsonException;
final class Anlagentopologie
{
public const VERSION = '1.0';
/**
* @return array<string, mixed>
* @throws JsonException
*/
public static function ausJson(
string $wechselrichterJson,
string $pvFlaechenJson,
string $batterienJson,
string $installationID
): array {
$wechselrichter = self::dekodiereListe($wechselrichterJson, 'AnlagenWechselrichter');
$pvFlaechen = self::dekodiereListe($pvFlaechenJson, 'AnlagenPVFlaechen');
$batterien = self::dekodiereListe($batterienJson, 'AnlagenBatterien');
$inverters = [];
$inverterById = [];
foreach ($wechselrichter as $index => $eintrag) {
$pfad = 'Wechselrichter ' . ($index + 1);
$id = self::kennung($eintrag['ID'] ?? null, $pfad . ': ID');
if (isset($inverterById[$id])) {
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
}
$typ = self::auswahl(
$eintrag['Typ'] ?? null,
['pv', 'battery', 'hybrid'],
$pfad . ': Typ'
);
$acRatedKw = self::positiveZahl(
$eintrag['ACNennleistung_kW'] ?? null,
$pfad . ': AC-Nennleistung'
);
$inverter = [
'id' => $id,
'name' => self::name($eintrag['Name'] ?? null, $id),
'type' => $typ,
'acRatedKw' => $acRatedKw,
];
$inverters[] = $inverter;
$inverterById[$id] = $inverter;
}
$pvArrays = [];
$pvIds = [];
foreach ($pvFlaechen as $index => $eintrag) {
$pfad = 'PV-Flaeche ' . ($index + 1);
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $pvIds, $pfad);
$inverterId = self::kennung(
$eintrag['WechselrichterID'] ?? null,
$pfad . ': Wechselrichter-ID'
);
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
if (!in_array($inverter['type'], ['pv', 'hybrid'], true)) {
throw new InvalidArgumentException(
$pfad . ': benoetigt einen PV- oder Hybridwechselrichter.'
);
}
$moduleCount = self::optionalePositiveGanzzahl(
$eintrag['Modulanzahl'] ?? null,
$pfad . ': Modulanzahl'
);
$modulePowerWp = self::optionalePositiveZahl(
$eintrag['Modulleistung_Wp'] ?? null,
$pfad . ': Modulleistung'
);
if (($moduleCount === null) !== ($modulePowerWp === null)) {
throw new InvalidArgumentException(
$pfad . ': Modulanzahl und Modulleistung muessen gemeinsam gepflegt werden.'
);
}
$pvArrays[] = [
'id' => $id,
'name' => self::name($eintrag['Name'] ?? null, $id),
'dcKwp' => self::positiveZahl(
$eintrag['DCLeistung_kWp'] ?? null,
$pfad . ': DC-Leistung'
),
'tiltDeg' => self::zahlImBereich(
$eintrag['Neigung_Grad'] ?? null,
0.0,
90.0,
$pfad . ': Neigung'
),
'azimuthDeg' => self::zahlImBereich(
$eintrag['Azimut_Grad'] ?? null,
-180.0,
180.0,
$pfad . ': Azimut'
),
'inverterId' => $inverterId,
'mpptInput' => self::optionalerText($eintrag['MPPT'] ?? null),
'moduleCount' => $moduleCount,
'modulePowerWp' => $modulePowerWp,
];
}
$batteryItems = [];
$batteryIds = [];
foreach ($batterien as $index => $eintrag) {
$pfad = 'Batterie ' . ($index + 1);
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $batteryIds, $pfad);
$inverterId = self::kennung(
$eintrag['WechselrichterID'] ?? null,
$pfad . ': Wechselrichter-ID'
);
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
$kopplung = self::auswahl(
$eintrag['Kopplung'] ?? null,
['ac', 'dc', 'hybrid'],
$pfad . ': Kopplung'
);
if ($kopplung === 'ac' && !in_array($inverter['type'], ['battery', 'hybrid'], true)) {
throw new InvalidArgumentException(
$pfad . ': AC-Kopplung benoetigt einen Batterie- oder Hybridwechselrichter.'
);
}
if ($kopplung !== 'ac' && $inverter['type'] !== 'hybrid') {
throw new InvalidArgumentException(
$pfad . ': DC- und Hybridkopplung benoetigen einen Hybridwechselrichter.'
);
}
$nominalCapacityKwh = self::positiveZahl(
$eintrag['Nennkapazitaet_kWh'] ?? null,
$pfad . ': Nennkapazitaet'
);
$usableCapacityKwh = self::positiveZahl(
$eintrag['Nutzkapazitaet_kWh'] ?? null,
$pfad . ': Nutzkapazitaet'
);
if ($usableCapacityKwh > $nominalCapacityKwh) {
throw new InvalidArgumentException(
$pfad . ': Nutzkapazitaet darf die Nennkapazitaet nicht uebersteigen.'
);
}
$batteryItems[] = [
'id' => $id,
'name' => self::name($eintrag['Name'] ?? null, $id),
'nominalCapacityKwh' => $nominalCapacityKwh,
'usableCapacityKwh' => $usableCapacityKwh,
'maxChargeKw' => self::positiveZahl(
$eintrag['MaxLadeleistung_kW'] ?? null,
$pfad . ': maximale Ladeleistung'
),
'maxDischargeKw' => self::positiveZahl(
$eintrag['MaxEntladeleistung_kW'] ?? null,
$pfad . ': maximale Entladeleistung'
),
'inverterId' => $inverterId,
'coupling' => $kopplung,
];
}
return [
'version' => self::VERSION,
'installationId' => trim($installationID),
'inverters' => $inverters,
'pvArrays' => $pvArrays,
'batteries' => $batteryItems,
'sharedAcLimits' => self::gemeinsameAcGrenzen(
$inverters,
$pvArrays,
$batteryItems
),
'summary' => self::zusammenfassung($inverters, $pvArrays, $batteryItems),
];
}
/** @return list<array<string, mixed>> */
private static function dekodiereListe(string $json, string $name): array
{
$daten = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)
|| ($daten !== [] && array_keys($daten) !== range(0, count($daten) - 1))
) {
throw new InvalidArgumentException($name . ' muss eine JSON-Liste sein.');
}
foreach ($daten as $eintrag) {
if (!is_array($eintrag)) {
throw new InvalidArgumentException($name . ' enthaelt einen ungueltigen Eintrag.');
}
}
return $daten;
}
/** @param array<string, bool> $verwendet */
private static function eindeutigeKennung($wert, array &$verwendet, string $pfad): string
{
$id = self::kennung($wert, $pfad . ': ID');
if (isset($verwendet[$id])) {
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
}
$verwendet[$id] = true;
return $id;
}
private static function kennung($wert, string $feld): string
{
if (!is_string($wert)
|| preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', trim($wert)) !== 1
) {
throw new InvalidArgumentException(
$feld . ' muss 1 bis 64 Zeichen aus Buchstaben, Zahlen, Punkt, Minus oder Unterstrich enthalten.'
);
}
return trim($wert);
}
private static function name($wert, string $fallback): string
{
if ($wert === null || $wert === '') {
return $fallback;
}
if (!is_string($wert) || strlen(trim($wert)) > 120) {
throw new InvalidArgumentException('Name muss ein Text mit maximal 120 Zeichen sein.');
}
return trim($wert);
}
/** @param list<string> $werte */
private static function auswahl($wert, array $werte, string $feld): string
{
if (!is_string($wert) || !in_array($wert, $werte, true)) {
throw new InvalidArgumentException($feld . ' ist ungueltig.');
}
return $wert;
}
private static function positiveZahl($wert, string $feld): float
{
if (!is_int($wert) && !is_float($wert)) {
throw new InvalidArgumentException($feld . ' muss eine Zahl sein.');
}
if (!is_finite((float) $wert) || (float) $wert <= 0.0) {
throw new InvalidArgumentException($feld . ' muss groesser als 0 sein.');
}
return (float) $wert;
}
private static function optionalePositiveZahl($wert, string $feld): ?float
{
if ($wert === null || $wert === '' || $wert === 0 || $wert === 0.0) {
return null;
}
return self::positiveZahl($wert, $feld);
}
private static function optionalePositiveGanzzahl($wert, string $feld): ?int
{
if ($wert === null || $wert === '' || $wert === 0) {
return null;
}
if (!is_int($wert) || $wert <= 0) {
throw new InvalidArgumentException($feld . ' muss eine positive Ganzzahl sein.');
}
return $wert;
}
private static function zahlImBereich(
$wert,
float $minimum,
float $maximum,
string $feld
): float {
if ((!is_int($wert) && !is_float($wert))
|| !is_finite((float) $wert)
|| (float) $wert < $minimum
|| (float) $wert > $maximum
) {
throw new InvalidArgumentException(
sprintf('%s muss zwischen %g und %g liegen.', $feld, $minimum, $maximum)
);
}
return (float) $wert;
}
private static function optionalerText($wert): ?string
{
if ($wert === null || $wert === '') {
return null;
}
if (!is_string($wert) || strlen(trim($wert)) > 64) {
throw new InvalidArgumentException('MPPT muss ein Text mit maximal 64 Zeichen sein.');
}
return trim($wert);
}
/**
* @param array<string, array<string, mixed>> $wechselrichter
* @return array<string, mixed>
*/
private static function wechselrichter(array $wechselrichter, string $id, string $pfad): array
{
if (!isset($wechselrichter[$id])) {
throw new InvalidArgumentException(
$pfad . ': referenzierter Wechselrichter ist nicht vorhanden.'
);
}
return $wechselrichter[$id];
}
/**
* @param list<array<string, mixed>> $inverters
* @param list<array<string, mixed>> $pvArrays
* @param list<array<string, mixed>> $batteries
* @return list<array<string, mixed>>
*/
private static function gemeinsameAcGrenzen(
array $inverters,
array $pvArrays,
array $batteries
): array {
$result = [];
foreach ($inverters as $inverter) {
if ($inverter['type'] !== 'hybrid') {
continue;
}
$pvIds = [];
foreach ($pvArrays as $pvArray) {
if ($pvArray['inverterId'] === $inverter['id']) {
$pvIds[] = $pvArray['id'];
}
}
$batteryIds = [];
foreach ($batteries as $battery) {
if ($battery['inverterId'] === $inverter['id']) {
$batteryIds[] = $battery['id'];
}
}
if ($pvIds === [] && $batteryIds === []) {
continue;
}
$result[] = [
'inverterId' => $inverter['id'],
'acRatedKw' => $inverter['acRatedKw'],
'pvArrayIds' => $pvIds,
'batteryIds' => $batteryIds,
];
}
return $result;
}
/**
* @param list<array<string, mixed>> $inverters
* @param list<array<string, mixed>> $pvArrays
* @param list<array<string, mixed>> $batteries
* @return array<string, float>
*/
private static function zusammenfassung(
array $inverters,
array $pvArrays,
array $batteries
): array {
$pvInverterIds = [];
$pvDcKwp = 0.0;
foreach ($pvArrays as $pvArray) {
$pvDcKwp += $pvArray['dcKwp'];
$pvInverterIds[$pvArray['inverterId']] = true;
}
$pvAcKw = 0.0;
foreach ($inverters as $inverter) {
if (isset($pvInverterIds[$inverter['id']])) {
$pvAcKw += $inverter['acRatedKw'];
}
}
$usableBatteryKwh = 0.0;
$maxBatteryChargeKw = 0.0;
$maxBatteryDischargeKw = 0.0;
foreach ($batteries as $battery) {
$usableBatteryKwh += $battery['usableCapacityKwh'];
$maxBatteryChargeKw += $battery['maxChargeKw'];
$maxBatteryDischargeKw += $battery['maxDischargeKw'];
}
return [
'pvDcKwp' => $pvDcKwp,
'pvAcKw' => $pvAcKw,
'usableBatteryKwh' => $usableBatteryKwh,
'maxBatteryChargeKw' => $maxBatteryChargeKw,
'maxBatteryDischargeKw' => $maxBatteryDischargeKw,
];
}
}
+254
View File
@@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class BatterieRegler
{
public const TYP_UNKONFIGURIERT = 0;
public const TYP_HERSTELLERUNABHAENGIG = 1;
public const TYP_GOODWE = 2;
public const TYP_SOLAREDGE = 3;
public const TYP_SIGENERGY = 4;
public const STEUERUNG_WECHSELRICHTER = 1;
public const STEUERUNG_ENELIX = 2;
public static function hystereseAktiv(
float $ladezustand,
float $reserve,
float $hysterese,
bool $bisherAktiv
): bool {
self::pruefeLadezustandsgrenzen($reserve, $hysterese);
if ($ladezustand > $reserve + $hysterese) {
return false;
}
if ($ladezustand <= $reserve) {
return true;
}
return $bisherAktiv;
}
/**
* Bildet das zustands- und betriebsartabhaengige Angebot als
* durchgaengige Leistungsbereiche in ganzen Watt ab.
*
* @return list<int|array{Von_W: int, Bis_W: int}>
*/
public static function leistungsangebot(
bool $aktiv,
bool $enelixSteuert,
bool $peakbetrieb,
float $ladezustand,
float $reserve,
float $minimum,
bool $hystereseAktiv,
float $netzleistungW,
float $istleistungW,
float $maxLadenW,
float $maxEntladenW
): array {
self::pruefeLadezustandsgrenzen($reserve, 0.0);
if ($minimum < 0.0 || $minimum > 100.0 || $minimum > $reserve) {
throw new InvalidArgumentException(
'Mindestladezustand muss zwischen 0 und der Reserve liegen.'
);
}
self::pruefeLeistungsgrenzen($maxLadenW, $maxEntladenW);
if (!$aktiv || !$enelixSteuert) {
return [0];
}
$maxLaden = (int) floor($maxLadenW);
$maxEntladen = (int) floor($maxEntladenW);
if ($peakbetrieb) {
if ($ladezustand > $reserve && !$hystereseAktiv) {
$ziel = $netzleistungW > 0.0
? -min($netzleistungW, $maxEntladenW) + $istleistungW
: $netzleistungW;
return [self::begrenzeLeistung($ziel, $maxLadenW, $maxEntladenW)];
}
if ($ladezustand > $reserve && $hystereseAktiv) {
return self::leistungsbereich(-$maxEntladen, 0);
}
if ($ladezustand > $minimum) {
return self::leistungsbereich(-$maxEntladen, $maxLaden);
}
return self::leistungsbereich(0, $maxLaden);
}
if ($ladezustand > 99.0) {
return self::leistungsbereich(-$maxEntladen, 0);
}
if ($ladezustand > $reserve && !$hystereseAktiv) {
return self::leistungsbereich(-$maxEntladen, $maxLaden);
}
if ($ladezustand >= $reserve && $hystereseAktiv) {
return self::leistungsbereich(0, $maxLaden);
}
return [$maxLaden];
}
/**
* Liefert nur die Registerwerte, die der gewaehlte Batterietyp benoetigt.
*
* @return array<string, int|float>
*/
public static function registerwerte(
int $batterietyp,
int $steuerung,
int $sollleistungW
): array {
self::pruefeBatterietyp($batterietyp);
self::pruefeSteuerung($steuerung);
$management = self::managementCode($batterietyp, $steuerung);
if ($steuerung === self::STEUERUNG_WECHSELRICHTER) {
if ($batterietyp === self::TYP_GOODWE) {
return ['Leistung' => 0, 'Management' => $management];
}
return [
'Ladeleistung' => 0,
'Entladeleistung' => 0,
'Management' => $management,
];
}
$betrag = abs($sollleistungW);
if ($batterietyp === self::TYP_HERSTELLERUNABHAENGIG) {
return [
'Ladeleistung' => $sollleistungW >= 0 ? $betrag : 0,
'Entladeleistung' => $sollleistungW < 0 ? $betrag : 0,
'Modus' => $sollleistungW >= 0 ? 0 : 1,
'Management' => $management,
];
}
if ($batterietyp === self::TYP_GOODWE) {
return [
'Leistung' => $betrag,
'Modus' => $sollleistungW >= 0 ? 11 : 12,
'Management' => $management,
];
}
if ($batterietyp === self::TYP_SOLAREDGE) {
return [
'Ladeleistung' => $sollleistungW >= 0 ? $betrag : 0,
'Entladeleistung' => $sollleistungW < 0 ? $betrag : 0,
'Modus' => $sollleistungW >= 0 ? 3 : 4,
'Management' => $management,
];
}
return [
'Ladeleistung' => $sollleistungW >= 0 ? $betrag / 1000.0 : 0.0,
'Entladeleistung' => $sollleistungW < 0 ? $betrag / 1000.0 : 0.0,
'Modus' => $sollleistungW >= 0 ? 3 : 6,
'Management' => $management,
];
}
/** @return list<string> */
public static function erforderlicheRegister(int $batterietyp): array
{
self::pruefeBatterietyp($batterietyp);
if ($batterietyp === self::TYP_GOODWE) {
return ['Management', 'Modus', 'Leistung'];
}
return ['Management', 'Modus', 'Ladeleistung', 'Entladeleistung'];
}
private static function managementCode(int $batterietyp, int $steuerung): int
{
if ($steuerung === self::STEUERUNG_WECHSELRICHTER) {
return in_array(
$batterietyp,
[self::TYP_GOODWE, self::TYP_SOLAREDGE],
true
) ? 1 : 0;
}
if ($batterietyp === self::TYP_SOLAREDGE) {
return 4;
}
if ($batterietyp === self::TYP_GOODWE) {
return 2;
}
return 1;
}
/** @return list<int|array{Von_W: int, Bis_W: int}> */
private static function leistungsbereich(int $vonW, int $bisW): array
{
if ($vonW === $bisW) {
return [$vonW];
}
return [['Von_W' => $vonW, 'Bis_W' => $bisW]];
}
private static function begrenzeLeistung(
float $leistungW,
float $maxLadenW,
float $maxEntladenW
): int {
$maxLaden = (int) floor($maxLadenW);
$maxEntladen = (int) floor($maxEntladenW);
return (int) round(min($maxLaden, max(-$maxEntladen, $leistungW)));
}
private static function pruefeLeistungsgrenzen(float $maxLadenW, float $maxEntladenW): void
{
if ($maxLadenW < 0.0 || $maxEntladenW < 0.0) {
throw new InvalidArgumentException('Leistungsgrenzen duerfen nicht negativ sein.');
}
}
private static function pruefeLadezustandsgrenzen(float $reserve, float $hysterese): void
{
if ($reserve < 0.0 || $reserve > 100.0) {
throw new InvalidArgumentException('Reserve muss zwischen 0 und 100 Prozent liegen.');
}
if ($hysterese < 0.0 || $reserve + $hysterese > 100.0) {
throw new InvalidArgumentException(
'Hysterese muss positiv sein und mit der Reserve hoechstens 100 Prozent ergeben.'
);
}
}
private static function pruefeBatterietyp(int $batterietyp): void
{
if (!in_array($batterietyp, [
self::TYP_HERSTELLERUNABHAENGIG,
self::TYP_GOODWE,
self::TYP_SOLAREDGE,
self::TYP_SIGENERGY,
], true)) {
throw new InvalidArgumentException('Batterietyp ist nicht konfiguriert.');
}
}
private static function pruefeSteuerung(int $steuerung): void
{
if (!in_array($steuerung, [
self::STEUERUNG_WECHSELRICHTER,
self::STEUERUNG_ENELIX,
], true)) {
throw new InvalidArgumentException('Batteriesteuerung ist ungueltig.');
}
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
final class EaseeGatewayProtokoll
{
/** @var list<int> */
public const BEOBACHTUNGEN = [
47, 48, 100, 104, 109, 110, 119, 120, 182, 183, 184, 185, 250,
];
public static function seriennummer(string $wert): string
{
return strtoupper(trim($wert));
}
/**
* @param mixed $knoten
* @param list<string> $abonnements
* @return list<array{Seriennummer: string, ID: int, Wert: mixed}>
*/
public static function extrahiereBeobachtungen(
$knoten,
array $abonnements,
string $seriennummer = '',
int $tiefe = 0
): array {
if (!is_array($knoten) || $tiefe > 12) {
return [];
}
$abonnements = array_values(array_unique(array_map(
[self::class, 'seriennummer'],
$abonnements
)));
foreach (['serialNumber', 'SerialNumber', 'chargerId', 'ChargerId', 'mid', 'Mid'] as $feld) {
if (!isset($knoten[$feld]) || !is_scalar($knoten[$feld])) {
continue;
}
$kandidat = self::seriennummer((string) $knoten[$feld]);
if (in_array($kandidat, $abonnements, true)) {
$seriennummer = $kandidat;
break;
}
}
if (self::istListe($knoten)) {
foreach ($knoten as $wert) {
if (!is_string($wert)) {
continue;
}
$kandidat = self::seriennummer($wert);
if (in_array($kandidat, $abonnements, true)) {
$seriennummer = $kandidat;
}
}
}
$id = null;
foreach (['id', 'Id', 'observationId', 'ObservationId'] as $feld) {
if (array_key_exists($feld, $knoten) && is_numeric($knoten[$feld])) {
$id = (int) $knoten[$feld];
break;
}
}
$wertVorhanden = false;
$wert = null;
foreach (['value', 'Value'] as $feld) {
if (array_key_exists($feld, $knoten)) {
$wertVorhanden = true;
$wert = $knoten[$feld];
break;
}
}
$ergebnis = [];
if (
$seriennummer !== ''
&& $wertVorhanden
&& in_array($id, self::BEOBACHTUNGEN, true)
) {
$ergebnis[] = [
'Seriennummer' => $seriennummer,
'ID' => $id,
'Wert' => $wert,
];
}
foreach ($knoten as $kind) {
if (is_array($kind)) {
$ergebnis = array_merge(
$ergebnis,
self::extrahiereBeobachtungen(
$kind,
$abonnements,
$seriennummer,
$tiefe + 1
)
);
}
}
return $ergebnis;
}
/** @param array<mixed> $wert */
private static function istListe(array $wert): bool
{
$index = 0;
foreach ($wert as $schluessel => $_) {
if ($schluessel !== $index++) {
return false;
}
}
return true;
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class EaseeLadestatus
{
public const BETRIEBSMODUS_EASEE = 0;
public const BETRIEBSMODUS_NUR_SOLAR = 1;
/**
* @param array<string, mixed> $beobachtungen
* @return array{
* StatusGueltig: bool,
* FahrzeugVerbunden: bool,
* FahrzeugGeladen: bool,
* Fahrzeugstatus: int,
* Phasenzahl: int,
* Istleistung_W: float,
* Ladestrom_A: float,
* ApiMaximalstrom_A: int,
* Stoerung: bool,
* Stoertext: string
* }
*/
public static function ausBeobachtungen(array $beobachtungen, int $konfigurierterMaximalstrom): array
{
if ($konfigurierterMaximalstrom < 6 || $konfigurierterMaximalstrom > 32) {
throw new InvalidArgumentException(
'MaximalerLadestrom muss zwischen 6 und 32 A liegen.'
);
}
$rohBetriebszustand = $beobachtungen['109'] ?? null;
$statusGueltig = is_numeric($rohBetriebszustand)
&& floor((float) $rohBetriebszustand) === (float) $rohBetriebszustand
&& in_array((int) $rohBetriebszustand, [0, 1, 2, 3, 4, 5, 6, 7, 8], true);
$betriebszustand = $statusGueltig ? (int) $rohBetriebszustand : 0;
$verbunden = $statusGueltig
&& in_array($betriebszustand, [2, 3, 4, 5, 6, 7, 8], true);
$geladen = $betriebszustand === 4;
$fahrzeugstatus = self::fahrzeugstatus($betriebszustand);
$phasenzahl = $verbunden
? self::phasenzahl((int) ($beobachtungen['110'] ?? 0))
: 0;
$leistung = max(0.0, (float) ($beobachtungen['120'] ?? 0.0) * 1000.0);
$stroeme = [];
foreach ([182, 183, 184, 185] as $id) {
if (array_key_exists((string) $id, $beobachtungen)) {
$stroeme[] = abs((float) $beobachtungen[(string) $id]);
}
}
$ladestrom = $stroeme === [] ? 0.0 : max($stroeme);
$grenzen = [$konfigurierterMaximalstrom];
foreach ([47, 104] as $id) {
$grenze = (float) ($beobachtungen[(string) $id] ?? 0.0);
if ($grenze >= 6.0) {
$grenzen[] = (int) floor($grenze);
}
}
$apiMaximalstrom = max(6, min($grenzen));
$fehlercode = (int) ($beobachtungen['119'] ?? 0);
$stoerung = $betriebszustand === 5 || $fehlercode !== 0;
$stoertext = '';
if ($betriebszustand === 5) {
$stoertext = 'Easee meldet einen Fehlerzustand.';
}
if ($fehlercode !== 0) {
$stoertext .= ($stoertext === '' ? '' : ' ') . 'Easee-Fehlercode: ' . $fehlercode . '.';
}
return [
'StatusGueltig' => $statusGueltig,
'FahrzeugVerbunden' => $verbunden,
'FahrzeugGeladen' => $geladen,
'Fahrzeugstatus' => $fahrzeugstatus,
'Phasenzahl' => $phasenzahl,
'Istleistung_W' => $leistung,
'Ladestrom_A' => $ladestrom,
'ApiMaximalstrom_A' => $apiMaximalstrom,
'Stoerung' => $stoerung,
'Stoertext' => trim($stoertext),
];
}
public static function solarladenErzwungen(int $betriebsmodus): bool
{
if (!in_array($betriebsmodus, [
self::BETRIEBSMODUS_EASEE,
self::BETRIEBSMODUS_NUR_SOLAR,
], true)) {
throw new InvalidArgumentException('Unbekannter Easee-Betriebsmodus.');
}
return $betriebsmodus === self::BETRIEBSMODUS_NUR_SOLAR;
}
private static function fahrzeugstatus(int $betriebszustand): int
{
if ($betriebszustand === 1) {
return 1;
}
if (in_array($betriebszustand, [2, 6, 7, 8], true)) {
return 2;
}
if ($betriebszustand === 3) {
return 3;
}
if ($betriebszustand === 4) {
return 4;
}
if ($betriebszustand === 5) {
return 5;
}
return 0;
}
private static function phasenzahl(int $ausgangsphase): int
{
if ($ausgangsphase >= 10 && $ausgangsphase <= 15) {
return 1;
}
if ($ausgangsphase === 30) {
return 3;
}
return 0;
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
/**
* Verteilt eine gemeinsame Einspeisegrenze auf regelbare PV-Wechselrichter.
*
* Netzleistung: positiv Bezug, negativ Einspeisung.
*/
final class EinspeiseRegler
{
/**
* @param list<array{ID: string, Nennleistung_W: float|int, Istleistung_W: float|int}> $wechselrichter
* @param array<string, float|int> $letzteGrenzen
* @return array{
* Netzabweichung_W: float,
* ZielGesamtleistung_W: float,
* Abregelbedarf_W: float,
* Grenzen_W: array<string, float>
* }
*/
public static function verteile(
float $netzleistung,
float $einspeisegrenze,
array $wechselrichter,
array $letzteGrenzen = []
): array {
if ($einspeisegrenze < 0.0) {
throw new InvalidArgumentException('Einspeisegrenze darf nicht negativ sein.');
}
if ($wechselrichter === []) {
throw new InvalidArgumentException('Mindestens ein regelbarer Wechselrichter ist erforderlich.');
}
$nennleistungGesamt = 0.0;
$istleistungGesamt = 0.0;
$bisherigeGrenzeGesamt = 0.0;
$normalisiert = [];
$kennungen = [];
foreach ($wechselrichter as $eintrag) {
$id = $eintrag['ID'] ?? null;
$nennleistung = $eintrag['Nennleistung_W'] ?? null;
$istleistung = $eintrag['Istleistung_W'] ?? null;
if (!is_string($id) || trim($id) === '' || isset($kennungen[$id])) {
throw new InvalidArgumentException('Wechselrichter-ID fehlt oder ist mehrfach vorhanden.');
}
if (!is_numeric($nennleistung) || (float) $nennleistung <= 0.0) {
throw new InvalidArgumentException('Wechselrichter-Nennleistung muss groesser als 0 sein.');
}
if (!is_numeric($istleistung) || (float) $istleistung < 0.0) {
throw new InvalidArgumentException('Wechselrichter-Istleistung darf nicht negativ sein.');
}
$nennleistung = (float) $nennleistung;
$istleistung = min((float) $istleistung, $nennleistung);
$bisherigeGrenze = $letzteGrenzen[$id] ?? $nennleistung;
if (!is_numeric($bisherigeGrenze)) {
throw new InvalidArgumentException('Gespeicherte Wechselrichtergrenze ist ungueltig.');
}
$bisherigeGrenze = max(0.0, min((float) $bisherigeGrenze, $nennleistung));
$normalisiert[] = [
'ID' => $id,
'Nennleistung_W' => $nennleistung,
'Istleistung_W' => $istleistung,
];
$kennungen[$id] = true;
$nennleistungGesamt += $nennleistung;
$istleistungGesamt += $istleistung;
$bisherigeGrenzeGesamt += $bisherigeGrenze;
}
$netzabweichung = $netzleistung + $einspeisegrenze;
$zielGesamt = $bisherigeGrenzeGesamt;
$nachIstleistungVerteilen = false;
if ($netzabweichung < 0.0) {
$nachIstleistungVerteilen = $istleistungGesamt > 0.0;
$zielGesamt = min($bisherigeGrenzeGesamt, $istleistungGesamt) + $netzabweichung;
} elseif ($netzabweichung > 0.0) {
$zielGesamt = $bisherigeGrenzeGesamt + $netzabweichung;
}
$zielGesamt = max(0.0, min($zielGesamt, $nennleistungGesamt));
$grenzen = [];
foreach ($normalisiert as $eintrag) {
$anteil = $nachIstleistungVerteilen
? $eintrag['Istleistung_W'] / $istleistungGesamt
: $eintrag['Nennleistung_W'] / $nennleistungGesamt;
$grenzen[$eintrag['ID']] = round(
min($eintrag['Nennleistung_W'], $zielGesamt * $anteil),
3
);
}
return [
'Netzabweichung_W' => $netzabweichung,
'ZielGesamtleistung_W' => round($zielGesamt, 3),
'Abregelbedarf_W' => round(max(0.0, $istleistungGesamt - $zielGesamt), 3),
'Grenzen_W' => $grenzen,
];
}
public static function stellwert(float $grenzeW, float $nennleistungW, string $art): float
{
if ($nennleistungW <= 0.0 || $grenzeW < 0.0) {
throw new InvalidArgumentException('Leistungsgrenzen sind ungueltig.');
}
$grenzeW = min($grenzeW, $nennleistungW);
if ($art === 'percent') {
return round($grenzeW / $nennleistungW * 100.0, 2);
}
if ($art === 'watt') {
return round($grenzeW, 1);
}
throw new InvalidArgumentException('Begrenzungsart muss percent oder watt sein.');
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
final class EnergieMessung
{
private const WATTSEKUNDEN_PRO_KWH = 3600000.0;
public static function hausleistung(float $pv, float $netz, float $batterie): float
{
return max(
0.0,
self::endlich($pv) + self::endlich($netz) - self::endlich($batterie)
);
}
public static function hausenergie(
float $pv,
float $netzbezug,
float $netzeinspeisung,
float $batterieLaden,
float $batterieEntladen
): float {
return max(
0.0,
self::endlich($pv)
+ self::endlich($netzbezug)
+ self::endlich($batterieEntladen)
- self::endlich($netzeinspeisung)
- self::endlich($batterieLaden)
);
}
/**
* @param array{PV: float, Haus: float, Netz: float, Batterie: float} $leistungen
* @return array{
* PVEnergie: float,
* Hausenergie: float,
* NetzbezugEnergie: float,
* EinspeisungEnergie: float,
* BatterieLadenEnergie: float,
* BatterieEntladenEnergie: float
* }
*/
public static function energieDeltas(array $leistungen, int $sekunden): array
{
$faktor = max(0, $sekunden) / self::WATTSEKUNDEN_PRO_KWH;
$pv = self::endlich($leistungen['PV']);
$haus = self::endlich($leistungen['Haus']);
$netz = self::endlich($leistungen['Netz']);
$batterie = self::endlich($leistungen['Batterie']);
return [
'PVEnergie' => max(0.0, $pv) * $faktor,
'Hausenergie' => max(0.0, $haus) * $faktor,
'NetzbezugEnergie' => max(0.0, $netz) * $faktor,
'EinspeisungEnergie' => max(0.0, -$netz) * $faktor,
'BatterieLadenEnergie' => max(0.0, $batterie) * $faktor,
'BatterieEntladenEnergie' => max(0.0, -$batterie) * $faktor,
];
}
private static function endlich(float $wert): float
{
return is_finite($wert) ? $wert : 0.0;
}
}
+95
View File
@@ -12,6 +12,9 @@ final class Lizenzpruefung
{
public const MANAGER_STANDARD = 'manager_standard';
public const MANAGER_PEAK = 'manager_peak';
public const PROGNOSE_PV = 'forecast_pv';
public const PROGNOSE_VERBRAUCH = 'forecast_load';
public const NETZFAHRPLAN = 'grid_schedule';
public static function normalisiereCode(string $code): string
{
@@ -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
*/
+542
View File
@@ -0,0 +1,542 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
trait ManagerEnergieTrait
{
private function konfiguriereEnergiearchiv(): void
{
$archivID = $this->archivID();
if ($archivID === 0) {
$this->protokolliere('Energiearchiv', 'Kein Archiv Control vorhanden.');
return;
}
$aktiv = $this->ReadPropertyBoolean('EnergieaufzeichnungAktiv');
$geaendert = [];
$leistungstyp = $this->verdichtungstyp(
$this->ReadPropertyInteger('LeistungsaufzeichnungMinuten')
);
$energietyp = $this->verdichtungstyp(
$this->ReadPropertyInteger('EnergieaufzeichnungMinuten')
);
foreach (self::LEISTUNGS_VARIABLEN as $ident) {
$variableID = $this->GetIDForIdent($ident);
AC_SetLoggingStatus($archivID, $variableID, $aktiv);
AC_SetAggregationType($archivID, $variableID, 0);
AC_SetGraphStatus($archivID, $variableID, $aktiv);
$regeln = [-1 => $leistungstyp];
$loeschen = $this->ReadPropertyInteger('LeistungLoeschenMonate');
if ($loeschen > 0) {
$regeln[$loeschen] = 7;
}
if ($aktiv && $this->setzeVerdichtung($archivID, $variableID, $regeln)) {
$geaendert[] = $variableID;
}
}
foreach (self::ENERGIE_VARIABLEN as $ident) {
$variableID = $this->GetIDForIdent($ident);
AC_SetLoggingStatus($archivID, $variableID, $aktiv);
AC_SetAggregationType($archivID, $variableID, 1);
AC_SetGraphStatus($archivID, $variableID, $aktiv);
$regeln = [-1 => $energietyp];
$verdichten = $this->ReadPropertyInteger('EnergieVerdichtenMonate');
if ($verdichten > 0) {
$regeln[$verdichten] = 3;
}
$loeschen = $this->ReadPropertyInteger('EnergieLoeschenMonate');
if ($loeschen > 0) {
$regeln[$loeschen] = 7;
}
if ($aktiv && $this->setzeVerdichtung($archivID, $variableID, $regeln)) {
$geaendert[] = $variableID;
}
}
IPS_ApplyChanges($archivID);
foreach (array_unique($geaendert) as $variableID) {
AC_ReAggregateVariable($archivID, $variableID);
}
}
/** @param array<int, int> $regeln */
private function setzeVerdichtung(int $archivID, int $variableID, array $regeln): bool
{
ksort($regeln);
$aktuell = [];
$eintraege = AC_GetCompaction($archivID, $variableID);
if (is_array($eintraege)) {
foreach ($eintraege as $eintrag) {
$monat = (int) ($eintrag['MonthOffset'] ?? -999);
$typ = (int) ($eintrag['CompactionType'] ?? -1);
if ($monat >= -1 && $typ >= 0) {
$aktuell[$monat] = $typ;
}
}
}
ksort($aktuell);
if ($aktuell === $regeln) {
return false;
}
foreach (array_keys($aktuell) as $monat) {
AC_SetCompaction($archivID, $variableID, $monat, -1);
}
foreach ($regeln as $monat => $typ) {
AC_SetCompaction($archivID, $variableID, $monat, $typ);
}
return true;
}
private function verdichtungstyp(int $minuten): int
{
return [1 => 0, 5 => 1, 60 => 2][$minuten] ?? 0;
}
private function archivID(): int
{
$archive = IPS_GetInstanceListByModuleID(self::ARCHIVE_MODULE_ID);
return $archive === [] ? 0 : (int) $archive[0];
}
private function synchronisiereEnergievisualisierungen(): void
{
$this->synchronisiereEnergyPie($this->ReadPropertyBoolean('EnergyPieAnzeigen'));
$this->synchronisiereEnergiediagramme(
$this->ReadPropertyBoolean('EnergiediagrammeAnzeigen')
);
$this->synchronisiereFunFacts($this->ReadPropertyBoolean('FunFactsAnzeigen'));
$this->synchronisiereEnergiefluss($this->ReadPropertyBoolean('EnergieflussAnzeigen'));
}
private function synchronisiereEnergyPie(bool $anzeigen): void
{
if ($anzeigen) {
$this->erstelleEnergyPie();
return;
}
$this->entferneInstanzUnterobjekt('EnergyPie', self::ENERGY_PIE_MODULE_ID);
}
private function synchronisiereEnergiediagramme(bool $anzeigen): void
{
if ($anzeigen) {
$this->erstelleEnergiediagramme();
return;
}
$this->entferneDiagrammUnterobjekt('Leistungsdiagramm');
$this->entferneDiagrammUnterobjekt('Energiediagramm');
}
private function synchronisiereFunFacts(bool $anzeigen): void
{
if ($anzeigen) {
$this->erstelleFunFacts();
return;
}
$variableID = $this->findeUnterobjekt('EnergieFunFacts');
if ($variableID === 0) {
return;
}
if ((int) IPS_GetObject($variableID)['ObjectType'] !== 2) {
throw new RuntimeException('Unterobjekt EnergieFunFacts ist keine Variable.');
}
$this->UnregisterVariable('EnergieFunFacts');
}
private function synchronisiereEnergiefluss(bool $anzeigen): void
{
if ($anzeigen) {
$this->erstelleEnergiefluss();
return;
}
$instanzID = $this->findeUnterobjekt('Energiefluss');
if ($instanzID === 0) {
return;
}
$objekt = IPS_GetObject($instanzID);
$modulID = $objekt['ObjectType'] === 1
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
: '';
if ($modulID !== self::ENERGY_FLOW_MODULE_ID) {
throw new RuntimeException('Unterobjekt Energiefluss hat einen unpassenden Typ.');
}
IPS_SetHidden($instanzID, true);
}
private function entferneInstanzUnterobjekt(string $ident, string $modulID): void
{
$instanzID = $this->findeUnterobjekt($ident);
if ($instanzID === 0) {
return;
}
$objekt = IPS_GetObject($instanzID);
$istModulID = $objekt['ObjectType'] === 1
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
: '';
if ($istModulID !== $modulID) {
throw new RuntimeException('Unterobjekt ' . $ident . ' hat einen unpassenden Typ.');
}
IPS_DeleteInstance($instanzID);
}
private function entferneDiagrammUnterobjekt(string $ident): void
{
$medienID = $this->findeUnterobjekt($ident);
if ($medienID === 0) {
return;
}
$objekt = IPS_GetObject($medienID);
if ($objekt['ObjectType'] !== 5 || (int) IPS_GetMedia($medienID)['MediaType'] !== 4) {
throw new RuntimeException('Unterobjekt ' . $ident . ' ist kein Diagramm.');
}
IPS_DeleteMedia($medienID, true);
}
private function erstelleEnergyPie(): string
{
if (!in_array(self::ENERGY_PIE_MODULE_ID, IPS_GetModuleList(), true)) {
throw new RuntimeException(
'Das Modul Energy Pie aus Enelix Utils ist nicht installiert.'
);
}
$instanzID = $this->findeUnterobjekt('EnergyPie');
if ($instanzID === 0) {
$instanzID = IPS_CreateInstance(self::ENERGY_PIE_MODULE_ID);
IPS_SetParent($instanzID, $this->InstanceID);
IPS_SetIdent($instanzID, 'EnergyPie');
} else {
$objekt = IPS_GetObject($instanzID);
$modulID = $objekt['ObjectType'] === 1
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
: '';
if ($modulID !== self::ENERGY_PIE_MODULE_ID) {
throw new RuntimeException('Unterobjekt EnergyPie hat einen unpassenden Typ.');
}
}
IPS_SetName($instanzID, 'Energy Pie');
IPS_SetPosition($instanzID, 500);
IPS_SetHidden($instanzID, false);
foreach ([
'ProduktionZaehlerID' => $this->GetIDForIdent('PVEnergie'),
'EinspeisungZaehlerID' => $this->GetIDForIdent('EinspeisungEnergie'),
'NetzbezugZaehlerID' => $this->GetIDForIdent('NetzbezugEnergie'),
'VerbrauchZaehlerID' => $this->GetIDForIdent('Hausenergie'),
'Zaehlerfaktor' => 1.0,
] as $property => $wert) {
IPS_SetProperty($instanzID, $property, $wert);
}
IPS_ApplyChanges($instanzID);
return 'Energy Pie wurde erstellt oder aktualisiert.';
}
private function erstelleEnergiefluss(): string
{
if (!in_array(self::ENERGY_FLOW_MODULE_ID, IPS_GetModuleList(), true)) {
throw new RuntimeException('Das Symcon-Modul Energy Distribution ist nicht installiert.');
}
$instanzID = $this->findeUnterobjekt('Energiefluss');
if ($instanzID === 0) {
$instanzID = IPS_CreateInstance(self::ENERGY_FLOW_MODULE_ID);
IPS_SetParent($instanzID, $this->InstanceID);
IPS_SetIdent($instanzID, 'Energiefluss');
} else {
$objekt = IPS_GetObject($instanzID);
$modulID = $objekt['ObjectType'] === 1
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
: '';
if ($modulID !== self::ENERGY_FLOW_MODULE_ID) {
throw new RuntimeException('Unterobjekt Energiefluss hat einen unpassenden Typ.');
}
}
IPS_SetName($instanzID, 'Energiefluss');
IPS_SetPosition($instanzID, 540);
IPS_SetHidden($instanzID, false);
$knoten = [
$this->energieflussKnoten('PVLeistungArchiv', 'PV-Anlage', 'solar'),
$this->energieflussKnoten('NetzleistungArchiv', 'Stromnetz', 'grid'),
$this->energieflussKnoten('HausverbrauchLeistungArchiv', 'Hausverbrauch', 'consumer'),
$this->energieflussKnoten('BatterieleistungArchiv', 'Batteriespeicher', 'battery'),
];
IPS_SetProperty($instanzID, 'Calculation', true);
IPS_SetProperty(
$instanzID,
'Config',
json_encode($knoten, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
);
IPS_SetProperty($instanzID, 'EnableLimits', false);
IPS_SetProperty($instanzID, 'Restrictions', '[]');
IPS_ApplyChanges($instanzID);
return 'Der Energiefluss wurde erstellt oder aktualisiert.';
}
/** @return array<string, mixed> */
private function energieflussKnoten(string $ident, string $name, string $typ): array
{
return [
'VariableConfigType' => 0,
'VariableID' => $this->GetIDForIdent($ident),
'SecondaryVariableID' => 1,
'Name' => $name,
'Type' => $typ,
'Grouped' => false,
'CalculationType' => 0,
'InfoVariableID' => 1,
'Invert' => false,
];
}
private function erstelleEnergiediagramme(): string
{
$leistung = [
$this->chartDatensatz('PVLeistungArchiv', 'PV', '#F4C542', '~Power'),
$this->chartDatensatz('HausverbrauchLeistungArchiv', 'Haus', '#22A06B', '~Power'),
$this->chartDatensatz('NetzleistungArchiv', 'Netz', '#E95D5D', '~Power'),
$this->chartDatensatz('BatterieleistungArchiv', 'Batterie', '#8B5CF6', '~Power'),
];
$energie = [
$this->chartDatensatz('PVEnergie', 'PV', '#F4C542', '~Electricity'),
$this->chartDatensatz('Hausenergie', 'Haus', '#22A06B', '~Electricity'),
$this->chartDatensatz('NetzbezugEnergie', 'Netzbezug', '#E95D5D', '~Electricity'),
$this->chartDatensatz('EinspeisungEnergie', 'Einspeisung', '#2F80ED', '~Electricity'),
$this->chartDatensatz('BatterieLadenEnergie', 'Batterie laden', '#8B5CF6', '~Electricity'),
$this->chartDatensatz('BatterieEntladenEnergie', 'Batterie entladen', '#F59E0B', '~Electricity'),
];
$this->erstelleDiagramm('Leistungsdiagramm', 'Leistungen', $leistung, 510);
$this->erstelleDiagramm('Energiediagramm', 'Energien', $energie, 520);
return 'Die Diagramme Leistungen und Energien wurden erstellt oder aktualisiert.';
}
/** @param list<array<string, mixed>> $datensaetze */
private function erstelleDiagramm(
string $ident,
string $name,
array $datensaetze,
int $position
): int {
$medienID = $this->findeUnterobjekt($ident);
if ($medienID === 0) {
$medienID = IPS_CreateMedia(4);
IPS_SetParent($medienID, $this->InstanceID);
IPS_SetIdent($medienID, $ident);
}
$objekt = IPS_GetObject($medienID);
if ($objekt['ObjectType'] !== 5 || (int) IPS_GetMedia($medienID)['MediaType'] !== 4) {
throw new RuntimeException('Unterobjekt ' . $ident . ' ist kein Diagramm.');
}
IPS_SetName($medienID, $name);
IPS_SetPosition($medienID, $position);
IPS_SetHidden($medienID, false);
IPS_SetMediaFile($medienID, 'media/' . $medienID . '.chart', false);
IPS_SetMediaContent(
$medienID,
base64_encode(json_encode(['datasets' => $datensaetze], JSON_THROW_ON_ERROR))
);
return $medienID;
}
/** @return array<string, mixed> */
private function chartDatensatz(
string $ident,
string $titel,
string $farbe,
string $profil
): array {
return [
'variableID' => $this->GetIDForIdent($ident),
'fillColor' => 'clear',
'strokeColor' => $farbe,
'timeOffset' => 0,
'visible' => true,
'title' => $titel,
'type' => 'line',
'side' => 'left',
'profile' => $profil,
];
}
private function findeUnterobjekt(string $ident): int
{
$objektID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
return is_int($objektID) && $objektID > 0 ? $objektID : 0;
}
private function erstelleFunFacts(): string
{
$this->RegisterVariableString('EnergieFunFacts', 'Energy Facts', '~HTMLBox', 530);
$variableID = $this->GetIDForIdent('EnergieFunFacts');
IPS_SetHidden($variableID, false);
$this->aktualisiereFunFacts($variableID);
return 'Energy Facts wurden erstellt oder aktualisiert.';
}
private function aktualisiereFunFacts(int $variableID): void
{
$archivID = $this->archivID();
if ($archivID === 0) {
SetValue($variableID, '<div style="padding:16px">Archiv nicht verfuegbar.</div>');
return;
}
$heute = strtotime('today');
$jahr = mktime(0, 0, 0, 1, 1, (int) date('Y'));
$jetzt = time();
$pvID = $this->GetIDForIdent('PVEnergie');
$hausID = $this->GetIDForIdent('Hausenergie');
$bezugID = $this->GetIDForIdent('NetzbezugEnergie');
$exportID = $this->GetIDForIdent('EinspeisungEnergie');
$pvHeute = $this->energieSumme($archivID, $pvID, $heute, $jetzt);
$pvJahr = $this->energieSumme($archivID, $pvID, $jahr, $jetzt);
$pvTotal = max(0.0, (float) GetValue($pvID));
[$besterTag, $besterTagZeit] = $this->maximalerTageswert($archivID, $pvID);
[$maxPV, $maxPVZeit] = $this->maximaleLeistung(
$archivID,
$this->GetIDForIdent('PVLeistungArchiv')
);
[$maxHausTag, $maxHausTagZeit] = $this->maximalerTageswert($archivID, $hausID);
[$maxHaus, $maxHausZeit] = $this->maximaleLeistung(
$archivID,
$this->GetIDForIdent('HausverbrauchLeistungArchiv')
);
[$maxBezug, $maxBezugZeit] = $this->maximalerTageswert($archivID, $bezugID);
[$maxExport, $maxExportZeit] = $this->maximalerTageswert($archivID, $exportID);
$werte = [
'{{PV_HEUTE}}' => $this->formatiereEnergie($pvHeute),
'{{PV_JAHR}}' => $this->formatiereEnergie($pvJahr),
'{{PV_TOTAL}}' => $this->formatiereEnergie($pvTotal),
'{{BESTER_TAG}}' => $this->formatiereEnergie($besterTag),
'{{BESTER_TAG_DATUM}}' => $this->formatiereDatum($besterTagZeit),
'{{MAX_PV}}' => $this->formatiereLeistung($maxPV),
'{{MAX_PV_DATUM}}' => $this->formatiereDatum($maxPVZeit),
'{{MAX_HAUS_TAG}}' => $this->formatiereEnergie($maxHausTag),
'{{MAX_HAUS_TAG_DATUM}}' => $this->formatiereDatum($maxHausTagZeit),
'{{MAX_HAUS}}' => $this->formatiereLeistung($maxHaus),
'{{MAX_HAUS_DATUM}}' => $this->formatiereDatum($maxHausZeit),
'{{MAX_BEZUG}}' => $this->formatiereEnergie($maxBezug),
'{{MAX_BEZUG_DATUM}}' => $this->formatiereDatum($maxBezugZeit),
'{{MAX_EXPORT}}' => $this->formatiereEnergie($maxExport),
'{{MAX_EXPORT_DATUM}}' => $this->formatiereDatum($maxExportZeit),
'{{HEIZOEL}}' => number_format($pvTotal / 10.0, 0, ',', "'") . ' Liter',
'{{FLUGKM}}' => number_format($pvTotal * 2.4, 0, ',', "'") . ' km',
];
SetValue($variableID, strtr($this->funFactsHtml(), $werte));
}
private function energieSumme(int $archivID, int $variableID, int $start, int $ende): float
{
$werte = @AC_GetAggregatedValues($archivID, $variableID, 1, $start, $ende, 0);
if (!is_array($werte)) {
return 0.0;
}
return array_sum(array_map(
static fn (array $wert): float => max(0.0, (float) ($wert['Avg'] ?? 0.0)),
$werte
));
}
/** @return array{0: float, 1: int} */
private function maximalerTageswert(int $archivID, int $variableID): array
{
$werte = @AC_GetAggregatedValues($archivID, $variableID, 1, 0, time(), 0);
$maximum = 0.0;
$zeit = 0;
if (is_array($werte)) {
foreach ($werte as $wert) {
$kandidat = max(0.0, (float) ($wert['Avg'] ?? 0.0));
if ($kandidat > $maximum) {
$maximum = $kandidat;
$zeit = (int) ($wert['TimeStamp'] ?? 0);
}
}
}
return [$maximum, $zeit];
}
/** @return array{0: float, 1: int} */
private function maximaleLeistung(int $archivID, int $variableID): array
{
$werte = @AC_GetAggregatedValues($archivID, $variableID, 1, 0, time(), 0);
$maximum = 0.0;
$zeit = 0;
if (is_array($werte)) {
foreach ($werte as $wert) {
$kandidat = max(0.0, (float) ($wert['Max'] ?? 0.0));
if ($kandidat > $maximum) {
$maximum = $kandidat;
$zeit = (int) ($wert['MaxTime'] ?? $wert['TimeStamp'] ?? 0);
}
}
}
return [$maximum, $zeit];
}
private function formatiereEnergie(float $wert): string
{
if ($wert >= 1000.0) {
return number_format($wert / 1000.0, 2, ',', "'") . ' MWh';
}
return number_format($wert, 1, ',', "'") . ' kWh';
}
private function formatiereLeistung(float $wert): string
{
if ($wert >= 10000.0) {
return number_format($wert / 1000.0, 1, ',', "'") . ' kW';
}
return number_format($wert, 0, ',', "'") . ' W';
}
private function formatiereDatum(int $zeit): string
{
return $zeit > 0 ? date('d.m.Y', $zeit) : 'Noch keine Daten';
}
private function funFactsHtml(): string
{
return <<<'HTML'
<style>
.enx-facts{box-sizing:border-box;font-family:system-ui,-apple-system,sans-serif;color:#182230;padding:16px;background:#f4f7f6}
.enx-facts *{box-sizing:border-box}.enx-facts-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
.enx-fact-panel{min-width:0;background:#fff;border:1px solid #dfe7e4;border-radius:8px;overflow:hidden;box-shadow:0 4px 18px rgba(17,44,36,.08)}
.enx-fact-head{padding:15px 18px 13px;border-bottom:3px solid #22a06b;display:flex;align-items:center;justify-content:space-between}
.enx-fact-head b{font-size:18px}.enx-fact-head span{font-size:11px;letter-spacing:.08em;color:#60736c}
.enx-fact-body{padding:5px 18px 10px}.enx-fact-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;padding:11px 0;border-bottom:1px solid #edf1ef}
.enx-fact-row:last-child{border-bottom:0}.enx-fact-label{min-width:0;color:#52635d;font-size:13px}.enx-fact-value{text-align:right;font-size:15px;font-weight:700;white-space:nowrap}
.enx-fact-date{display:block;color:#81908b;font-size:11px;font-weight:500;margin-top:2px}.enx-fact-panel.grid .enx-fact-head{border-color:#2f80ed}
.enx-fact-panel.impact .enx-fact-head{border-color:#f4c542}.enx-impact{padding:18px}.enx-impact-value{font-size:30px;font-weight:750;line-height:1.15}
.enx-impact-label{font-size:12px;color:#60736c;margin:5px 0 20px}.enx-impact-note{font-size:11px;color:#81908b;line-height:1.45}
@media(max-width:820px){.enx-facts-grid{grid-template-columns:1fr}.enx-facts{padding:10px}}
</style>
<div class="enx-facts"><div class="enx-facts-grid">
<section class="enx-fact-panel"><div class="enx-fact-head"><b>Solar</b><span>ERZEUGUNG</span></div><div class="enx-fact-body">
<div class="enx-fact-row"><span class="enx-fact-label">Heute</span><span class="enx-fact-value">{{PV_HEUTE}}</span></div>
<div class="enx-fact-row"><span class="enx-fact-label">Dieses Jahr</span><span class="enx-fact-value">{{PV_JAHR}}</span></div>
<div class="enx-fact-row"><span class="enx-fact-label">Total</span><span class="enx-fact-value">{{PV_TOTAL}}</span></div>
<div class="enx-fact-row"><span class="enx-fact-label">Bester Tag</span><span class="enx-fact-value">{{BESTER_TAG}}<small class="enx-fact-date">{{BESTER_TAG_DATUM}}</small></span></div>
<div class="enx-fact-row"><span class="enx-fact-label">Max. Leistung</span><span class="enx-fact-value">{{MAX_PV}}<small class="enx-fact-date">{{MAX_PV_DATUM}}</small></span></div>
</div></section>
<section class="enx-fact-panel grid"><div class="enx-fact-head"><b>Netz &amp; Haus</b><span>SPITZENWERTE</span></div><div class="enx-fact-body">
<div class="enx-fact-row"><span class="enx-fact-label">Max. Tagesverbrauch</span><span class="enx-fact-value">{{MAX_HAUS_TAG}}<small class="enx-fact-date">{{MAX_HAUS_TAG_DATUM}}</small></span></div>
<div class="enx-fact-row"><span class="enx-fact-label">Max. Hauslast</span><span class="enx-fact-value">{{MAX_HAUS}}<small class="enx-fact-date">{{MAX_HAUS_DATUM}}</small></span></div>
<div class="enx-fact-row"><span class="enx-fact-label">Max. Tagesbezug</span><span class="enx-fact-value">{{MAX_BEZUG}}<small class="enx-fact-date">{{MAX_BEZUG_DATUM}}</small></span></div>
<div class="enx-fact-row"><span class="enx-fact-label">Max. Einspeisung</span><span class="enx-fact-value">{{MAX_EXPORT}}<small class="enx-fact-date">{{MAX_EXPORT_DATUM}}</small></span></div>
</div></section>
<section class="enx-fact-panel impact"><div class="enx-fact-head"><b>Wirkung</b><span>VERGLEICHE</span></div><div class="enx-impact">
<div class="enx-impact-value">{{HEIZOEL}}</div><div class="enx-impact-label">rechnerisch kompensiertes Heizoel</div>
<div class="enx-impact-value">{{FLUGKM}}</div><div class="enx-impact-label">rechnerisch kompensierte Flugkilometer</div>
<div class="enx-impact-note">Vergleichswerte auf Basis von 10 kWh pro Liter Heizoel und 2,4 Flugkilometern pro erzeugter kWh.</div>
</div></section>
</div></div>
HTML;
}
}
+27
View File
@@ -70,6 +70,33 @@ final class Nachrichtenvertrag
self::pruefeZustand($daten['Zustand']);
}
/**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
*/
public static function enthaeltLeistungswert(array $leistungswerte, int $leistung): bool
{
foreach ($leistungswerte as $eintrag) {
if (is_int($eintrag)) {
if ($eintrag === $leistung) {
return true;
}
continue;
}
if (is_array($eintrag)
&& isset($eintrag['Von_W'], $eintrag['Bis_W'])
&& is_int($eintrag['Von_W'])
&& is_int($eintrag['Bis_W'])
&& $leistung >= $eintrag['Von_W']
&& $leistung <= $eintrag['Bis_W']
) {
return true;
}
}
return false;
}
/**
* @param mixed $kopf
*/
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class StoerungsSnapshot
{
public const VERSION = '1.0';
private const SCHWEREGRADE = [
'critical' => 0,
'error' => 1,
'warning' => 2,
'info' => 3,
];
/**
* @param list<array<string, mixed>> $meldungen
* @return array<string, mixed>
*/
public static function erstellen(
string $installationID,
int $managerID,
string $rolle,
bool $managerAktiv,
array $meldungen,
?int $zeitpunkt = null
): array {
if (preg_match(
'/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
$installationID
) !== 1) {
throw new InvalidArgumentException('Die Installations-ID muss eine UUIDv4 sein.');
}
if ($managerID <= 0) {
throw new InvalidArgumentException('Die Manager-ID muss positiv sein.');
}
if (!in_array($rolle, ['standalone', 'main', 'submanager'], true)) {
throw new InvalidArgumentException('Die Managerrolle ist ungueltig.');
}
$eindeutig = [];
foreach ($meldungen as $meldung) {
$normalisiert = self::normalisiereMeldung($meldung);
$schluessel = $normalisiert['sourceId'] . "\0" . $normalisiert['code'];
$eindeutig[$schluessel] = $normalisiert;
}
$meldungen = array_values($eindeutig);
usort($meldungen, static function (array $links, array $rechts): int {
$schweregrad = self::SCHWEREGRADE[$links['severity']]
<=> self::SCHWEREGRADE[$rechts['severity']];
if ($schweregrad !== 0) {
return $schweregrad;
}
return [$links['sourceName'], $links['code']]
<=> [$rechts['sourceName'], $rechts['code']];
});
// Das Portal akzeptiert hoechstens 100 Meldungen; die hoechste Prioritaet bleibt erhalten.
$meldungen = array_slice($meldungen, 0, 100);
return [
'version' => self::VERSION,
'installationId' => strtolower($installationID),
'capturedAt' => gmdate('Y-m-d\TH:i:s\Z', $zeitpunkt ?? time()),
'manager' => [
'instanceId' => $managerID,
'role' => $rolle,
'active' => $managerAktiv,
],
'faults' => $meldungen,
];
}
/** @param array<string, mixed> $snapshot */
public static function zustandHash(array $snapshot): string
{
unset($snapshot['capturedAt']);
return hash('sha256', json_encode(
$snapshot,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
));
}
/**
* @param array<string, mixed> $meldung
* @return array<string, string>
*/
private static function normalisiereMeldung(array $meldung): array
{
$felder = ['sourceType', 'sourceId', 'sourceName', 'code', 'severity', 'message'];
if (array_diff($felder, array_keys($meldung)) !== []
|| array_diff(array_keys($meldung), $felder) !== []
) {
throw new InvalidArgumentException('Eine Stoerungsmeldung hat ungueltige Felder.');
}
$normalisiert = [];
foreach ($felder as $feld) {
if (!is_string($meldung[$feld])) {
throw new InvalidArgumentException('Stoerungsfelder muessen Texte sein.');
}
$normalisiert[$feld] = trim((string) preg_replace('/\s+/', ' ', $meldung[$feld]));
}
if ($normalisiert['sourceType'] === ''
|| $normalisiert['sourceId'] === ''
|| $normalisiert['sourceName'] === ''
|| $normalisiert['code'] === ''
|| $normalisiert['message'] === ''
) {
throw new InvalidArgumentException('Stoerungsfelder duerfen nicht leer sein.');
}
if (!array_key_exists($normalisiert['severity'], self::SCHWEREGRADE)) {
throw new InvalidArgumentException('Der Stoerungsschweregrad ist ungueltig.');
}
if (strlen($normalisiert['sourceType']) > 40
|| strlen($normalisiert['sourceId']) > 120
|| strlen($normalisiert['sourceName']) > 160
|| strlen($normalisiert['code']) > 120
|| strlen($normalisiert['message']) > 500
) {
throw new InvalidArgumentException('Mindestens ein Stoerungsfeld ist zu lang.');
}
return $normalisiert;
}
}
+190
View File
@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class WaermepumpenRegler
{
public const KONTAKTART_SPERRE_ERHOEHUNG = 0;
public const KONTAKTART_SG_READY = 1;
public const RUECKMELDUNG_LEISTUNG = 0;
public const RUECKMELDUNG_BETRIEB = 1;
public const STEUERZUSTAND_NORMAL = 0;
public const STEUERZUSTAND_SPERRE = 1;
public const STEUERZUSTAND_ERHOEHUNG = 2;
/** @return array{Kontakt1: bool, Kontakt2: bool} */
public static function kontaktwerte(int $kontaktart, int $steuerzustand): array
{
self::pruefeKontaktart($kontaktart);
self::pruefeSteuerzustand($steuerzustand);
return [
'Kontakt1' => $steuerzustand === self::STEUERZUSTAND_SPERRE,
'Kontakt2' => $steuerzustand === self::STEUERZUSTAND_ERHOEHUNG,
];
}
/** @param bool|int|float $rueckmeldung */
public static function laeuft($rueckmeldung, int $rueckmeldungsart, float $laufschwelle): bool
{
self::pruefeRueckmeldungsart($rueckmeldungsart);
if ($laufschwelle < 0) {
throw new InvalidArgumentException('Die Laufschwelle darf nicht negativ sein.');
}
if ($rueckmeldungsart === self::RUECKMELDUNG_BETRIEB) {
return (bool) $rueckmeldung;
}
return abs((float) $rueckmeldung) >= $laufschwelle;
}
/** @param bool|int|float $rueckmeldung */
public static function istleistung(
$rueckmeldung,
int $rueckmeldungsart,
int $nennleistung
): float {
self::pruefeRueckmeldungsart($rueckmeldungsart);
self::pruefeNennleistung($nennleistung);
if ($rueckmeldungsart === self::RUECKMELDUNG_BETRIEB) {
return (bool) $rueckmeldung ? (float) $nennleistung : 0.0;
}
return max(0.0, (float) $rueckmeldung);
}
/** @return list<int> */
public static function leistungsangebot(
int $nennleistung,
string $betriebsart,
bool $laeuft,
bool $verfuegbar,
bool $mindestlaufzeitAktiv,
bool $mindestsperrzeitAktiv,
bool $sperrenZulaessig,
bool $startenZulaessig,
bool $sperrerholung
): array {
self::pruefeNennleistung($nennleistung);
if (!in_array($betriebsart, [
Nachrichtenvertrag::BETRIEBSART_PV,
Nachrichtenvertrag::BETRIEBSART_PEAK,
], true)) {
throw new InvalidArgumentException('Die Betriebsart ist ungueltig.');
}
if (!$verfuegbar) {
return [$laeuft ? $nennleistung : 0];
}
if ($sperrerholung || $mindestlaufzeitAktiv) {
return [$laeuft ? $nennleistung : 0];
}
if ($betriebsart === Nachrichtenvertrag::BETRIEBSART_PV) {
return ($laeuft || (!$mindestsperrzeitAktiv && $startenZulaessig))
? [0, $nennleistung]
: [0];
}
return ($laeuft && $sperrenZulaessig)
? [0, $nennleistung]
: [$laeuft ? $nennleistung : 0];
}
public static function restzeit(int $beginn, int $dauer, int $jetzt): int
{
if ($beginn < 0 || $dauer < 0 || $jetzt < 0) {
throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.');
}
if ($beginn === 0 || $dauer === 0) {
return 0;
}
return max(0, $beginn + $dauer - $jetzt);
}
/**
* @param list<array{Von: int, Bis: int}> $historie
* @return list<array{Von: int, Bis: int}>
*/
public static function bereinigeSperrhistorie(array $historie, int $jetzt): array
{
if ($jetzt < 0) {
throw new InvalidArgumentException('Der Zeitpunkt darf nicht negativ sein.');
}
$grenze = $jetzt - 86400;
$ergebnis = [];
foreach ($historie as $intervall) {
$von = (int) ($intervall['Von'] ?? -1);
$bis = (int) ($intervall['Bis'] ?? -1);
if ($von < 0 || $bis < $von || $bis <= $grenze) {
continue;
}
$ergebnis[] = ['Von' => max($von, $grenze), 'Bis' => min($bis, $jetzt)];
}
return $ergebnis;
}
/** @param list<array{Von: int, Bis: int}> $historie */
public static function sperrsekunden24h(
array $historie,
int $jetzt,
?int $aktiverBeginn = null
): int {
$sekunden = 0;
foreach (self::bereinigeSperrhistorie($historie, $jetzt) as $intervall) {
$sekunden += $intervall['Bis'] - $intervall['Von'];
}
if ($aktiverBeginn !== null && $aktiverBeginn > 0) {
$sekunden += max(0, $jetzt - max($aktiverBeginn, $jetzt - 86400));
}
return min(86400, $sekunden);
}
private static function pruefeKontaktart(int $kontaktart): void
{
if (!in_array($kontaktart, [
self::KONTAKTART_SPERRE_ERHOEHUNG,
self::KONTAKTART_SG_READY,
], true)) {
throw new InvalidArgumentException('Die Kontaktart ist ungueltig.');
}
}
private static function pruefeRueckmeldungsart(int $rueckmeldungsart): void
{
if (!in_array($rueckmeldungsart, [
self::RUECKMELDUNG_LEISTUNG,
self::RUECKMELDUNG_BETRIEB,
], true)) {
throw new InvalidArgumentException('Die Rueckmeldungsart ist ungueltig.');
}
}
private static function pruefeSteuerzustand(int $steuerzustand): void
{
if (!in_array($steuerzustand, [
self::STEUERZUSTAND_NORMAL,
self::STEUERZUSTAND_SPERRE,
self::STEUERZUSTAND_ERHOEHUNG,
], true)) {
throw new InvalidArgumentException('Der Steuerzustand ist ungueltig.');
}
}
private static function pruefeNennleistung(int $nennleistung): void
{
if ($nennleistung <= 0) {
throw new InvalidArgumentException('Die Nennleistung muss groesser als 0 sein.');
}
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\Anlagentopologie;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class AnlagentopologieTest extends TestCase
{
public function testHybridwechselrichterVerknuepftPvUndBatterieMitGemeinsamerAcGrenze(): void
{
$topologie = Anlagentopologie::ausJson(
json_encode([[
'ID' => 'hybrid-1',
'Name' => 'Hybrid Ost',
'Typ' => 'hybrid',
'ACNennleistung_kW' => 10.0,
]], JSON_THROW_ON_ERROR),
json_encode([
[
'ID' => 'pv-ost',
'Name' => 'Ostdach',
'DCLeistung_kWp' => 6.4,
'Neigung_Grad' => 25.0,
'Azimut_Grad' => -90.0,
'WechselrichterID' => 'hybrid-1',
'MPPT' => 'A',
'Modulanzahl' => 16,
'Modulleistung_Wp' => 400.0,
],
[
'ID' => 'pv-west',
'Name' => 'Westdach',
'DCLeistung_kWp' => 6.4,
'Neigung_Grad' => 25.0,
'Azimut_Grad' => 90.0,
'WechselrichterID' => 'hybrid-1',
'MPPT' => 'B',
'Modulanzahl' => 16,
'Modulleistung_Wp' => 400.0,
],
], JSON_THROW_ON_ERROR),
json_encode([[
'ID' => 'bat-1',
'Name' => 'Hausspeicher',
'Nennkapazitaet_kWh' => 13.5,
'Nutzkapazitaet_kWh' => 12.8,
'MaxLadeleistung_kW' => 5.0,
'MaxEntladeleistung_kW' => 5.0,
'WechselrichterID' => 'hybrid-1',
'Kopplung' => 'hybrid',
]], JSON_THROW_ON_ERROR),
'2a518707-8d62-4d97-82ce-606c95726500'
);
self::assertSame(Anlagentopologie::VERSION, $topologie['version']);
self::assertSame(12.8, $topologie['summary']['pvDcKwp']);
self::assertSame(10.0, $topologie['summary']['pvAcKw']);
self::assertSame(12.8, $topologie['summary']['usableBatteryKwh']);
self::assertArrayNotHasKey('acExportLimitKw', $topologie['inverters'][0]);
self::assertArrayNotHasKey('acImportLimitKw', $topologie['inverters'][0]);
self::assertSame([[
'inverterId' => 'hybrid-1',
'acRatedKw' => 10.0,
'pvArrayIds' => ['pv-ost', 'pv-west'],
'batteryIds' => ['bat-1'],
]], $topologie['sharedAcLimits']);
}
public function testLeereTopologieBleibtFuerBestandsinstallationenGueltig(): void
{
$topologie = Anlagentopologie::ausJson(
'[]',
'[]',
'[]',
'2a518707-8d62-4d97-82ce-606c95726500'
);
self::assertSame([], $topologie['inverters']);
self::assertSame([], $topologie['pvArrays']);
self::assertSame([], $topologie['batteries']);
self::assertSame(0.0, $topologie['summary']['pvDcKwp']);
}
public function testPvFlaecheBenoetigtEinenVorhandenenPassendenWechselrichter(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('referenzierter Wechselrichter ist nicht vorhanden');
Anlagentopologie::ausJson(
'[]',
json_encode([[
'ID' => 'pv-1',
'DCLeistung_kWp' => 5.0,
'Neigung_Grad' => 30.0,
'Azimut_Grad' => 0.0,
'WechselrichterID' => 'wr-fehlt',
]], JSON_THROW_ON_ERROR),
'[]',
'2a518707-8d62-4d97-82ce-606c95726500'
);
}
public function testDcBatterieBenoetigtEinenHybridwechselrichter(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('DC- und Hybridkopplung');
Anlagentopologie::ausJson(
json_encode([[
'ID' => 'bat-wr',
'Typ' => 'battery',
'ACNennleistung_kW' => 5.0,
]], JSON_THROW_ON_ERROR),
'[]',
json_encode([[
'ID' => 'bat-1',
'Nennkapazitaet_kWh' => 10.0,
'Nutzkapazitaet_kWh' => 9.0,
'MaxLadeleistung_kW' => 4.0,
'MaxEntladeleistung_kW' => 4.0,
'WechselrichterID' => 'bat-wr',
'Kopplung' => 'dc',
]], JSON_THROW_ON_ERROR),
'2a518707-8d62-4d97-82ce-606c95726500'
);
}
public function testModuldatenMuessenGemeinsamGesetztWerden(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Modulanzahl und Modulleistung');
Anlagentopologie::ausJson(
json_encode([[
'ID' => 'pv-wr',
'Typ' => 'pv',
'ACNennleistung_kW' => 5.0,
]], JSON_THROW_ON_ERROR),
json_encode([[
'ID' => 'pv-1',
'DCLeistung_kWp' => 6.0,
'Neigung_Grad' => 30.0,
'Azimut_Grad' => 0.0,
'WechselrichterID' => 'pv-wr',
'Modulanzahl' => 15,
'Modulleistung_Wp' => 0.0,
]], JSON_THROW_ON_ERROR),
'[]',
'2a518707-8d62-4d97-82ce-606c95726500'
);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class BatterieModulstrukturTest extends TestCase
{
private const MODULPFAD = __DIR__ . '/../Batterie/';
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
{
foreach (['module.json', 'form.json', 'module.php', 'README.md'] as $datei) {
self::assertFileExists(self::MODULPFAD . $datei);
}
$metadaten = json_decode(
(string) file_get_contents(self::MODULPFAD . 'module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame('{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}', $metadaten['id']);
self::assertSame('Batterie', $metadaten['name']);
self::assertSame('Enelix', $metadaten['vendor']);
self::assertSame(3, $metadaten['type']);
}
public function testAllePropertiesSindImFormularEnthalten(): void
{
$basis = (string) file_get_contents(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
preg_match_all(
'/RegisterProperty(?:Integer|Float|Boolean|String)\(\'([^\']+)\'/',
$basis . $modul,
$treffer
);
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
foreach (array_values(array_unique($treffer[1])) as $property) {
self::assertStringContainsString(
'"name": "' . $property . '"',
$formular,
$property
);
}
}
public function testModulIstEreignisbasiertUndVerwendetVertrag40(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul);
self::assertStringContainsString('public function MessageSink(', $modul);
self::assertStringContainsString('RegisterMessage($neueID, self::VM_UPDATE)', $modul);
self::assertStringContainsString("'Betriebsart' =>", $modul);
self::assertStringNotContainsString('Timer_Do_UserCalc_Battery', $modul);
self::assertStringNotContainsString("RegisterTimer('Regelzyklus'", $modul);
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul);
}
public function testRegisterWerdenUeberAusgewaehlteAktionsvariablenGeschrieben(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
self::assertStringContainsString(
'RequestAction($this->ReadPropertyInteger($property), $wert)',
$modul
);
self::assertStringContainsString('FormBatterietyp', $modul);
self::assertStringContainsString('UpdateFormField', $modul);
foreach ([
'ManagementRegisterVariableID',
'ModusRegisterVariableID',
'LeistungsRegisterVariableID',
'LadeleistungRegisterVariableID',
'EntladeleistungRegisterVariableID',
] as $property) {
self::assertStringContainsString('"name": "' . $property . '"', $formular);
}
}
public function testLoggingDiagnoseUndAktionsbuttonsSindVorhanden(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul);
self::assertStringContainsString('"name": "DiagnosevariablenAnzeigen"', $formular);
self::assertStringContainsString('Messwerte und Angebot aktualisieren', $formular);
self::assertStringContainsString('Register auf sicheren Zustand setzen', $formular);
}
public function testManagerKenntDieBatterieModulIDGenauEinmal(): void
{
$manager = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertSame(
1,
substr_count($manager, '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}')
);
}
}
+210
View File
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\BatterieRegler;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class BatterieReglerTest extends TestCase
{
public function testLeistungsbereichBleibtInnerhalbDynamischerGrenzen(): void
{
self::assertSame(
[['Von_W' => -999, 'Bis_W' => 1000]],
BatterieRegler::leistungsangebot(
true, true, false, 50.0, 20.0, 10.0, false,
0.0, 0.0, 1000.9, 999.9
)
);
}
public function testHystereseSchaltetAnDenBisherigenGrenzen(): void
{
self::assertTrue(BatterieRegler::hystereseAktiv(20.0, 20.0, 2.0, false));
self::assertTrue(BatterieRegler::hystereseAktiv(21.0, 20.0, 2.0, true));
self::assertFalse(BatterieRegler::hystereseAktiv(22.1, 20.0, 2.0, true));
}
public function testPvAngebotVerwendetStufenloseBereicheMitBisherigerSocLogik(): void
{
self::assertSame(
[['Von_W' => -1000, 'Bis_W' => 1000]],
BatterieRegler::leistungsangebot(
true, true, false, 50.0, 20.0, 10.0, false,
0.0, 0.0, 1000.0, 1000.0
)
);
self::assertSame(
[1000],
BatterieRegler::leistungsangebot(
true, true, false, 19.0, 20.0, 10.0, true,
0.0, 0.0, 1000.0, 1000.0
)
);
self::assertSame(
[['Von_W' => -1000, 'Bis_W' => 0]],
BatterieRegler::leistungsangebot(
true, true, false, 100.0, 20.0, 10.0, false,
0.0, 0.0, 1000.0, 1000.0
)
);
}
public function testPeakAngebotBeruecksichtigtNetzUndAktuelleBatterieleistung(): void
{
self::assertSame(
[-500],
BatterieRegler::leistungsangebot(
true, true, true, 50.0, 20.0, 10.0, false,
700.0, 200.0, 1000.0, 1000.0
)
);
}
public function testPeakEinschraenkungenWerdenAlsBereicheGemeldet(): void
{
self::assertSame(
[['Von_W' => -1000, 'Bis_W' => 0]],
BatterieRegler::leistungsangebot(
true, true, true, 21.0, 20.0, 10.0, true,
0.0, 0.0, 1000.0, 1000.0
)
);
self::assertSame(
[['Von_W' => 0, 'Bis_W' => 1000]],
BatterieRegler::leistungsangebot(
true, true, true, 10.0, 20.0, 10.0, true,
0.0, 0.0, 1000.0, 1000.0
)
);
}
public function testWechselrichtersteuerungBietetNurNull(): void
{
self::assertSame(
[0],
BatterieRegler::leistungsangebot(
true, false, false, 50.0, 20.0, 10.0, false,
0.0, 0.0, 1000.0, 1000.0
)
);
}
public function testHerstellerunabhaengigeRegisterwerte(): void
{
self::assertSame(
[
'Ladeleistung' => 1500,
'Entladeleistung' => 0,
'Modus' => 0,
'Management' => 1,
],
BatterieRegler::registerwerte(
BatterieRegler::TYP_HERSTELLERUNABHAENGIG,
BatterieRegler::STEUERUNG_ENELIX,
1500
)
);
self::assertSame(
[
'Ladeleistung' => 0,
'Entladeleistung' => 1500,
'Modus' => 1,
'Management' => 1,
],
BatterieRegler::registerwerte(
BatterieRegler::TYP_HERSTELLERUNABHAENGIG,
BatterieRegler::STEUERUNG_ENELIX,
-1500
)
);
}
public function testGoodweRegisterwerte(): void
{
self::assertSame(
['Leistung' => 750, 'Modus' => 11, 'Management' => 2],
BatterieRegler::registerwerte(
BatterieRegler::TYP_GOODWE,
BatterieRegler::STEUERUNG_ENELIX,
750
)
);
self::assertSame(
['Leistung' => 750, 'Modus' => 12, 'Management' => 2],
BatterieRegler::registerwerte(
BatterieRegler::TYP_GOODWE,
BatterieRegler::STEUERUNG_ENELIX,
-750
)
);
self::assertSame(
['Leistung' => 0, 'Management' => 1],
BatterieRegler::registerwerte(
BatterieRegler::TYP_GOODWE,
BatterieRegler::STEUERUNG_WECHSELRICHTER,
-750
)
);
}
public function testSolarEdgeRegisterwerte(): void
{
self::assertSame(
[
'Ladeleistung' => 0,
'Entladeleistung' => 500,
'Modus' => 4,
'Management' => 4,
],
BatterieRegler::registerwerte(
BatterieRegler::TYP_SOLAREDGE,
BatterieRegler::STEUERUNG_ENELIX,
-500
)
);
}
public function testSigenergySkaliertLeistungAufKilowatt(): void
{
self::assertSame(
[
'Ladeleistung' => 1.5,
'Entladeleistung' => 0.0,
'Modus' => 3,
'Management' => 1,
],
BatterieRegler::registerwerte(
BatterieRegler::TYP_SIGENERGY,
BatterieRegler::STEUERUNG_ENELIX,
1500
)
);
self::assertSame(
[
'Ladeleistung' => 0.0,
'Entladeleistung' => 1.5,
'Modus' => 6,
'Management' => 1,
],
BatterieRegler::registerwerte(
BatterieRegler::TYP_SIGENERGY,
BatterieRegler::STEUERUNG_ENELIX,
-1500
)
);
}
public function testUngueltigeGrenzenWerdenAbgewiesen(): void
{
$this->expectException(InvalidArgumentException::class);
BatterieRegler::leistungsangebot(
true, true, false, 50.0, 20.0, 10.0, false,
0.0, 0.0, -1.0, 1000.0
);
}
}
+75
View File
@@ -18,8 +18,12 @@ final class DemoanlageBootstrapTest extends TestCase
'boiler1' => 2400,
'boiler2' => 1500,
'dryer' => 900,
'buffer' => 1800,
'battery' => -1200,
'temp1' => 51.2,
'temp2' => 48.7,
'bufferTemp' => 39.4,
'batterySoc' => 63.5,
'manager' => 'PV',
]);
@@ -27,6 +31,9 @@ final class DemoanlageBootstrapTest extends TestCase
self::assertStringContainsString('Boiler 1', $html);
self::assertStringContainsString('Boiler 2', $html);
self::assertStringContainsString('Entfeuchter', $html);
self::assertStringContainsString('Pufferspeicher', $html);
self::assertStringContainsString('Batterie', $html);
self::assertStringContainsString('63,5 %', $html);
self::assertStringContainsString('Einspeisung', $html);
self::assertStringContainsString('12:30', $html);
}
@@ -105,6 +112,31 @@ final class DemoanlageBootstrapTest extends TestCase
self::assertStringContainsString('ENELIX_DEMO_DUMMY_MODULE', $source);
self::assertStringContainsString("'Boiler1Liste'", $source);
self::assertStringContainsString("'Boiler2Liste'", $source);
self::assertStringContainsString("'PufferListe'", $source);
self::assertStringContainsString("'BatterieListe'", $source);
self::assertStringContainsString('ENELIX_DEMO_BUFFER_MODULE', $source);
self::assertStringContainsString('ENELIX_DEMO_BATTERY_MODULE', $source);
self::assertStringContainsString("'AnlagenWechselrichter'", $source);
self::assertStringContainsString("'AnlagenPVFlaechen'", $source);
self::assertStringContainsString("'AnlagenBatterien'", $source);
self::assertStringContainsString("'EnergyPieAnzeigen' => true", $source);
self::assertStringContainsString("'EnergiediagrammeAnzeigen' => true", $source);
self::assertStringContainsString("'FunFactsAnzeigen' => true", $source);
self::assertStringContainsString("'EnergieflussAnzeigen' => true", $source);
self::assertStringContainsString("'LeistungVariableID' => \$batteryActual", $source);
self::assertStringContainsString("'SOCVariableID' => \$batterySoc", $source);
self::assertStringContainsString("'PrognoseSendeintervall' => 60", $source);
self::assertStringContainsString("'IstleistungVariableID' => \$inverterActual", $source);
self::assertStringContainsString("'BegrenzungVariableID' => \$inverterLimit", $source);
self::assertStringContainsString("'Begrenzungsart' => 'watt'", $source);
self::assertStringContainsString("'EinspeisebegrenzungAktiv' => true", $source);
self::assertStringContainsString("'Einspeisegrenze' => 3000.0", $source);
self::assertStringContainsString("'PrognoseAktiv' => false", $source);
self::assertStringContainsString("'NetzfahrplanAktiv' => false", $source);
self::assertStringNotContainsString("'Einspeisetoleranz'", $source);
self::assertStringContainsString("'demo-hybrid-1'", $source);
self::assertStringContainsString("'demo-pv-1'", $source);
self::assertStringContainsString("'demo-battery-1'", $source);
self::assertStringContainsString("'EnergieflussPVkW'", $source);
self::assertStringContainsString("'~Power'", $source);
self::assertStringContainsString("'~Electricity'", $source);
@@ -120,6 +152,20 @@ final class DemoanlageBootstrapTest extends TestCase
"enelixDemoEnergyNode(\$flowDryer, 'Entfeuchter', 'consumer')",
$source
);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowBuffer, 'Pufferspeicher', 'consumer', \$bufferTemp)",
$source
);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowBattery, 'Batteriespeicher', 'battery', \$batterySoc)",
$source
);
self::assertStringContainsString(
"\$consumerIds = [\$boiler1, \$boiler2, \$dryer, \$buffer, \$battery]",
$source
);
self::assertStringContainsString("'consumerIds' => \$consumerIds", $source);
self::assertStringContainsString("'VerbraucherZuordnung' => '[]'", $source);
self::assertStringContainsString(
"enelixDemoEnsureChart(\$history, 'LeistungsdiagrammNativ'",
$source
@@ -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
{
$this->expectException(RuntimeException::class);
+29 -15
View File
@@ -8,27 +8,27 @@ use PHPUnit\Framework\TestCase;
final class DokumentationsstrukturTest extends TestCase
{
/** @return list<array{string}> */
public function modulReadmes(): array
public function testWaermepumpeDokumentiertDenImplementiertenStand(): void
{
return [
['Batterie'],
['Waermepumpe'],
['Ladestation-Gateway'],
['Easee-Gateway'],
];
}
/** @dataProvider modulReadmes */
public function testJederModulentwurfHatEinReadme(string $modul): void
{
$pfad = __DIR__ . '/../docs/module/' . $modul . '/README.md';
$pfad = __DIR__ . '/../docs/module/Waermepumpe/README.md';
self::assertFileExists($pfad);
$inhalt = file_get_contents($pfad);
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('Registeradapter', $inhalt);
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle-Batterie.md');
self::assertFileExists(__DIR__ . '/../docs/migration/Batterie.md');
}
public function testPufferspeicherDokumentiertDenImplementiertenStand(): void
@@ -48,9 +48,23 @@ final class DokumentationsstrukturTest extends TestCase
self::assertStringContainsString('Fake-HTTP-Transport', $inhalt);
}
public function testEaseeModuleDokumentierenDenImplementiertenStand(): void
{
foreach (['Easee-Gateway', 'Ladestation-Gateway'] as $modul) {
$inhalt = file_get_contents(
__DIR__ . '/../docs/module/' . $modul . '/README.md'
);
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: implementiert', $inhalt);
self::assertStringContainsString('Properties', $inhalt);
self::assertStringContainsString('Tests', $inhalt);
}
}
public function testSchnittstellenDokumentiertSind(): void
{
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle.md');
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle-Easee-Gateway.md');
self::assertFileExists(__DIR__ . '/../docs/Obere-Anschluesse.md');
self::assertFileExists(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\EaseeGatewayProtokoll;
use PHPUnit\Framework\TestCase;
final class EaseeGatewayProtokollTest extends TestCase
{
public function testVerschachtelteSignalRBeobachtungenWerdenExtrahiert(): void
{
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
'arguments' => [
'EH123456',
[
['id' => 109, 'value' => 3],
['ObservationId' => 110, 'Value' => 30],
],
],
], ['EH123456']);
self::assertSame([
['Seriennummer' => 'EH123456', 'ID' => 109, 'Wert' => 3],
['Seriennummer' => 'EH123456', 'ID' => 110, 'Wert' => 30],
], $beobachtungen);
}
public function testNichtAbonnierteStationenUndUnbekannteIdsWerdenIgnoriert(): void
{
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
[
'serialNumber' => 'EH999999',
'id' => 109,
'value' => 3,
],
[
'serialNumber' => 'EH123456',
'id' => 999,
'value' => 1,
],
], ['EH123456']);
self::assertSame([], $beobachtungen);
}
public function testSeriennummerWirdNormalisiert(): void
{
self::assertSame(
'EH123456',
EaseeGatewayProtokoll::seriennummer(' eh123456 ')
);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\EaseeLadestatus;
use PHPUnit\Framework\TestCase;
final class EaseeLadestatusTest extends TestCase
{
public function testFahrzeugUndEinphasigesLadenKommenDirektAusApiWerten(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 3,
'110' => 10,
'120' => 2.3,
'182' => 10.1,
], 16);
self::assertTrue($status['FahrzeugVerbunden']);
self::assertFalse($status['FahrzeugGeladen']);
self::assertSame(3, $status['Fahrzeugstatus']);
self::assertSame(1, $status['Phasenzahl']);
self::assertEqualsWithDelta(2300.0, $status['Istleistung_W'], 0.01);
self::assertEqualsWithDelta(10.1, $status['Ladestrom_A'], 0.01);
}
public function testDreiphasigesLadenWirdNichtAusLeistungGeschaetzt(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 3,
'110' => 30,
'120' => 1.0,
'182' => 2.0,
'183' => 2.2,
'184' => 2.1,
], 32);
self::assertSame(3, $status['Phasenzahl']);
self::assertEqualsWithDelta(2.2, $status['Ladestrom_A'], 0.01);
}
public function testZweiphasigerApiWertWirdNichtAlsEinOderDreiGeschaetzt(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 3,
'110' => 20,
'120' => 4.6,
], 16);
self::assertSame(0, $status['Phasenzahl']);
}
public function testGetrenntUndGeladenFolgenDemEaseeBetriebszustand(): void
{
$getrennt = EaseeLadestatus::ausBeobachtungen(['109' => 1, '110' => 30], 16);
$geladen = EaseeLadestatus::ausBeobachtungen(['109' => 4, '110' => 30], 16);
self::assertFalse($getrennt['FahrzeugVerbunden']);
self::assertSame(0, $getrennt['Phasenzahl']);
self::assertTrue($geladen['FahrzeugVerbunden']);
self::assertTrue($geladen['FahrzeugGeladen']);
self::assertSame(4, $geladen['Fahrzeugstatus']);
}
public function testUnbekannterApiBetriebszustandGibtRegelungNichtFrei(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 99,
'110' => 30,
], 16);
self::assertFalse($status['StatusGueltig']);
self::assertFalse($status['FahrzeugVerbunden']);
self::assertSame(0, $status['Fahrzeugstatus']);
self::assertSame(0, $status['Phasenzahl']);
}
public function testApiUndKabelgrenzenBegrenzenDenKonfiguriertenStrom(): void
{
$status = EaseeLadestatus::ausBeobachtungen([
'109' => 2,
'110' => 10,
'47' => 25.0,
'104' => 13.7,
], 32);
self::assertSame(13, $status['ApiMaximalstrom_A']);
}
public function testNurSolarladenIstFestErzwungen(): void
{
self::assertFalse(EaseeLadestatus::solarladenErzwungen(0));
self::assertTrue(EaseeLadestatus::solarladenErzwungen(1));
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class EaseeModuleStrukturTest extends TestCase
{
public function testGatewayUndLadestationSindVollstaendigeModule(): void
{
foreach (['EaseeGateway', 'LadestationGateway'] as $modul) {
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
self::assertFileExists(__DIR__ . '/../' . $modul . '/' . $datei);
}
}
}
public function testDatenSchnittstellenPassenZusammen(): void
{
$gateway = $this->module('EaseeGateway');
$ladestation = $this->module('LadestationGateway');
self::assertContains(
'{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}',
$gateway['implemented']
);
self::assertContains(
'{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}',
$gateway['childRequirements']
);
self::assertContains(
'{7AEF3DF7-DA5B-47C5-BCDC-0110D06DDC04}',
$ladestation['parentRequirements']
);
self::assertContains(
'{107D5CFA-8F3D-4E38-8643-90DDFE6A3B4D}',
$ladestation['implemented']
);
}
public function testZugangsdatenLiegenNurImGateway(): void
{
$gatewayForm = (string) file_get_contents(
__DIR__ . '/../EaseeGateway/form.json'
);
$ladestationForm = (string) file_get_contents(
__DIR__ . '/../LadestationGateway/form.json'
);
self::assertStringContainsString('PasswordTextBox', $gatewayForm);
self::assertStringNotContainsString('PasswordTextBox', $ladestationForm);
self::assertStringNotContainsString('Username', $ladestationForm);
}
public function testLadestationVerwendetApiStatusUndGemeinsamenVertrag(): void
{
$modul = (string) file_get_contents(
__DIR__ . '/../LadestationGateway/module.php'
);
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
self::assertStringContainsString('EaseeLadestatus::ausBeobachtungen', $modul);
self::assertStringContainsString(
"RegisterPropertyBoolean('Ladefreigabe', true)",
$modul
);
self::assertStringContainsString("'FahrzeugVerbunden'", $modul);
self::assertStringContainsString("'Phasenzahl'", $modul);
self::assertStringNotContainsString('7500', $modul);
self::assertStringNotContainsString('Abfrageintervall', $modul);
}
/** @return array<string, mixed> */
private function module(string $name): array
{
return json_decode(
(string) file_get_contents(__DIR__ . '/../' . $name . '/module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\EinspeiseRegler;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class EinspeiseReglerTest extends TestCase
{
public function testUeberschreitungWirdAufDieGesamtanlageBegrenzt(): void
{
$ergebnis = EinspeiseRegler::verteile(
-15000.0,
10000.0,
[
['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 10000],
['ID' => 'wr-2', 'Nennleistung_W' => 5000, 'Istleistung_W' => 5000],
]
);
self::assertSame(10000.0, $ergebnis['ZielGesamtleistung_W']);
self::assertSame(5000.0, $ergebnis['Abregelbedarf_W']);
self::assertSame(6666.667, $ergebnis['Grenzen_W']['wr-1']);
self::assertSame(3333.333, $ergebnis['Grenzen_W']['wr-2']);
}
public function testAbregelungFolgtDerTatsaechlichenErzeugung(): void
{
$ergebnis = EinspeiseRegler::verteile(
-10000.0,
5000.0,
[
['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 10000],
['ID' => 'wr-2', 'Nennleistung_W' => 10000, 'Istleistung_W' => 0],
]
);
self::assertSame(5000.0, $ergebnis['Grenzen_W']['wr-1']);
self::assertSame(0.0, $ergebnis['Grenzen_W']['wr-2']);
}
public function testFreierNetzspielraumHebtEineBestehendeBegrenzungAn(): void
{
$ergebnis = EinspeiseRegler::verteile(
-6000.0,
10000.0,
[
['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 4000],
['ID' => 'wr-2', 'Nennleistung_W' => 10000, 'Istleistung_W' => 2000],
],
['wr-1' => 5000, 'wr-2' => 5000]
);
self::assertSame(14000.0, $ergebnis['ZielGesamtleistung_W']);
self::assertSame(7000.0, $ergebnis['Grenzen_W']['wr-1']);
self::assertSame(7000.0, $ergebnis['Grenzen_W']['wr-2']);
}
public function testRegelungFolgtAuchKleinenAbweichungenStufenlos(): void
{
$ergebnis = EinspeiseRegler::verteile(
-10050.0,
10000.0,
[['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 9000]],
['wr-1' => 8000]
);
self::assertSame(7950.0, $ergebnis['ZielGesamtleistung_W']);
}
public function testStellwerteWerdenInProzentOderWattAusgegeben(): void
{
self::assertSame(62.5, EinspeiseRegler::stellwert(5000.0, 8000.0, 'percent'));
self::assertSame(5000.0, EinspeiseRegler::stellwert(5000.0, 8000.0, 'watt'));
}
public function testUngueltigeBegrenzungsartWirdAbgelehnt(): void
{
$this->expectException(InvalidArgumentException::class);
EinspeiseRegler::stellwert(5000.0, 8000.0, 'volt');
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\EnergieMessung;
use PHPUnit\Framework\TestCase;
final class EnergieMessungTest extends TestCase
{
public function testIntegriertVierLeistungenMitVorzeichenrichtung(): void
{
$werte = EnergieMessung::energieDeltas([
'PV' => 7200.0,
'Haus' => 3600.0,
'Netz' => -1800.0,
'Batterie' => 900.0,
], 300);
self::assertEqualsWithDelta(0.6, $werte['PVEnergie'], 0.000001);
self::assertEqualsWithDelta(0.3, $werte['Hausenergie'], 0.000001);
self::assertSame(0.0, $werte['NetzbezugEnergie']);
self::assertEqualsWithDelta(0.15, $werte['EinspeisungEnergie'], 0.000001);
self::assertEqualsWithDelta(0.075, $werte['BatterieLadenEnergie'], 0.000001);
self::assertSame(0.0, $werte['BatterieEntladenEnergie']);
}
public function testTrenntBezugUndBatterieentladung(): void
{
$werte = EnergieMessung::energieDeltas([
'PV' => -10.0,
'Haus' => -10.0,
'Netz' => 1000.0,
'Batterie' => -2000.0,
], 3600);
self::assertSame(0.0, $werte['PVEnergie']);
self::assertSame(0.0, $werte['Hausenergie']);
self::assertSame(1.0, $werte['NetzbezugEnergie']);
self::assertSame(0.0, $werte['EinspeisungEnergie']);
self::assertSame(0.0, $werte['BatterieLadenEnergie']);
self::assertSame(2.0, $werte['BatterieEntladenEnergie']);
}
public function testBerechnetHausverbrauchAusDerAnlagenbilanz(): void
{
self::assertSame(2700.0, EnergieMessung::hausleistung(4200.0, -800.0, 700.0));
self::assertSame(3500.0, EnergieMessung::hausleistung(2000.0, 500.0, -1000.0));
self::assertSame(0.0, EnergieMessung::hausleistung(500.0, -1200.0, 0.0));
}
public function testBerechnetHausenergieAusDenZaehlerdeltas(): void
{
self::assertSame(8.5, EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0));
self::assertSame(0.0, EnergieMessung::hausenergie(1.0, 0.0, 5.0, 0.0, 0.0));
}
public function testIgnoriertUngueltigeDauerUndNichtEndlicheWerte(): void
{
$werte = EnergieMessung::energieDeltas([
'PV' => INF,
'Haus' => NAN,
'Netz' => 1000.0,
'Batterie' => -1000.0,
], -1);
foreach ($werte as $wert) {
self::assertSame(0.0, $wert);
}
}
}
@@ -64,6 +64,10 @@ final class LadestationStandAloneModulstrukturTest extends TestCase
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString(
"RegisterPropertyBoolean('Ladefreigabe', true)",
$modul
);
self::assertStringContainsString("EnableAction('Ladefreigabe')", $modul);
self::assertStringContainsString("EnableAction('Solarladen')", $modul);
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul);
+3
View File
@@ -17,6 +17,9 @@ final class LibraryMetadataTest extends TestCase
self::assertSame('Enelix EMS', $daten['name']);
self::assertSame('Enelix', $daten['author']);
self::assertSame('8.0', $daten['compatibility']['version']);
self::assertSame('0.1', $daten['version']);
self::assertSame(1, $daten['build']);
self::assertGreaterThan(0, $daten['date']);
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']);
}
}
+124
View File
@@ -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
{
self::assertSame(
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class ManagerEnergieModulstrukturTest extends TestCase
{
public function testManagerKonfiguriertMessquellenUndArchivregeln(): void
{
$modul = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
$trait = (string) file_get_contents(__DIR__ . '/../libs/ManagerEnergieTrait.php');
$formular = (string) file_get_contents(__DIR__ . '/../Manager/form.json');
foreach ([
"RegisterPropertyBoolean('EnergyPieAnzeigen', false)",
"RegisterPropertyBoolean('EnergiediagrammeAnzeigen', false)",
"RegisterPropertyBoolean('FunFactsAnzeigen', false)",
"RegisterPropertyBoolean('EnergieflussAnzeigen', false)",
"RegisterPropertyInteger('LeistungsaufzeichnungMinuten', 1)",
"RegisterPropertyInteger('LeistungLoeschenMonate', 12)",
"RegisterPropertyInteger('EnergieaufzeichnungMinuten', 5)",
"RegisterPropertyInteger('EnergieVerdichtenMonate', 12)",
"RegisterPropertyInteger('EnergieLoeschenMonate', 0)",
] as $erwartet) {
self::assertStringContainsString($erwartet, $modul);
}
foreach ([
'AC_SetLoggingStatus',
'AC_SetAggregationType',
'AC_SetCompaction',
'AC_ReAggregateVariable',
'IPS_CreateMedia(4)',
'IPS_SetMediaContent',
'ENERGY_PIE_MODULE_ID',
'ENERGY_FLOW_MODULE_ID',
'energieflussKnoten',
'IPS_DeleteInstance',
'IPS_DeleteMedia',
] as $erwartet) {
self::assertStringContainsString($erwartet, $modul . $trait);
}
foreach ([
'Energieaufzeichnung',
'FormEnergyPieAnzeigen',
'FormEnergiediagrammeAnzeigen',
'FormFunFactsAnzeigen',
'FormEnergieflussAnzeigen',
'MesswerteAnzeigen',
'IstleistungVariableID',
'LeistungVariableID',
'SOCVariableID',
'NetzbezugEnergieVariableID',
'NetzeinspeisungEnergieVariableID',
'ErzeugungsenergieVariableID',
'LadeenergieVariableID',
'EntladeenergieVariableID',
] as $erwartet) {
self::assertStringContainsString($erwartet, $formular);
}
}
public function testManagerFuehrtAlleMessvariablenUndRichtungszaehler(): void
{
$modul = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
foreach ([
'PVLeistungArchiv',
'HausverbrauchLeistungArchiv',
'NetzleistungArchiv',
'BatterieleistungArchiv',
'PVEnergie',
'Hausenergie',
'NetzbezugEnergie',
'EinspeisungEnergie',
'BatterieLadenEnergie',
'BatterieEntladenEnergie',
] as $ident) {
self::assertStringContainsString("'" . $ident . "'", $modul);
}
self::assertStringContainsString("RegisterTimer(
'MesswerteAufzeichnen'", $modul);
self::assertStringContainsString('EnergieMessung::energieDeltas', $modul);
self::assertStringContainsString('EnergieMessung::hausenergie', $modul);
self::assertStringContainsString("RegisterAttributeString('MessungZaehlerstaende'", $modul);
}
public function testFunFactsSindResponsivUndErklaerenUmrechnungen(): void
{
$trait = (string) file_get_contents(__DIR__ . '/../libs/ManagerEnergieTrait.php');
self::assertStringContainsString('grid-template-columns:repeat(3,minmax(0,1fr))', $trait);
self::assertStringContainsString('@media(max-width:820px)', $trait);
self::assertStringContainsString('10 kWh pro Liter Heizoel', $trait);
self::assertStringContainsString('2,4 Flugkilometern', $trait);
self::assertStringContainsString('AC_GetAggregatedValues', $trait);
}
}
+205 -1
View File
@@ -48,18 +48,49 @@ final class ManagerModulstrukturTest extends TestCase
'VerbraucherTimeout',
'MesswertMaxAlter',
'Umschaltdifferenz',
'EinspeisebegrenzungAktiv',
'Einspeisegrenze',
'PrognoseAktiv',
'PrognoseLizenzInformation',
'NetzfahrplanAktiv',
'NetzbezugEnergieVariableID',
'NetzeinspeisungEnergieVariableID',
'IstleistungVariableID',
'ErzeugungsenergieVariableID',
'Erzeugungsenergiefaktor',
'BegrenzungVariableID',
'Begrenzungsart',
'DiagnosevariablenAnzeigen',
'LoggingEin',
'Lizenzcode',
'LizenzPruefen',
'LizenzVerwalten',
'LizenzInstallationID',
'LizenzInformation',
'VerbraucherAktualisieren',
'AnlagenWechselrichter',
'AnlagenPVFlaechen',
'AnlagenBatterien',
'Istleistungsfaktor',
'LeistungVariableID',
'Leistungsfaktor',
'SOCVariableID',
'LadeenergieVariableID',
'EntladeenergieVariableID',
'Energiefaktor',
'EnergyPieAnzeigen',
'EnergiediagrammeAnzeigen',
'FunFactsAnzeigen',
'EnergieflussAnzeigen',
'PrognoseSendeintervall',
'StoerueberwachungAktiv',
'StoerungsSendeintervall',
] as $property) {
self::assertStringContainsString($property, $json);
}
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);
}
@@ -69,6 +100,8 @@ final class ManagerModulstrukturTest extends TestCase
self::assertStringContainsString($bereich, $json);
}
self::assertSame('Lizenzierung', $formular['elements'][0]['caption']);
self::assertStringContainsString('https://license.enelix.ch', $json);
self::assertStringContainsString('"link":true', $json);
self::assertStringContainsString(
"'SuchbereichID' => \$SuchbereichID",
$json
@@ -107,13 +140,182 @@ final class ManagerModulstrukturTest extends TestCase
$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('LizenzcodeHash'", $inhalt);
self::assertStringContainsString('STATUS_LIZENZ_UNGUELTIG', $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);
}
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
{
$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');
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("'ManagerdatenEmpfangen'", $inhalt);
}
+11
View File
@@ -43,6 +43,17 @@ final class NachrichtenvertragTest extends TestCase
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
{
$daten = $this->verbraucherdaten();
+131
View File
@@ -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',
]]
);
}
}
+40
View File
@@ -3,6 +3,35 @@
declare(strict_types=1);
return [
'Batterie' => [
'file' => __DIR__ . '/modules/Batterie.php',
'paths' => [
'Batterie/*',
'libs/BatterieRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'EaseeGateway' => [
'file' => __DIR__ . '/modules/EaseeGateway.php',
'paths' => [
'EaseeGateway/*',
'libs/EaseeGatewayProtokoll.php',
],
],
'LadestationGateway' => [
'file' => __DIR__ . '/modules/LadestationGateway.php',
'paths' => [
'LadestationGateway/*',
'libs/EaseeGatewayProtokoll.php',
'libs/EaseeLadestatus.php',
'libs/LadestationRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'LadestationStandAlone' => [
'file' => __DIR__ . '/modules/LadestationStandAlone.php',
'paths' => [
@@ -18,6 +47,7 @@ return [
'file' => __DIR__ . '/modules/Manager.php',
'paths' => [
'Manager/*',
'libs/EinspeiseRegler.php',
'libs/Lizenzpruefung.php',
'libs/ManagerRegler.php',
'libs/ManagerSchnittstelle.php',
@@ -45,6 +75,16 @@ return [
'libs/Nachrichtenvertrag.php',
],
],
'Waermepumpe' => [
'file' => __DIR__ . '/modules/Waermepumpe.php',
'paths' => [
'Waermepumpe/*',
'libs/WaermepumpenRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'Warmwassererwaermer' => [
'file' => __DIR__ . '/modules/Warmwassererwaermer.php',
'paths' => [
+180
View File
@@ -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
);
}
);
};
+89
View File
@@ -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']);
});
};
+155
View File
@@ -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);
});
};
+170 -2
View File
@@ -5,7 +5,10 @@ declare(strict_types=1);
use Belevo\EnelixEMS\SymconTest\TestContext;
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');
$einStufigId = $test->createInstance(
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
@@ -72,12 +75,48 @@ return static function (TestContext $test): void {
['InstanzID' => $warmwasserId, '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'));
$managerId = $test->createInstance(
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
'Manager',
[
'NetzleistungVariableID' => $netzleistungId,
'EnergyPieAnzeigen' => true,
'EnergiediagrammeAnzeigen' => true,
'FunFactsAnzeigen' => true,
'EnergieflussAnzeigen' => true,
'Lizenzcode' => $lizenzcode,
'AutomatischeSuche' => false,
'VerbraucherZuordnung' => json_encode($zuordnung, JSON_THROW_ON_ERROR),
@@ -87,6 +126,12 @@ return static function (TestContext $test): void {
'Lastspitzengrenze' => 0.0,
'SollwertSolarladen' => 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,
]
);
@@ -145,6 +190,114 @@ return static function (TestContext $test): void {
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 &amp; 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) {
foreach ($daten['Verbraucher'] ?? [] as $verbraucher) {
if (($verbraucher['InstanzID'] ?? 0) !== $instanzId) {
@@ -163,7 +316,7 @@ return static function (TestContext $test): void {
if ($lizenzcode === '') {
$test->runCase('Manager bleibt ohne Lizenz sicher gesperrt', static function (
TestContext $test
) use ($managerId): void {
) use ($managerId, $verbraucherstatus): void {
IPS_RequestAction($managerId, 'Aktiv', true);
IPS_RequestAction($managerId, 'Regeln', true);
$test->assertInstanceStatus($managerId, 203);
@@ -171,6 +324,21 @@ return static function (TestContext $test): void {
'Inaktiv',
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;
+153
View File
@@ -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)
));
});
};
+21 -2
View File
@@ -58,7 +58,16 @@ final class SymconTestContractTest extends TestCase
public function testAffectedModuleResolverSelectsAllMatches(): void
{
self::assertSame(
['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'],
[
'Batterie',
'LadestationGateway',
'LadestationStandAlone',
'Manager',
'Pufferspeicher',
'VerbraucherEinStufig',
'Waermepumpe',
'Warmwassererwaermer',
],
$this->resolveAffectedModules(['libs/Nachrichtenvertrag.php'])
);
}
@@ -74,7 +83,17 @@ final class SymconTestContractTest extends TestCase
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
{
self::assertSame(
['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'],
[
'Batterie',
'EaseeGateway',
'LadestationGateway',
'LadestationStandAlone',
'Manager',
'Pufferspeicher',
'VerbraucherEinStufig',
'Waermepumpe',
'Warmwassererwaermer',
],
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
);
}
+136
View File
@@ -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);
}
}
+162
View File
@@ -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);
}
}