89 Commits
Author SHA1 Message Date
dh 372b997876 feat(forecast): integrate full forecast charts and complete SDL visualization
Tests / test (push) Failing after 52s
2026-10-06 17:40:45 +00:00
dh 5687596799 feat(portal): prepare heat pump licensing and header navigation
Tests / test (push) Failing after 49s
2026-10-06 14:35:17 +00:00
dh faa905ab09 docs: publish public guides, screenshots and Git-synced portal documentation
Tests / test (push) Failing after 50s
2026-10-06 14:32:05 +00:00
dh 9fbc52ee47 feat(manager): integrate forecast controls and merge V4 development
Tests / test (push) Failing after 49s
Merge the V4 development history with current manager, SDL and setpoint fixes. Retain guarded trial behavior and consolidate controls in Prognose / Forecast. Exclude the unverified accounting-evidence change; no runtime deployment or new dispatch permission.

Validated: 357 PHPUnit tests / 1758 assertions, 150 PHP syntax checks, 310 backend tests, 16 portal tests and isolated UI/receiver checks. develop and beta publication explicitly approved by Daniel Haefliger.
2026-10-06 14:22:16 +00:00
dh 5437f3f058 fix(verbraucher): clear discarded power setpoints
Tests / test (push) Failing after 51s
2026-10-06 13:45:54 +00:00
dh ebec355c96 feat(manager): account for SDL power and SOC separately
Tests / test (push) Failing after 1m12s
Add opt-in SDL measurement sources and independent visualization toggles. Exclude balancing power from household telemetry and new household energy. Preserve custom views and cover measurement, migration and Symcon 8.0 behavior.
2026-10-06 13:35:17 +00:00
dh 58f91e4319 feat(manager): distribute equal-priority power stepwise
Tests / test (push) Successful in 44s
2026-10-06 11:55:26 +00:00
dh ec7182b833 fix(charger): stabilize Go-E setpoint control
Tests / test (push) Successful in 47s
2026-10-06 11:33:59 +00:00
dh 37addf4242 feat(manager): Energievisualisierung und Synchronisierung erweitern
Tests / test (push) Successful in 50s
2026-10-06 08:00:30 +00:00
dh ce525a1ca0 fix(v4): reset receiver throttle on reinit 2026-10-05 15:10:25 +00:00
dh e6aa50bb44 fix(v4): retry transient confirmed device reads 2026-10-05 14:50:48 +00:00
dh b6e7353086 docs(v4): record active-operation safety findings 2026-10-04 17:18:14 +00:00
dh f4a3c5fbe7 docs(v4): record deployment blockers 2026-10-04 15:35:35 +00:00
dh 13e49ff60c feat(v4): confirm feedback with device reads 2026-10-04 15:34:36 +00:00
dh 30f7652803 feat(forecast): commission corrected Lihrenmoos profile 2026-10-04 09:07:30 +00:00
ENELIX Agent 913a4cd5f3 docs(v4): preserve conversation decisions and current agent handoff 2026-10-04 07:06:50 +00:00
ENELIX Agent 9878197aea feat(control): connect physical feedback to bounded V4 command and fallback paths 2026-10-03 11:40:54 +00:00
ENELIX Agent 8eb9688942 feat(v4): consolidate audited data recovery economic replay and optional archival 2026-10-03 10:40:18 +00:00
ENELIX Agent 6c91d6bcc6 fix(accounting): stabilize numeric mapping identity across property storage 2026-10-03 09:40:02 +00:00
ENELIX Agent 5890093b80 fix(history): model sparse publication without relaxing live freshness 2026-10-03 08:04:28 +00:00
ENELIX Agent 6d9aba74b0 fix(installer): avoid staged and installed capture class collision 2026-10-03 07:34:44 +00:00
ENELIX Agent fe3f46ba0e fix(release): include forecast test dependencies in versioned application 2026-10-02 21:01:24 +00:00
ENELIX Agent fc024fcfba feat(application): integrate measured-load ingestion training and planner source 2026-10-02 21:00:05 +00:00
ENELIX Agent 44362e4bf5 feat(observation): separate physical load and unfiltered EV-SDL model without dispatch 2026-10-02 17:00:32 +00:00
ENELIX Agent 76f8b067cc feat(data): add passive versioned raw capture independent of control 2026-10-02 14:55:43 +00:00
ENELIX Agent e0e52d2454 feat(accounting): separate physical storage and SDL with source-age validation 2026-10-02 14:23:19 +00:00
ENELIX Agent 9394fe3eb1 feat(manager): add gated short-lease V4 control trial and local fallback 2026-10-02 14:01:41 +00:00
ENELIX Agent b5c1fa6be2 feat(manager): receive and preview V4 shadow plans without dispatch 2026-10-02 13:01:04 +00:00
ENELIX Agent fc4c4632ce feat(manager): transmit estimated meter samples and SOC recovery state 2026-10-01 18:05:59 +00:00
ENELIX Agent 12174a59b4 fix(manager): isolate V4 active import sources from legacy counter 2026-10-01 16:56:39 +00:00
ENELIX Agent 640fe53720 feat(manager): add opt-in native V4 shadow operation telemetry 2026-10-01 16:20:36 +00:00
dh 8d83b083bf feat(batterie): Nachladeleistung begrenzen
Tests / test (push) Successful in 48s
2026-10-01 15:56:23 +00:00
dh 6cd7bf19f5 test(ladestation): Regeluebergang abwarten 2026-10-01 15:56:14 +00:00
dh 3ce9af4244 fix(ladestation): Regeluebergaenge stabilisieren
Tests / test (push) Successful in 50s
2026-10-01 13:01:32 +00:00
dh 8357e8dfab merge: vorbereiteten Fahrplan-Commit uebernehmen
Tests / test (push) Successful in 54s
2026-10-01 12:34:51 +00:00
dh 38353aed93 feat(manager): Fahrplanvorgabe linear interpolieren
Tests / test (push) Successful in 54s
2026-10-01 12:16:10 +00:00
dh cf92e7623b fix(manager): Fahrplanvorgabe linear interpolieren 2026-09-30 14:38:42 +02:00
dh add1077674 fix(manager): Batterie nach Prioritaet als Restverbraucher regeln
Tests / test (push) Canceled after 0s
2026-09-30 05:42:13 +00:00
dh bfdcf4536b fix(ladestation): Steuerbefehle in Diagnose erhalten
Tests / test (push) Canceled after 0s
2026-09-30 04:37:33 +00:00
dh faa5dfb33f feat(ladestation): Phasenerkennung und Schaltsperren erweitern
Tests / test (push) Successful in 45s
2026-09-29 22:49:26 +00:00
dh 53fca5e2bb feat(manager): Batteriesollwert aus Netzfahrplan anwenden
Tests / test (push) Successful in 47s
2026-09-29 17:23:25 +00:00
dh f611da975d fix(easee): Ereignisverbindung konsistent neu aufbauen
Tests / test (push) Successful in 1m2s
2026-09-29 16:47:03 +00:00
dh 393969594e fix(manager): Regelung und Energiefluss korrigieren
Tests / test (push) Successful in 48s
2026-09-29 16:17:49 +00:00
dh cae14cf399 fix(manager): Festlasten nach Istleistung bilanzieren
Tests / test (push) Successful in 52s
2026-09-29 15:11:14 +00:00
dh 3aaa744a8a fix(manager): Batterie als neutralen Restregler behandeln
Tests / test (push) Successful in 51s
2026-09-29 14:38:14 +00:00
dh fdc2007bb1 fix(manager): Energieausgleich aus Enelix 1 wiederherstellen
Tests / test (push) Successful in 54s
2026-09-29 14:14:25 +00:00
dh 37400878b6 fix(easee): veraltete Ladeleistung nach Ladestopp nullen
Tests / test (push) Successful in 50s
2026-09-29 13:15:43 +00:00
dh ace1821213 fix(ladestation): Fahrzeugstrom nach Lernzeit begrenzen
Tests / test (push) Successful in 56s
2026-09-29 12:47:23 +00:00
dh b3b868cd2a fix(ladestation): Ladeanlauf und Easee-Verbindung stabilisieren
Tests / test (push) Successful in 1m23s
2026-09-29 12:25:35 +00:00
dh a74f14aa19 feat(warmwasser): Temperaturgrenzen konfigurierbar machen
Tests / test (push) Successful in 1m10s
2026-09-29 12:11:43 +00:00
dh fe966516b5 test: integrierte Freigabepruefungen stabilisieren
Tests / test (push) Successful in 1m14s
2026-09-29 12:06:51 +00:00
dh b3bbae1a72 fix(manager): Anlagentopologie immer synchronisieren
Tests / test (push) Failing after 50s
2026-09-29 11:51:55 +00:00
dh ae2cd8ecff Merge remote-tracking branch 'origin/develop' into develop
Tests / test (push) Successful in 46s
2026-09-29 11:43:12 +00:00
dh 995b704e36 fix(manager): Prognoseberechtigungen verständlich diagnostizieren
Tests / test (push) Successful in 49s
2026-09-29 11:40:24 +00:00
dh 5ff0d405d4 fix(manager): feste Leistungsangebote immer zuteilen 2026-09-29 11:40:24 +00:00
dh ccecc437e9 fix(easee): wartende Ladevorgaenge starten
Tests / test (push) Successful in 45s
2026-09-29 09:34:37 +00:00
dh 156591a91e fix(ladestation): Ladeende nach Update neu prüfen
Tests / test (push) Successful in 47s
2026-09-29 09:19:56 +00:00
dh e0814a0c14 fix(ladestation): Ladeende nach Update neu prüfen 2026-09-29 09:19:39 +00:00
dh 3cabf597ac fix(ladestation): Solarpause nicht als Ladeende erkennen 2026-09-29 09:07:56 +00:00
dh b86810d30f fix(ladestation): Solarpause nicht als Ladeende erkennen 2026-09-29 09:07:44 +00:00
dh d1fc62d43c fix(manager): Regelung bei veralteten Verbrauchern fortsetzen
Tests / test (push) Successful in 46s
2026-09-29 08:46:44 +00:00
dh 21577c0125 fix(manager): Prognoseberechtigungen verständlich diagnostizieren 2026-09-29 08:41:32 +00:00
dh aefe46352f fix(easee): Wartezustand ohne Ausgangsphase zulassen
Tests / test (push) Successful in 48s
2026-09-29 08:25:08 +00:00
dh 6f76e6cd86 fix(easee): Initialstatus der Ladestation laden
Tests / test (push) Successful in 48s
2026-09-29 07:55:31 +00:00
dh 18c34b32e7 chore(release): erste Beta vorbereiten
Tests / test (push) Successful in 52s
2026-09-29 06:25:26 +00:00
dh b111a5c501 Ladefreigabe für Ladestationen standardmäßig aktivieren
Tests / test (push) Successful in 55s
2026-09-29 06:24:26 +00:00
dh ef708564d8 feat: Wärmepumpenlizenz separat freischalten
Tests / test (push) Successful in 47s
2026-09-29 05:53:15 +00:00
dh cf2ce3aff4 feat(manager): Lizenzierung und Energietopologie verfeinern
Tests / test (push) Successful in 46s
2026-09-28 17:47:22 +00:00
dh 708a8ecea2 feat(manager): Energieaufzeichnung und Visualisierungen integrieren
Tests / test (push) Successful in 47s
2026-09-27 19:29:37 +00:00
dh 40f41a2fb8 fix(manager): Störungszugang zuverlässig aktivieren
Tests / test (push) Successful in 52s
2026-09-27 16:39:15 +00:00
dh 2d24ce0900 feat(manager): Störungsüberwachung ans Portal anbinden
Tests / test (push) Successful in 47s
2026-09-27 15:41:15 +00:00
dh f47c908798 feat(batterie): stufenlose Leistungsbereiche unterstützen
Tests / test (push) Successful in 49s
2026-09-27 08:46:17 +00:00
dh 72d96aeb81 feat: Einspeisebegrenzung in Demo simulieren
Tests / test (push) Successful in 46s
2026-09-26 10:09:10 +00:00
dh dcf51bd8be feat: anlagenweite Einspeisebegrenzung und Netzfahrplan
Tests / test (push) Successful in 50s
2026-09-26 09:09:08 +00:00
dh efa8fe698c feat: Prognosetelemetrie aus Demoanlage senden
Tests / test (push) Successful in 48s
2026-09-23 06:40:17 +00:00
dh f250dea59a feat: Demoanlage an Prognosetopologie anbinden
Tests / test (push) Successful in 49s
2026-09-23 05:46:07 +00:00
dh 1e6481330a feat: Anlagentopologie mit Prognoseportal synchronisieren
Tests / test (push) Successful in 45s
2026-09-23 04:36:59 +00:00
dh dbd728dd03 feat: Anlagentopologie im Manager abbilden 2026-09-22 17:11:12 +00:00
dh a67018e68f fix: Waermepumpe ohne Vorgabe normal betreiben
Tests / test (push) Successful in 45s
2026-09-22 15:13:04 +00:00
dh 0f1e0c9fbb Installations-ID bei Manager-Updates erhalten
Tests / test (push) Successful in 45s
2026-09-22 15:06:31 +00:00
dh 693beb2263 docs: Projektstatus und Einrichtungsentscheidung aktualisieren
Tests / test (push) Successful in 49s
2026-09-22 14:43:36 +00:00
dh b1cb289922 docs: Ladestation Stand-Alone vollständig dokumentieren 2026-09-22 14:43:36 +00:00
dh 98ee569178 feat: Demoanlage um Batterie und Tagesprofile erweitern 2026-09-22 14:43:36 +00:00
dh 2392a9c209 feat: Lizenzkontingente im Manager durchsetzen 2026-09-22 14:43:36 +00:00
dh 7845c66fd8 feat: Wärmepumpenmodul integrieren
Tests / test (push) Successful in 49s
2026-09-22 14:34:23 +00:00
dh d7308663da feat: Batteriemodul ereignisbasiert integrieren
Tests / test (push) Successful in 45s
2026-09-22 09:27:49 +00:00
dh d03a7fd8fb Easee-Gateway und Ladestationsmodul implementieren
Tests / test (push) Successful in 44s
2026-09-22 07:09:59 +00:00
dh d804ba4bc1 Manager-Lizenzierung dokumentieren
Tests / test (push) Successful in 45s
2026-09-22 07:07:29 +00:00
dh e7c546ff2d docs: README fuer Verbraucher 1-Stufig ergaenzen 2026-09-22 07:04:41 +00:00
358 changed files with 55823 additions and 632 deletions
+6
View File
@@ -21,6 +21,12 @@ jobs:
- name: Abhaengigkeiten installieren - name: Abhaengigkeiten installieren
run: composer install --no-interaction --no-progress --prefer-dist run: composer install --no-interaction --no-progress --prefer-dist
- name: Composer-Metadaten pruefen
run: composer validate --strict
- name: Abhaengigkeiten auf Sicherheitsmeldungen pruefen
run: composer audit --no-interaction
- name: Unit-Tests und Syntaxpruefung ausfuehren - name: Unit-Tests und Syntaxpruefung ausfuehren
run: composer check run: composer check
+61
View File
@@ -0,0 +1,61 @@
# Agenten-Anweisungen für Enelix-EMS
## Aktueller V4-Fortsetzungseinstieg: 06.10.2026
Neuester Quellstand: Bedienintegration und Zusammenfuehrung sind in
docs/prognose-bedienintegration.md und im obersten Abschnitt der folgenden
Uebergabe beschrieben. Daniel hat Commit/Push auf develop und beta ausdruecklich
freigegeben; dies ist kein Anlagenrollout und keine neue Stellfreigabe.
Die vollstaendige Uebergabe liegt auf **enelix-services / Server 89** unter
`/srv/agent/repos/Enelix-EMS/docs/NETPLAN_V4_AGENT_HANDOFF.md`.
Vor Fortsetzung zuerst den neuesten datierten Abschnitt lesen. Die Datei ist
im integrierten Repository versioniert; alte Serverkopien koennen abweichen.
Letzter hier belegter Anlagenzustand: **06.10.2026, 12:01:11 UTC /
14:01:11 Europe/Zurich**, aus `/srv/agent/v4-recovery-20261006/finished.json`
auf **iot-symcon01**. Dies ist keine neue Live-Abnahme:
V4 und alter Netzfahrplan AUS, Batterie 44234 gestoppt, EV-Auftrag 19651 = 0 W.
Der unabhaengige SDL-Auftrag blieb unangetastet.
Rueckmelde-/Cache-/Empfangskorrekturen sind installiert; **offen ist die
instanzbezogene Batterietimerblockade (LastRun=0)**. Kein nachgewiesenes
physisches Tracking, kein >90-s-Dauerbetrieb und kein automatischer
Batterie-Watchdog-Lauf. Alte Installer nicht erneut ausfuehren und keine
externen Tick-Hooks als Umgehung einsetzen. Zuerst Timerursache beheben und
automatische Ausfuehrung belegen, danach nur innerhalb gueltiger Freigabe testen.
EV bleibt **161.44 kWh / 39 kW**. Die bestehende befristete Testfreigabe endet
**07.10.2026, 17:38:07 Europe/Zurich**; diese Notiz erneuert oder verlaengert
sie nicht. Wegfall des zusaetzlichen +/-5-kW-Limits hebt Geraete-, SOC-,
Netzgrenzen und die dokumentierten Grenzen der Testanlagen-Ausnahme nicht auf.
Kein Aktivstart oder Dienstneustart aus der Bitte um Kontext-Sicherung ableiten.
Aeltere V4-Statusangaben und damalige "naechste Schritte" weiter unten sind
historisch, soweit die neuere Uebergabe sie ersetzt. Sonstige Projekt- und
Sicherheitsregeln sowie separate Manager-Leistungsverteilungsnotizen bleiben
unveraendert. Aktuellen Zustand und Freigaben vor operativen Arbeiten erneut
pruefen. Ein neuer Agent muss diese Quellen lesen; kein automatischer Chattransfer.
## Netzfahrplan V4: zuerst den Gesprächskontext übernehmen
Bei Aufgaben zu Netzfahrplan, Prognosen, V4, Batterieoptimierung oder Lihrenmoos **vor Änderungen** die vollständige Übergabe lesen:
**[`docs/NETPLAN_V4_AGENT_HANDOFF.md`](docs/NETPLAN_V4_AGENT_HANDOFF.md)**
Sie enthält Daniels Anforderungen und Korrekturen, installierte versus nur entwickelte Komponenten, Quellen-/Instanzzuordnung, bekannte behobene Fehler, Grenzen der Testfreigabe und den nächsten konkreten Arbeitsschritt. Die Runtime-Berichte stammen zuletzt vom 03.10.2026; Zeitpunkt und aktuellen Zustand prüfen. Alte Entwicklungsdokumente können noch „nicht installiert“ sagen, obwohl ein jüngerer Installationsbericht den Abschluss belegt.
Kurzstand bei Erstellung dieser Anweisung: Unified RC1 und korrigierter Rückmelde-/begrenzter Testpfad installiert; Versandproblem behoben. Nächste offene Umsetzung sind bestätigte Geräte-Lesezeitpunkte statt pauschaler Verwendung von `VariableUpdated` (`feedback_source_skew`). EV **161.44 kWh / 39 kW**, nicht die zurückgenommenen 160/30. Dauerproduktion ist noch nicht fertig; keine automatische Stellfreigabe.
## Arbeits- und Git-Regeln
- Vor Arbeit Host-`/srv/agent/AGENT_CONTEXT.md`, Branch und Arbeitsverzeichnis prüfen. Servercheckout ist nicht gleich laufendes Symcon-Modulverzeichnis.
- Neue Entwicklung auf **`develop`**, keine Feature-Branches. Nur eigene/zugehörige Änderungen committen. `develop` darf im vereinbarten Rahmen gepusht werden; `beta` nur gemäss ausdrücklicher Freigabe und nach Tests, `main` nach ausdrücklichem Auftrag/Feldabnahme. Keine Schutzregeln oder fehlende Authentifizierung umgehen.
- Daniel möchte zusammenhängende produktionsgeeignete Umsetzung, keine weitere Schleife aus Diagnosekategorien, identischen Rückfragen und veralteten Installern. Vorhandene Quellen und Werkzeuge benutzen, keine bekannten Anlagenangaben erneut verlangen.
- Code-, Host-/Container-, Kernel- und Feldtests sowie Installation, Veröffentlichung und Stellfreigabe getrennt belegen. „Optimal“/„ACK“/grüne Tests sind keine gemessene Einsparung und keine Produktionsfreigabe.
- Keine Stellbefehle, Testsitzungen, Freigaben oder Neustarts aus einer reinen Kontext-/Statusanfrage ableiten. Aktive Hardware nur innerhalb des ausdrücklich freigegebenen Umfangs behandeln. Messgrenzen/Watchdog-Nachweise nicht erfinden oder per Label umgehen.
- Keine Credentials lesen/ausgeben/einchecken. Root/Docker-Rechte nicht ausweiten. Bestehende GUI-/Fremdänderungen erhalten. Vor Runtime-Änderungen passende Sicherung, exakte Quell-/Abhängigkeitsprüfung und Rücksetzweg.
- PHP-IPS-Skripte gehören in den Symcon-Kernel; isolierte Tests mit simulierten IPS-Aufrufen niemals dort ausführen. `MC_ReloadModule`/`IPS_ApplyChanges` können Initialisierungsnebenwirkungen haben.
- Alte Outbox-/Historienwerte nicht löschen, Kennungen nicht im Original umschreiben, Cursor nur nach passender Serverbestätigung weiterführen. Keine künstlich frischen Messzeitstempel.
- Übergabe nach tatsächlichem Fortschritt aktualisieren: Datum, Commit, was wirklich installiert/getestet wurde und nächster konkreter Schritt. Keine Hintergrundarbeit behaupten.
Diese Datei ergänzt bestehende höherrangige Projekt-/Hostregeln. Sie ist keine neue Betriebsfreigabe und kann keinen anderen Agenten ohne Lesen der Dateien automatisch mit dem gesamten Chat synchronisieren.
+181
View File
@@ -0,0 +1,181 @@
# 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. NachladenMitMaximalleistung verwendet standardmaessig die
dynamische maximale Ladeleistung. Wird der Schalter deaktiviert, begrenzt
MaximaleNachladeleistung das schutzbedingte Nachladen in W. Der wirksame Wert
wird nie hoeher als die dynamische Ladegrenze der Batterie.
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 wirksame Nachladeleistung 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; die positive Grenze ist dabei
hoechstens die konfigurierte Nachladeleistung.
- Oberhalb der Reserve ohne aktive Hysterese bleibt der normale Ladebereich
unveraendert und wird nicht durch die Nachladegrenze reduziert.
## 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, Hysterese und Nachladeleistung 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)
+243
View File
@@ -0,0 +1,243 @@
{
"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": "CheckBox",
"name": "NachladenMitMaximalleistung",
"caption": "Beim Nachladen maximale Ladeleistung verwenden",
"onChange": "IPS_RequestAction($id, 'FormNachladenMitMaximalleistung', $NachladenMitMaximalleistung);"
},
{
"type": "NumberSpinner",
"name": "MaximaleNachladeleistung",
"caption": "Maximale Nachladeleistung",
"suffix": " W",
"minimum": 1,
"visible": false
}
]
},
{
"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"
}
+1282
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
# Changelog
Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
## Unreleased
- Manager: V4-Planempfang und Prognosebedienung im vorhandenen Bereich
Prognose / Forecast zusammengefasst. Explizite Start-/Stoppaktionen pruefen
Lizenz und bestehende Testfreigaben. Kein automatischer Start, keine neue
Dauerbetriebsfreigabe und keine Loeschung bestehender Laufzeitobjekte.
- Integration des bisherigen V4-Entwicklungsstands mit den aktuellen Manager-,
SDL- und Sollwertkorrekturen; V4-Stopp und Sollwertverwerfen bleiben erhalten.
- Optionaler nativer V4-Betriebsdatensender fuer den Schattenversuch (standardmaessig aus), separate Diagnose und sichere Batterie-/Messquellenzuordnung. Kein neuer Fahrplan wird lokal ausgefuehrt; vollstaendige Monatspeak-/Viertelstunden-Messnachweise und deren Historienadapter bleiben Voraussetzung.
- Verbraucher: Beim Verwerfen einer Manager-Vorgabe wird auch die gespeicherte
und sichtbare Sollleistung auf 0 W gesetzt. Gilt fuer Batterie, beide
Ladestationen, Pufferspeicher, Verbraucher 1-Stufig, Waermepumpe und
Warmwassererwaermer. Bereits ungueltige Altwerte werden beim Anwenden der
Instanzkonfiguration bereinigt. Gueltige Vorgaben, lokale Schutzprogramme,
Mindestlaufzeiten und der Nachrichtenvertrag bleiben erhalten.
- Manager: SDL-/Regelenergie als separate Istleistungsquelle mit optionalem SOC,
Leistungsfaktor und getrennten Anzeigeoptionen fuer Energiefluss und Diagramme.
SDL wird unabhaengig von der Anzeige aus Hauslast und neuer Hausenergie
herausgerechnet; die Prognosetelemetrie verwendet die korrigierte Hauslast.
Bestehende Visualisierungen werden ausschliesslich um eigene SDL-Eintraege
ergaenzt. Standardmaessig deaktiviert, keine automatische Quellenmigration,
keine rueckwirkende Datenkorrektur und keine Aenderung externer SDL-Steuerungen.
- Manager: Positives Budget wird bei gleicher Prioritaet nach der kleinsten
naechsten erreichbaren Sollleistung verteilt. Nur bei gleicher Leistungsstufe
entscheiden exakter Energiebezug und anschliessend die Instanz-ID. Einzelstufen
und ganzzahlige Bereiche teilen sich das Budget; reservierte Lasten und die
negative Defizitregelung bleiben unveraendert. Keine Konfigurationsmigration.
## 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
- Ladestationen halten beim Aktivieren und Umschalten von Solarladen den bisherigen Strom bis zur neuen Manager-Vorgabe; die Stand-alone-Phasenprobe verwendet nur noch 6 A.
- Fahrzeugstatus ist bei beiden Ladestationsvarianten eine Diagnosevariable, Ist- und Sollleistung gehoeren zu den optionalen Visualisierungseinstellungen.
- Stufenlose Leistungsbereiche fuer Batteriespeicher.
- Schutzbedingtes Nachladen der Batterie kann wahlweise auf die dynamische Maximalleistung oder eine benutzerdefinierte Watt-Grenze begrenzt werden.
- Ladefreigabe neuer Ladestationsinstanzen standardmaessig aktiviert.
- Stand-alone-Ladestationen erkennen nach einer einstellbaren Beobachtungszeit den niedrigeren Fahrzeug-Maximalstrom und begrenzen ihr Leistungsangebot ohne Ladeunterbruch.
- Easee-Ladestationen laden einen fehlenden Initialstatus ueber die Observations-API nach und starten bei noch unbekannter Ausgangsphase konservativ einphasig.
- Wartende Easee-Ladestationen erhalten bei positiver Stromvorgabe einmal pro Fahrzeugverbindung einen Startbefehl; Freigabe- und Blockiergruende werden diagnostiziert.
- Fehlende oder veraltete Verbraucher blockieren die Verteilung an aktuelle Verbraucher nicht mehr.
- Verbraucher gleicher Prioritaet werden wie in Enelix 1 anhand ihrer bezogenen Energie in 2-kWh-Gruppen ausgeglichen.
- Die Stoerungsueberwachung fordert einen fehlenden Geraetezugang innerhalb ihres Retry-Zyklus automatisch neu an.
- Der Warmwassererwaermer verwendet konfigurierbare Temperatur-Stoergrenzen von standardmaessig 0 bis 100 Grad C.
- 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"
}
+964
View File
@@ -0,0 +1,964 @@
<?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";
private const CACHE_MAXIMALALTER_SEKUNDEN = 60;
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->RegisterTimer(
'ReconnectTimer',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'ReconnectAfterLoss', false);"
);
$this->RequireParent(self::WEBSOCKET_MODULE_ID);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->SetTimerInterval('MaintainConnectionTimer', 0);
$this->SetTimerInterval('TokenRefreshTimer', 0);
$this->SetTimerInterval('ReconnectTimer', 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 'ReconnectAfterLoss':
$this->SetTimerInterval('ReconnectTimer', 0);
if (
$this->ReadPropertyBoolean('Active')
&& !$this->ReadPropertyBoolean('Testmodus')
&& $this->GetBuffer('SignalRReady') !== '1'
) {
$this->verbindeSignalR();
}
return;
case 'RefreshToken':
if (
$this->erneuereZugangsdaten(false)
&& $this->GetBuffer('SignalRReady') !== '1'
) {
$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;
case 'TestSignalRFrame':
if (!$this->ReadPropertyBoolean('Testmodus') || !is_string($wert)) {
throw new InvalidArgumentException(
'TestSignalRFrame ist nur im Testmodus zulaessig.'
);
}
$this->verarbeiteSignalRRahmen($wert);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
public function GetConfigurationForParent(): string
{
return json_encode([
'Active' => $this->ReadPropertyBoolean('Active')
&& !$this->ReadPropertyBoolean('Testmodus'),
'URL' => $this->GetBuffer('WebSocketURL'),
'VerifyCertificate' => $this->ReadPropertyBoolean('VerifyCertificate'),
'Headers' => '[]',
], JSON_THROW_ON_ERROR);
}
public function ReceiveData($jsonString): void
{
$paket = json_decode((string) $jsonString, true);
if (!is_array($paket) || !isset($paket['Buffer'])) {
return;
}
$this->SetBuffer('LastReceive', (string) time());
$puffer = $this->GetBuffer('ReceiveBuffer') . (string) $paket['Buffer'];
$rahmen = explode(self::RECORD_SEPARATOR, $puffer);
$this->SetBuffer('ReceiveBuffer', (string) array_pop($rahmen));
foreach ($rahmen as $eintrag) {
if ($eintrag !== '') {
$this->verarbeiteSignalRRahmen($eintrag);
}
}
}
public function ForwardData($jsonString): string
{
$paket = json_decode((string) $jsonString, true);
if (!is_array($paket) || !isset($paket['Buffer'])) {
return json_encode([
'success' => false,
'error' => 'Ungueltiges Datenpaket.',
], JSON_THROW_ON_ERROR);
}
return $this->ProcessStationRequest((string) $paket['Buffer']);
}
public function ProcessStationRequest($jsonString): string
{
$anfrage = json_decode((string) $jsonString, true);
if (!is_array($anfrage) || !isset($anfrage['action'])) {
return json_encode([
'success' => false,
'error' => 'Ungueltige Gateway-Anfrage.',
], JSON_THROW_ON_ERROR);
}
$seriennummer = EaseeGatewayProtokoll::seriennummer(
(string) ($anfrage['serialNumber'] ?? '')
);
switch ($anfrage['action']) {
case 'Subscribe':
case 'GetState':
if ($seriennummer === '') {
return json_encode([
'success' => false,
'error' => 'Seriennummer fehlt.',
], JSON_THROW_ON_ERROR);
}
$this->registriereAbonnement($seriennummer);
return $this->erstelleStatusantwort($seriennummer);
case 'SetDynamicChargerCurrent':
return json_encode($this->setzeDynamischenLadestrom(
$seriennummer,
(float) ($anfrage['amps'] ?? -1)
), JSON_THROW_ON_ERROR);
case 'StartCharging':
return json_encode(
$this->sendeLadestart($seriennummer),
JSON_THROW_ON_ERROR
);
}
return json_encode([
'success' => false,
'error' => 'Unbekannte Gateway-Aktion.',
], JSON_THROW_ON_ERROR);
}
private function stelleWebSocketParentSicher(): bool
{
$instanz = IPS_GetInstance($this->InstanceID);
$parentID = (int) $instanz['ConnectionID'];
if ($parentID <= 0 && !$this->RequireParent(self::WEBSOCKET_MODULE_ID)) {
$this->setzeLetztenFehler('WebSocket-Client konnte nicht erstellt werden.');
return false;
}
$instanz = IPS_GetInstance($this->InstanceID);
$parentID = (int) $instanz['ConnectionID'];
if ($parentID <= 0 || !IPS_InstanceExists($parentID)) {
$this->setzeLetztenFehler('WebSocket-Client ist nicht verbunden.');
return false;
}
$parent = IPS_GetInstance($parentID);
if ($parent['ModuleInfo']['ModuleID'] !== self::WEBSOCKET_MODULE_ID) {
$this->setzeLetztenFehler('Ungueltige WebSocket-Schnittstelle.');
return false;
}
return true;
}
private function pflegeVerbindung(): void
{
if (!$this->ReadPropertyBoolean('Active') || $this->ReadPropertyBoolean('Testmodus')) {
return;
}
$jetzt = time();
$letzteAushandlung = (int) $this->GetBuffer('LastNegotiation');
$letzterEmpfang = (int) $this->GetBuffer('LastReceive');
$bereit = $this->GetBuffer('SignalRReady') === '1';
if ($bereit) {
$this->sendeSignalRRahmen(['type' => 6]);
if ($letzterEmpfang > 0 && ($jetzt - $letzterEmpfang) <= 90) {
return;
}
$this->planeWiederverbindung(
'Seit mehr als 90 Sekunden keine Easee-Ereignisdaten empfangen.'
);
return;
}
if (($jetzt - $letzteAushandlung) >= 45) {
$this->verbindeSignalR();
return;
}
$this->sendeHandshake();
}
private function verbindeSignalR(): bool
{
$this->SetTimerInterval('ReconnectTimer', 0);
if ($this->ReadPropertyBoolean('Testmodus')) {
$this->setzeEreignisverbindung(true);
return true;
}
if (!$this->stelleAccessTokenSicher()) {
$this->SetBuffer('SignalRReady', '0');
$this->setzeVerbunden(false);
$this->SetStatus(202);
return false;
}
$this->setzeEreignisverbindung(
false,
'Easee-Ereignisverbindung wird aufgebaut.'
);
$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->setzeEreignisverbindung(
false,
'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->setzeEreignisverbindung(
false,
'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->markiereBeobachtungscacheVeraltet();
$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->setzeEreignisverbindung(true);
$this->sendeAlleAbonnements();
return;
}
$nachricht = json_decode($rahmen, true);
if (!is_array($nachricht)) {
$this->setzeLetztenFehler('Ungueltige Easee-Ereignisnachricht.');
return;
}
if (isset($nachricht['error'])) {
$this->planeWiederverbindung(
'Easee-Ereignisfehler: ' . $nachricht['error']
);
return;
}
$typ = (int) ($nachricht['type'] ?? 0);
if ($typ === 6) {
return;
}
if ($typ === 7) {
$this->planeWiederverbindung(
'Easee-Ereignisverbindung wurde beendet.'
);
return;
}
$seriennummer = '';
if ($typ === 3 && isset($nachricht['invocationId'])) {
$offen = $this->lesePufferArray('PendingInvocations');
$aufrufID = (string) $nachricht['invocationId'];
$seriennummer = (string) ($offen[$aufrufID] ?? '');
unset($offen[$aufrufID]);
$this->SetBuffer(
'PendingInvocations',
json_encode($offen, JSON_THROW_ON_ERROR)
);
}
foreach (['arguments', 'result'] as $feld) {
if (!isset($nachricht[$feld])) {
continue;
}
$abonnements = array_keys($this->lesePufferArray('Subscriptions'));
foreach (EaseeGatewayProtokoll::extrahiereBeobachtungen(
$nachricht[$feld],
$abonnements,
$seriennummer
) as $beobachtung) {
$this->veroeffentlicheBeobachtung(
$beobachtung['Seriennummer'],
$beobachtung['ID'],
$beobachtung['Wert']
);
}
}
}
private function registriereAbonnement(string $seriennummer): void
{
$abonnements = $this->lesePufferArray('Subscriptions');
if (!isset($abonnements[$seriennummer])) {
$abonnements[$seriennummer] = true;
$this->SetBuffer(
'Subscriptions',
json_encode($abonnements, JSON_THROW_ON_ERROR)
);
$this->SetValue('SubscriptionCount', count($abonnements));
}
if ($this->GetBuffer('SignalRReady') === '1') {
$this->sendeAbonnement($seriennummer);
}
}
private function sendeAlleAbonnements(): void
{
foreach (array_keys($this->lesePufferArray('Subscriptions')) as $seriennummer) {
$this->sendeAbonnement((string) $seriennummer);
}
}
private function sendeAbonnement(string $seriennummer): void
{
$gesendet = $this->lesePufferArray('SubscribedThisConnection');
if (isset($gesendet[$seriennummer])) {
return;
}
$aufrufID = (string) (((int) $this->GetBuffer('InvocationID')) + 1);
$this->SetBuffer('InvocationID', $aufrufID);
$offen = $this->lesePufferArray('PendingInvocations');
$offen[$aufrufID] = $seriennummer;
$this->SetBuffer(
'PendingInvocations',
json_encode($offen, JSON_THROW_ON_ERROR)
);
$this->sendeSignalRRahmen([
'type' => 1,
'invocationId' => $aufrufID,
'target' => 'SubscribeWithCurrentState',
'arguments' => [$seriennummer, true],
]);
$gesendet[$seriennummer] = true;
$this->SetBuffer(
'SubscribedThisConnection',
json_encode($gesendet, JSON_THROW_ON_ERROR)
);
}
private function veroeffentlicheBeobachtung(
string $seriennummer,
int $id,
$wert
): void {
if (
$seriennummer === ''
|| !in_array($id, EaseeGatewayProtokoll::BEOBACHTUNGEN, true)
) {
return;
}
$this->speichereBeobachtungImCache($seriennummer, $id, $wert);
$this->SendDataToChildren(json_encode([
'DataID' => self::CHILD_EVENT_DATA_ID,
'Buffer' => json_encode([
'type' => 'Observation',
'serialNumber' => $seriennummer,
'id' => $id,
'value' => $wert,
'timestamp' => time(),
], JSON_THROW_ON_ERROR),
], JSON_THROW_ON_ERROR));
}
private function speichereBeobachtungImCache(
string $seriennummer,
int $id,
$wert
): void {
$cache = $this->leseAttributArray('ObservationCache');
if (!isset($cache[$seriennummer]) || !is_array($cache[$seriennummer])) {
$cache[$seriennummer] = [];
}
if ($id === 109 && (int) $wert === 1) {
foreach ([110, 120, 182, 183, 184, 185] as $sessionID) {
$cache[$seriennummer][(string) $sessionID] = 0;
}
}
$cache[$seriennummer][(string) $id] = $wert;
$cache[$seriennummer]['updated'] = time();
$this->WriteAttributeString(
'ObservationCache',
json_encode($cache, JSON_THROW_ON_ERROR)
);
}
private function markiereBeobachtungscacheVeraltet(): void
{
$cache = $this->leseAttributArray('ObservationCache');
foreach ($cache as &$zustand) {
if (is_array($zustand)) {
$zustand['updated'] = 0;
}
}
unset($zustand);
$this->WriteAttributeString(
'ObservationCache',
json_encode($cache, JSON_THROW_ON_ERROR)
);
}
private function stelleAktuellenStatusSicher(string $seriennummer): void
{
$cache = $this->leseAttributArray('ObservationCache');
$zustand = $cache[$seriennummer] ?? [];
$cacheIstAktuell = is_array($zustand)
&& array_key_exists('109', $zustand)
&& (int) ($zustand['updated'] ?? 0) >= time() - self::CACHE_MAXIMALALTER_SEKUNDEN;
if (
$cacheIstAktuell
|| $this->ReadPropertyBoolean('Testmodus')
|| $this->GetBuffer('SignalRReady') !== '1'
) {
return;
}
$antwort = $this->autorisierteApiAnfrage(
'GET',
'/state/' . rawurlencode($seriennummer) . '/observations?ids='
. rawurlencode(implode(',', EaseeGatewayProtokoll::BEOBACHTUNGEN)),
''
);
if (!($antwort['success'] ?? false)) {
$this->setzeLetztenFehler(
'Easee-Status konnte nicht geladen werden: '
. (string) ($antwort['error'] ?? 'Unbekannter API-Fehler.')
);
return;
}
$daten = json_decode((string) ($antwort['body'] ?? ''), true);
if (!is_array($daten)) {
$this->setzeLetztenFehler('Easee-Statusantwort ist ungueltig.');
return;
}
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen(
$daten,
[$seriennummer],
$seriennummer
);
$betriebsstatusGeladen = false;
foreach ($beobachtungen as $beobachtung) {
$this->speichereBeobachtungImCache(
$beobachtung['Seriennummer'],
$beobachtung['ID'],
$beobachtung['Wert']
);
$betriebsstatusGeladen = $betriebsstatusGeladen
|| $beobachtung['ID'] === 109;
}
if (!$betriebsstatusGeladen) {
$this->setzeLetztenFehler(
'Easee-Statusantwort enthaelt keinen Betriebsstatus (109).'
);
return;
}
$this->setzeLetztenFehler('');
}
private function erstelleStatusantwort(string $seriennummer): string
{
$this->stelleAktuellenStatusSicher($seriennummer);
$cache = $this->leseAttributArray('ObservationCache');
return json_encode([
'success' => true,
'connected' => $this->GetBuffer('SignalRReady') === '1'
|| $this->ReadPropertyBoolean('Testmodus'),
'state' => $cache[$seriennummer] ?? [],
], JSON_THROW_ON_ERROR);
}
/** @return array<string, mixed> */
private function setzeDynamischenLadestrom(string $seriennummer, float $ampere): array
{
if ($seriennummer === '') {
return ['success' => false, 'error' => 'Seriennummer fehlt.'];
}
if (
!is_finite($ampere)
|| floor($ampere) !== $ampere
|| $ampere < 0
|| $ampere > 32
|| ($ampere > 0 && $ampere < 6)
) {
return [
'success' => false,
'error' => 'Strom muss 0 A oder eine ganze Zahl zwischen 6 und 32 A sein.',
];
}
if ($this->ReadPropertyBoolean('Testmodus')) {
$this->SetBuffer('LetzterTestbefehl', json_encode([
'serialNumber' => $seriennummer,
'amps' => $ampere,
], JSON_THROW_ON_ERROR));
return ['success' => true, 'httpCode' => 200, 'body' => '{}'];
}
return $this->autorisierteApiAnfrage(
'POST',
'/api/chargers/' . rawurlencode($seriennummer)
. '/commands/set_dynamic_charger_current',
json_encode([
'amps' => (int) round($ampere),
'minutes' => 0,
], JSON_THROW_ON_ERROR)
);
}
/** @return array<string, mixed> */
private function sendeLadestart(string $seriennummer): array
{
if ($seriennummer === '') {
return ['success' => false, 'error' => 'Seriennummer fehlt.'];
}
if ($this->ReadPropertyBoolean('Testmodus')) {
return ['success' => true, 'httpCode' => 200, 'body' => '{}'];
}
return $this->autorisierteApiAnfrage(
'POST',
'/api/chargers/' . rawurlencode($seriennummer)
. '/commands/start_charging',
''
);
}
/** @return array<string, mixed> */
private function autorisierteApiAnfrage(
string $methode,
string $pfad,
string $inhalt
): array {
if (!$this->stelleAccessTokenSicher()) {
return ['success' => false, 'error' => 'Kein Easee-Access-Token.'];
}
$antwort = $this->httpAnfrage(
$methode,
self::API_BASE_URL . $pfad,
$inhalt,
$this->GetBuffer('AccessToken')
);
if ($antwort['httpCode'] === 401 && $this->erneuereZugangsdaten(false)) {
$antwort = $this->httpAnfrage(
$methode,
self::API_BASE_URL . $pfad,
$inhalt,
$this->GetBuffer('AccessToken')
);
}
if (!$antwort['success']) {
return [
'success' => false,
'error' => $antwort['error'] !== ''
? $antwort['error']
: 'Easee-HTTP-Fehler ' . $antwort['httpCode'] . '.',
'httpCode' => $antwort['httpCode'],
];
}
return [
'success' => true,
'httpCode' => $antwort['httpCode'],
'body' => $antwort['body'],
];
}
private function erneuereZugangsdaten(bool $neuVerbinden = true): bool
{
$tokenpaar = null;
$refreshToken = $this->GetBuffer('RefreshToken');
if ($refreshToken !== '') {
$tokenpaar = $this->fordereTokenpaarAn(
self::API_BASE_URL . '/api/accounts/refresh_token',
['refreshToken' => $refreshToken]
);
}
if ($tokenpaar === null) {
$tokenpaar = $this->fordereTokenpaarAn(
self::API_BASE_URL . '/api/accounts/login',
[
'userName' => $this->ReadPropertyString('Username'),
'password' => $this->ReadPropertyString('Password'),
]
);
}
if ($tokenpaar === null || !isset($tokenpaar['accessToken'])) {
$this->setzeLetztenFehler('Easee-Anmeldung oder Token-Erneuerung fehlgeschlagen.');
$this->SetStatus(202);
return false;
}
$this->SetBuffer('AccessToken', (string) $tokenpaar['accessToken']);
if (isset($tokenpaar['refreshToken'])) {
$this->SetBuffer('RefreshToken', (string) $tokenpaar['refreshToken']);
}
if ($neuVerbinden) {
return $this->verbindeSignalR();
}
return true;
}
private function stelleAccessTokenSicher(): bool
{
return $this->GetBuffer('AccessToken') !== ''
|| $this->erneuereZugangsdaten(false);
}
/** @param array<string, string> $nutzdaten
* @return array<string, mixed>|null
*/
private function fordereTokenpaarAn(string $url, array $nutzdaten): ?array
{
$antwort = $this->httpAnfrage(
'POST',
$url,
json_encode($nutzdaten, JSON_THROW_ON_ERROR)
);
if (!$antwort['success']) {
return null;
}
$daten = json_decode($antwort['body'], true);
return is_array($daten) ? $daten : null;
}
/**
* @return array{success: bool, body: string, error: string, httpCode: int}
*/
private function httpAnfrage(
string $methode,
string $url,
string $inhalt = '',
string $bearerToken = ''
): array {
$kopf = ['Accept: application/json', 'Content-Type: application/json'];
if ($bearerToken !== '') {
$kopf[] = 'Authorization: Bearer ' . $bearerToken;
}
$curl = curl_init($url);
if ($curl === false) {
return [
'success' => false,
'body' => '',
'error' => 'HTTP-Anfrage konnte nicht initialisiert werden.',
'httpCode' => 0,
];
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $methode,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 30,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HTTPHEADER => $kopf,
CURLOPT_POSTFIELDS => $inhalt,
CURLOPT_SSL_VERIFYPEER => $this->ReadPropertyBoolean('VerifyCertificate'),
CURLOPT_SSL_VERIFYHOST => $this->ReadPropertyBoolean('VerifyCertificate') ? 2 : 0,
]);
$antwort = curl_exec($curl);
$fehler = curl_error($curl);
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
return [
'success' => $antwort !== false
&& $fehler === ''
&& $httpStatus >= 200
&& $httpStatus < 300,
'body' => $antwort === false ? '' : (string) $antwort,
'error' => $fehler,
'httpCode' => $httpStatus,
];
}
private function setzeEreignisverbindung(
bool $verbunden,
string $fehler = ''
): void {
$this->SetBuffer('SignalRReady', $verbunden ? '1' : '0');
$this->SetStatus($verbunden ? 102 : 203);
$this->setzeLetztenFehler($verbunden ? '' : $fehler);
if ($verbunden) {
$this->SetTimerInterval('ReconnectTimer', 0);
}
$this->setzeVerbunden($verbunden);
}
private function planeWiederverbindung(string $fehler): void
{
$this->setzeEreignisverbindung(false, $fehler);
$this->SetBuffer('LastNegotiation', '0');
if (
$this->ReadPropertyBoolean('Active')
&& !$this->ReadPropertyBoolean('Testmodus')
) {
$this->SetTimerInterval('ReconnectTimer', 1000);
}
}
private function setzeVerbunden(bool $verbunden): void
{
$warVerbunden = (bool) $this->GetValue('Connected');
$this->SetValue('Connected', $verbunden);
if ($warVerbunden === $verbunden) {
return;
}
$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"
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -37,6 +37,41 @@
"caption": "Geraetestatus abfragen", "caption": "Geraetestatus abfragen",
"suffix": " s", "suffix": " s",
"minimum": 1 "minimum": 1
},
{
"type": "NumberSpinner",
"name": "Phasenerkennungszeit",
"caption": "Phasen erkennen nach",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "FahrzeugstromErkennungszeit",
"caption": "Fahrzeug-Maximalstrom erkennen nach",
"suffix": " s",
"minimum": 30
},
{
"type": "NumberSpinner",
"name": "ZeitZwischenZustandswechseln",
"caption": "Zeit zwischen Leistungsaenderungen",
"suffix": " min",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Mindesteinschaltdauer",
"caption": "Mindesteinschaltdauer",
"suffix": " min",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Mindestausschaltdauer",
"caption": "Mindestausschaltdauer",
"suffix": " min",
"minimum": 0
} }
] ]
}, },
+413 -47
View File
@@ -23,17 +23,19 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
private const STATUS_AKTIV = 102; private const STATUS_AKTIV = 102;
private const STATUS_KONFIGURATION_UNGUELTIG = 201; private const STATUS_KONFIGURATION_UNGUELTIG = 201;
private const STATUS_KOMMUNIKATIONSFEHLER = 202; private const STATUS_KOMMUNIKATIONSFEHLER = 202;
private const REGELUEBERGANG_TIMEOUT_MS = 2000;
private const PHASENERKENNUNGSSTROM = 6;
/** @var list<string> */ /** @var list<string> */
private const DIAGNOSE_VARIABLEN = [ private const DIAGNOSE_VARIABLEN = [
'Istleistung',
'Leistungsquelle', 'Leistungsquelle',
'Sollleistung',
'SollwertGueltig', 'SollwertGueltig',
'Verfuegbar', 'Verfuegbar',
'AenderungMoeglich', 'AenderungMoeglich',
'Stoerung', 'Stoerung',
'Stoertext', 'Stoertext',
'Fahrzeugstatus',
'FahrzeugMaximalstrom',
'LetzterGeraetebefehl', 'LetzterGeraetebefehl',
'LeistungsangebotDiagnose', 'LeistungsangebotDiagnose',
]; ];
@@ -50,8 +52,13 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->RegisterPropertyString('Benutzername', ''); $this->RegisterPropertyString('Benutzername', '');
$this->RegisterPropertyString('Passwort', ''); $this->RegisterPropertyString('Passwort', '');
$this->RegisterPropertyInteger('MaximalerLadestrom', 16); $this->RegisterPropertyInteger('MaximalerLadestrom', 16);
$this->RegisterPropertyInteger('Phasenerkennungszeit', 60);
$this->RegisterPropertyInteger('FahrzeugstromErkennungszeit', 90);
$this->RegisterPropertyInteger('ZeitZwischenZustandswechseln', 1);
$this->RegisterPropertyInteger('Mindesteinschaltdauer', 0);
$this->RegisterPropertyInteger('Mindestausschaltdauer', 0);
$this->RegisterPropertyInteger('Abfrageintervall', 5); $this->RegisterPropertyInteger('Abfrageintervall', 5);
$this->RegisterPropertyBoolean('Ladefreigabe', false); $this->RegisterPropertyBoolean('Ladefreigabe', true);
$this->RegisterPropertyBoolean('Solarladen', true); $this->RegisterPropertyBoolean('Solarladen', true);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
@@ -61,7 +68,6 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->RegisterVariableBoolean('FahrzeugVerbunden', 'Fahrzeug verbunden', '~Switch', 100); $this->RegisterVariableBoolean('FahrzeugVerbunden', 'Fahrzeug verbunden', '~Switch', 100);
$this->RegisterVariableBoolean('FahrzeugGeladen', 'Fahrzeug geladen', '~Switch', 110); $this->RegisterVariableBoolean('FahrzeugGeladen', 'Fahrzeug geladen', '~Switch', 110);
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 120);
$this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 130); $this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 130);
$this->RegisterVariableInteger('Phasenzahl', 'Phasenzahl', '', 140); $this->RegisterVariableInteger('Phasenzahl', 'Phasenzahl', '', 140);
@@ -87,6 +93,18 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->RegisterAttributeInteger('LetzteManagerID', 0); $this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0); $this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzterGesetzterStrom', -1); $this->RegisterAttributeInteger('LetzterGesetzterStrom', -1);
$this->RegisterAttributeInteger('LetzteGesetzteLeistung', 0);
$this->RegisterAttributeInteger('LeistungswechselGesperrtBis', 0);
$this->RegisterAttributeInteger('AusschaltenGesperrtBis', 0);
$this->RegisterAttributeInteger('EinschaltenGesperrtBis', 0);
$this->RegisterAttributeInteger('PhasenerkennungSeit', 0);
$this->RegisterAttributeInteger('PhasenerkennungVersion', 0);
$this->RegisterAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
$this->RegisterAttributeInteger('GeraeteMaximalstrom', 32);
$this->RegisterAttributeInteger('ErkannterMaximalstrom', 32);
$this->RegisterAttributeInteger('FahrzeugstromKandidat', 0);
$this->RegisterAttributeInteger('FahrzeugstromKandidatSeit', 0);
$this->RegisterAttributeBoolean('RegeluebergangAktiv', false);
$this->RegisterTimer( $this->RegisterTimer(
'Statuszyklus', 'Statuszyklus',
@@ -108,6 +126,11 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
0, 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', false);" "IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', false);"
); );
$this->RegisterTimer(
'RegeluebergangTimeout',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'RegeluebergangTimeout', false);"
);
} }
public function ApplyChanges(): void public function ApplyChanges(): void
@@ -116,6 +139,30 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->initialisiereLokaleEinstellungen(); $this->initialisiereLokaleEinstellungen();
$this->aktualisiereVariablen(); $this->aktualisiereVariablen();
$this->bereinigeUngueltigenSollwert();
// Ein persistiertes Ladeende nach Neueinrichtung oder Modulupdate einmal neu pruefen.
$this->setzeZustand('FahrzeugGeladen', false);
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
$this->WriteAttributeInteger('LetzteGesetzteLeistung', 0);
$this->WriteAttributeInteger('LeistungswechselGesperrtBis', 0);
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
if ($this->ReadAttributeInteger('PhasenerkennungVersion') < 1) {
$this->WriteAttributeInteger(
'ZustandPhasenzahl',
LadestationRegler::PHASEN_UNBEKANNT
);
$this->WriteAttributeInteger('PhasenerkennungVersion', 1);
}
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
$this->WriteAttributeInteger(
'ErkannterMaximalstrom',
$this->ReadPropertyInteger('MaximalerLadestrom')
);
$this->setzeFahrzeugstromKandidatZurueck();
$this->beendeRegeluebergang();
try { try {
$this->pruefeKonfiguration(); $this->pruefeKonfiguration();
@@ -138,18 +185,37 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
{ {
switch ($ident) { switch ($ident) {
case 'Aktiv': case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert); $aktiv = (bool) $wert;
if (!(bool) $wert) { $this->SetValue('Aktiv', $aktiv);
$this->setzeZustand('SollwertGueltig', false); if ($aktiv) {
$this->starteRegeluebergang();
} else {
$this->beendeRegeluebergang();
$this->verwerfeSollwert();
} }
$this->aktualisiere(true); $this->aktualisiere(true);
return; return;
case 'Ladefreigabe': case 'Ladefreigabe':
$ladefreigabe = (bool) $wert;
$this->WriteAttributeBoolean('ZustandLadefreigabe', $ladefreigabe);
$this->setzeSichtbareVariable('Ladefreigabe', $ladefreigabe);
if ($ladefreigabe) {
$this->starteRegeluebergang();
} else {
$this->beendeRegeluebergang();
$this->verwerfeSollwert();
}
$this->aktualisiere(true);
return;
case 'Solarladen': case 'Solarladen':
$this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert); $solarladen = (bool) $wert;
$this->setzeSichtbareVariable($ident, (bool) $wert); if ($solarladen !== $this->ReadAttributeBoolean('ZustandSolarladen')) {
$this->setzeZustand('SollwertGueltig', false); $this->starteRegeluebergang();
}
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
$this->setzeSichtbareVariable('Solarladen', $solarladen);
$this->aktualisiere(true); $this->aktualisiere(true);
return; return;
@@ -159,7 +225,12 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
case 'VorgabeTimeout': case 'VorgabeTimeout':
$this->SetTimerInterval('VorgabeTimeout', 0); $this->SetTimerInterval('VorgabeTimeout', 0);
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
$this->aktualisiere(true);
return;
case 'RegeluebergangTimeout':
$this->beendeRegeluebergang();
$this->aktualisiere(true); $this->aktualisiere(true);
return; return;
@@ -167,7 +238,6 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
if ((bool) $wert) { if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0); $this->SetTimerInterval('RueckmeldungVerzoegert', 0);
} }
$this->aktualisiere(false);
$this->sendeVerbraucherdaten(); $this->sendeVerbraucherdaten();
return; return;
@@ -200,13 +270,13 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
} }
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']); $this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->aktualisiere(false, false); $this->aktualisiere(false, false, false);
$sollleistung = $daten['Sollleistung_W']; $sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) { if ($sollleistung === null) {
if ((bool) $this->leseZustand('SollwertGueltig') if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true) && !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)
) { ) {
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
$this->aktualisiere(false, false); $this->aktualisiere(false, false);
} }
$this->SetTimerInterval('RueckmeldungVerzoegert', 100); $this->SetTimerInterval('RueckmeldungVerzoegert', 100);
@@ -218,6 +288,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
); );
} }
$this->beendeRegeluebergang();
$this->WriteAttributeInteger('LetzteManagerID', $managerID); $this->WriteAttributeInteger('LetzteManagerID', $managerID);
$this->WriteAttributeInteger('LetzteVorgabeZeit', time()); $this->WriteAttributeInteger('LetzteVorgabeZeit', time());
$this->setzeZustand('Sollleistung', $sollleistung); $this->setzeZustand('Sollleistung', $sollleistung);
@@ -227,7 +298,11 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->SetTimerInterval('RueckmeldungVerzoegert', 100); $this->SetTimerInterval('RueckmeldungVerzoegert', 100);
} }
private function aktualisiere(bool $meldungPlanen, bool $statusLesen = true): void private function aktualisiere(
bool $meldungPlanen,
bool $statusLesen = true,
bool $steuerungAusfuehren = true
): void
{ {
try { try {
if ($statusLesen) { if ($statusLesen) {
@@ -239,7 +314,12 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$verbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden'); $verbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden');
$geladen = $this->ReadAttributeBoolean('ZustandFahrzeugGeladen'); $geladen = $this->ReadAttributeBoolean('ZustandFahrzeugGeladen');
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl'); $phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom'); $maximalstrom = min(
$this->ReadPropertyInteger('MaximalerLadestrom'),
max(6, $this->ReadAttributeInteger('GeraeteMaximalstrom')),
max(6, $this->ReadAttributeInteger('ErkannterMaximalstrom'))
);
$this->setzeSichtbareVariable('FahrzeugMaximalstrom', $maximalstrom);
$solarladen = $this->ReadAttributeBoolean('ZustandSolarladen'); $solarladen = $this->ReadAttributeBoolean('ZustandSolarladen');
$peakbetrieb = $this->ReadAttributeString('Betriebsart') $peakbetrieb = $this->ReadAttributeString('Betriebsart')
=== Nachrichtenvertrag::BETRIEBSART_PEAK; === Nachrichtenvertrag::BETRIEBSART_PEAK;
@@ -263,6 +343,40 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$solarladen, $solarladen,
$peakbetrieb $peakbetrieb
); );
if ($aktiv
&& $ladefreigabe
&& $verbunden
&& !$geladen
&& !($peakbetrieb && $solarladen)
&& $phasenzahl !== LadestationRegler::PHASEN_UNBEKANNT
) {
$jetzt = time();
$letzteLeistung = $this->ReadAttributeInteger('LetzteGesetzteLeistung');
if ($jetzt < $this->ReadAttributeInteger('LeistungswechselGesperrtBis')) {
$angebot = [$letzteLeistung];
} elseif ($letzteLeistung > 0
&& $jetzt < $this->ReadAttributeInteger('AusschaltenGesperrtBis')
) {
$positiveStufen = array_values(array_filter(
LadestationRegler::leistungsangebot(
$aktiv,
$ladefreigabe,
$verbunden,
$geladen,
$phasenzahl,
$maximalstrom,
$solarladen,
false
),
static fn (int $wert): bool => $wert > 0
));
$angebot = $positiveStufen !== [] ? $positiveStufen : [$letzteLeistung];
} elseif ($letzteLeistung <= 0
&& $jetzt < $this->ReadAttributeInteger('EinschaltenGesperrtBis')
) {
$angebot = [0];
}
}
$angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR); $angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR);
$this->WriteAttributeString('Leistungsangebot', $angebotJson); $this->WriteAttributeString('Leistungsangebot', $angebotJson);
$this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson); $this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson);
@@ -270,7 +384,19 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->setzeZustand('AenderungMoeglich', $verfuegbar && count($angebot) > 1); $this->setzeZustand('AenderungMoeglich', $verfuegbar && count($angebot) > 1);
$sollleistung = $this->bestimmeWirksameSollleistung($angebot); $sollleistung = $this->bestimmeWirksameSollleistung($angebot);
$this->setzeLadestrom($sollleistung); $phasenerkennungAktiv = $aktiv
&& $ladefreigabe
&& $verbunden
&& !$geladen
&& $phasenzahl === LadestationRegler::PHASEN_UNBEKANNT;
$regeluebergangAktiv = $aktiv
&& $ladefreigabe
&& $verbunden
&& !$geladen
&& $this->ReadAttributeBoolean('RegeluebergangAktiv');
if ($steuerungAusfuehren && !$phasenerkennungAktiv && !$regeluebergangAktiv) {
$this->setzeLadestrom($sollleistung);
}
$this->setzeStoerung(''); $this->setzeStoerung('');
$this->SetStatus(self::STATUS_AKTIV); $this->SetStatus(self::STATUS_AKTIV);
} catch (Throwable $fehler) { } catch (Throwable $fehler) {
@@ -299,16 +425,134 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->ReadPropertyInteger('Geraetetyp'), $this->ReadPropertyInteger('Geraetetyp'),
$antwort $antwort
); );
if (isset($rohstatus['Sollstrom_A'])) {
$geraeteSollstrom = (int) $rohstatus['Sollstrom_A'];
if ($geraeteSollstrom === 0 || ($geraeteSollstrom >= 6 && $geraeteSollstrom <= 32)) {
$this->WriteAttributeInteger('LetzterGesetzterStrom', $geraeteSollstrom);
}
}
if (isset($rohstatus['Maximalstrom_A'])) {
$this->WriteAttributeInteger('GeraeteMaximalstrom', (int) $rohstatus['Maximalstrom_A']);
}
$warVerbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden');
$verbunden = $rohstatus['Rohstatus'] !== 1;
$bekanntePhasenzahl = $warVerbunden
? $this->ReadAttributeInteger('ZustandPhasenzahl')
: LadestationRegler::PHASEN_UNBEKANNT;
if (!$verbunden) {
$bekanntePhasenzahl = LadestationRegler::PHASEN_UNBEKANNT;
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
$this->WriteAttributeInteger('LeistungswechselGesperrtBis', 0);
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
} elseif ($bekanntePhasenzahl === LadestationRegler::PHASEN_UNBEKANNT) {
$direktErkanntePhasen = (int) ($rohstatus['Phasenzahl']
?? LadestationRegler::PHASEN_UNBEKANNT);
if (in_array(
$direktErkanntePhasen,
[LadestationRegler::PHASEN_EIN, LadestationRegler::PHASEN_DREI],
true
)) {
$bekanntePhasenzahl = $direktErkanntePhasen;
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
} elseif ($rohstatus['Istleistung_W'] > 7500.0) {
$bekanntePhasenzahl = LadestationRegler::PHASEN_DREI;
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
} elseif ((bool) $this->GetValue('Aktiv')
&& $this->ReadAttributeBoolean('ZustandLadefreigabe')
) {
$erkennungSeit = $this->ReadAttributeInteger('PhasenerkennungSeit');
if ($erkennungSeit <= 0) {
$this->sendeLadestromAnGeraet(self::PHASENERKENNUNGSSTROM);
$erkennungSeit = time();
$this->WriteAttributeInteger('PhasenerkennungSeit', $erkennungSeit);
} elseif ((time() - $erkennungSeit)
>= $this->ReadPropertyInteger('Phasenerkennungszeit')
) {
$bekanntePhasenzahl = LadestationRegler::erkennePhasenzahlAusLeistung(
(float) $rohstatus['Istleistung_W'],
$this->ReadAttributeInteger('LetzterGesetzterStrom')
);
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
}
} else {
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
}
}
$status = LadestationRegler::normalisiereStatus( $status = LadestationRegler::normalisiereStatus(
$rohstatus, $rohstatus,
$this->ReadAttributeBoolean('ZustandFahrzeugVerbunden') $warVerbunden,
$this->ReadAttributeBoolean('ZustandFahrzeugGeladen'),
$this->ReadAttributeInteger('LetzterGesetzterStrom') >= 6,
$this->ReadAttributeBoolean('LadeleistungSeitFreigabeErkannt'),
$bekanntePhasenzahl
); );
if (!$status['FahrzeugVerbunden']) {
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
} elseif ($status['Ladestrom_A'] >= 3.5) {
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', true);
}
if (!$status['FahrzeugVerbunden']
|| $status['Phasenzahl'] !== LadestationRegler::PHASEN_UNBEKANNT
) {
$this->aktualisiereFahrzeugstromgrenze($status);
}
foreach ($status as $ident => $wert) { foreach ($status as $ident => $wert) {
$this->setzeZustand($ident, $wert); $this->setzeZustand($ident, $wert);
} }
} }
/** @param array<string, int|float|bool> $status */
private function aktualisiereFahrzeugstromgrenze(array $status): void
{
$konfigurierterMaximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
if (!$status['FahrzeugVerbunden']) {
$this->WriteAttributeInteger('ErkannterMaximalstrom', $konfigurierterMaximalstrom);
$this->setzeSichtbareVariable('FahrzeugMaximalstrom', $konfigurierterMaximalstrom);
$this->setzeFahrzeugstromKandidatZurueck();
return;
}
$kandidat = LadestationRegler::ermittleFahrzeugMaximalstrom(
$this->ReadAttributeInteger('LetzterGesetzterStrom'),
(float) $status['Ladestrom_A'],
$konfigurierterMaximalstrom
);
if ($kandidat === null
|| $kandidat >= $this->ReadAttributeInteger('ErkannterMaximalstrom')
) {
$this->setzeFahrzeugstromKandidatZurueck();
return;
}
$jetzt = time();
if ($this->ReadAttributeInteger('FahrzeugstromKandidat') !== $kandidat) {
$this->WriteAttributeInteger('FahrzeugstromKandidat', $kandidat);
$this->WriteAttributeInteger('FahrzeugstromKandidatSeit', $jetzt);
return;
}
if (($jetzt - $this->ReadAttributeInteger('FahrzeugstromKandidatSeit'))
< $this->ReadPropertyInteger('FahrzeugstromErkennungszeit')
) {
return;
}
$this->WriteAttributeInteger('ErkannterMaximalstrom', $kandidat);
$this->WriteAttributeInteger('LeistungswechselGesperrtBis', 0);
$this->setzeSichtbareVariable('FahrzeugMaximalstrom', $kandidat);
$this->setzeFahrzeugstromKandidatZurueck();
}
private function setzeFahrzeugstromKandidatZurueck(): void
{
$this->WriteAttributeInteger('FahrzeugstromKandidat', 0);
$this->WriteAttributeInteger('FahrzeugstromKandidatSeit', 0);
}
/** @param list<int> $angebot */ /** @param list<int> $angebot */
private function bestimmeWirksameSollleistung(array $angebot): int private function bestimmeWirksameSollleistung(array $angebot): int
{ {
@@ -317,7 +561,15 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
if (in_array($sollleistung, $angebot, true)) { if (in_array($sollleistung, $angebot, true)) {
return $sollleistung; return $sollleistung;
} }
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
$ersatz = 0;
foreach ($angebot as $leistungswert) {
if ($leistungswert > $sollleistung) {
break;
}
$ersatz = $leistungswert;
}
return $ersatz;
} }
if (!$this->ReadAttributeBoolean('ZustandSolarladen') && $angebot !== [0]) { if (!$this->ReadAttributeBoolean('ZustandSolarladen') && $angebot !== [0]) {
@@ -333,12 +585,61 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$strom = $leistung === 0 $strom = $leistung === 0
? 0 ? 0
: LadestationRegler::stromFuerLeistung($leistung, $phasenzahl); : LadestationRegler::stromFuerLeistung($leistung, $phasenzahl);
if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) { $bisherigeLeistung = $this->ReadAttributeInteger('LetzteGesetzteLeistung');
$stromUnveraendert = $strom === $this->ReadAttributeInteger('LetzterGesetzterStrom');
if ($stromUnveraendert && $leistung === $bisherigeLeistung) {
return;
}
if (!$stromUnveraendert) {
$this->sendeLadestromAnGeraet($strom);
}
if ($leistung === $bisherigeLeistung) {
return; return;
} }
if (!$this->ReadAttributeBoolean('ZustandFahrzeugVerbunden')) {
$leistungswechselGesperrtBis = 0;
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
} else {
$jetzt = time();
$wechselzeit = $this->ReadPropertyInteger('ZeitZwischenZustandswechseln');
$leistungswechselGesperrtBis = $wechselzeit > 0
? $jetzt + ($wechselzeit * 60)
: 0;
if ($bisherigeLeistung <= 0 && $leistung > 0) {
$mindestzeit = $this->ReadPropertyInteger('Mindesteinschaltdauer');
$this->WriteAttributeInteger(
'AusschaltenGesperrtBis',
$mindestzeit > 0 ? $jetzt + ($mindestzeit * 60) : 0
);
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
} elseif ($bisherigeLeistung > 0 && $leistung <= 0) {
$mindestzeit = $this->ReadPropertyInteger('Mindestausschaltdauer');
$this->WriteAttributeInteger(
'EinschaltenGesperrtBis',
$mindestzeit > 0 ? $jetzt + ($mindestzeit * 60) : 0
);
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
}
}
$this->WriteAttributeInteger('LetzteGesetzteLeistung', $leistung);
$this->WriteAttributeInteger(
'LeistungswechselGesperrtBis',
$leistungswechselGesperrtBis
);
}
private function sendeLadestromAnGeraet(int $strom): void
{
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
$this->setzeFahrzeugstromKandidatZurueck();
$transport = new LadestationTransport( $transport = new LadestationTransport(
fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage) fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage, true)
); );
$transport->ladestromSetzen( $transport->ladestromSetzen(
$this->ReadPropertyInteger('Geraetetyp'), $this->ReadPropertyInteger('Geraetetyp'),
@@ -351,16 +652,21 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
/** /**
* @param array<string, mixed> $anfrage * @param array<string, mixed> $anfrage
*/ */
private function fuehreHttpAnfrageAus(array $anfrage): string private function fuehreHttpAnfrageAus(
array $anfrage,
bool $alsGeraetebefehl = false
): string
{ {
$protokoll = [ if ($alsGeraetebefehl) {
'Methode' => $anfrage['Methode'], $protokoll = [
'URL' => $anfrage['URL'], 'Methode' => $anfrage['Methode'],
]; 'URL' => $anfrage['URL'],
$this->setzeSichtbareVariable( ];
'LetzterGeraetebefehl', $this->setzeSichtbareVariable(
json_encode($protokoll, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) 'LetzterGeraetebefehl',
); json_encode($protokoll, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
);
}
if ($this->ReadPropertyBoolean('Testmodus')) { if ($this->ReadPropertyBoolean('Testmodus')) {
$url = (string) $anfrage['URL']; $url = (string) $anfrage['URL'];
@@ -564,6 +870,23 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
return is_array($zuordnung) ? $zuordnung : []; return is_array($zuordnung) ? $zuordnung : [];
} }
private function starteRegeluebergang(): void
{
if ($this->zugeordneteManagerIDs() === []) {
$this->beendeRegeluebergang();
return;
}
$this->WriteAttributeBoolean('RegeluebergangAktiv', true);
$this->SetTimerInterval('RegeluebergangTimeout', self::REGELUEBERGANG_TIMEOUT_MS);
}
private function beendeRegeluebergang(): void
{
$this->WriteAttributeBoolean('RegeluebergangAktiv', false);
$this->SetTimerInterval('RegeluebergangTimeout', 0);
}
private function pruefeKonfiguration(): void private function pruefeKonfiguration(): void
{ {
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) { foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
@@ -571,11 +894,27 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
throw new InvalidArgumentException($property . ' muss mindestens 0 sein.'); throw new InvalidArgumentException($property . ' muss mindestens 0 sein.');
} }
} }
foreach (['Meldeintervall', 'VorgabeTimeout', 'Abfrageintervall'] as $property) { foreach ([
'Meldeintervall',
'VorgabeTimeout',
'Abfrageintervall',
'Phasenerkennungszeit',
'FahrzeugstromErkennungszeit',
] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) { if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
} }
} }
foreach ([
'ZeitZwischenZustandswechseln',
'Mindesteinschaltdauer',
'Mindestausschaltdauer',
] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException($property . ' darf nicht negativ sein.');
}
}
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom'); $maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
if ($maximalstrom < 6 || $maximalstrom > 32) { if ($maximalstrom < 6 || $maximalstrom > 32) {
throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.'); throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.');
@@ -611,28 +950,31 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
private function aktualisiereVariablen(): void 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')) { if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->registriereVerbraucherDiagnose(); $this->registriereVerbraucherDiagnose();
$this->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 200); $this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 200);
$this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 210); $this->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 210);
$this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 220);
$this->RegisterVariableInteger(
'FahrzeugMaximalstrom',
'Erkannter Fahrzeug-Maximalstrom',
'',
230
);
$this->SetValue(
'FahrzeugMaximalstrom',
$this->ReadAttributeInteger('ErkannterMaximalstrom')
);
$this->SetValue( $this->SetValue(
'LeistungsangebotDiagnose', 'LeistungsangebotDiagnose',
$this->ReadAttributeString('Leistungsangebot') $this->ReadAttributeString('Leistungsangebot')
); );
foreach (self::DIAGNOSE_VARIABLEN as $ident) { foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if (!in_array($ident, ['LetzterGeraetebefehl', 'LeistungsangebotDiagnose'], true)) { if (!in_array($ident, [
'LetzterGeraetebefehl',
'LeistungsangebotDiagnose',
'FahrzeugMaximalstrom',
], true)) {
$this->setzeSichtbareVariable($ident, $this->leseZustand($ident)); $this->setzeSichtbareVariable($ident, $this->leseZustand($ident));
} }
} }
@@ -641,6 +983,24 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->entferneVariable($ident); $this->entferneVariable($ident);
} }
} }
if ($this->ReadPropertyBoolean('EinstellungenInVisu')) {
$this->RegisterVariableBoolean('Ladefreigabe', 'Ladefreigabe', '~Switch', 150);
$this->RegisterVariableBoolean('Solarladen', 'Solarladen', '~Switch', 160);
$this->RegisterVariableFloat('Istleistung', 'Istleistung', '', 170);
$this->RegisterVariableInteger('Sollleistung', 'Sollleistung', '', 180);
$this->EnableAction('Ladefreigabe');
$this->EnableAction('Solarladen');
$this->SetValue('Ladefreigabe', $this->ReadAttributeBoolean('ZustandLadefreigabe'));
$this->SetValue('Solarladen', $this->ReadAttributeBoolean('ZustandSolarladen'));
$this->SetValue('Istleistung', $this->ReadAttributeFloat('ZustandIstleistung'));
$this->SetValue('Sollleistung', $this->ReadAttributeInteger('ZustandSollleistung'));
} else {
$this->entferneVariable('Ladefreigabe');
$this->entferneVariable('Solarladen');
$this->entferneVariable('Istleistung');
$this->entferneVariable('Sollleistung');
}
} }
private function entferneVariable(string $ident): void private function entferneVariable(string $ident): void
@@ -748,7 +1108,13 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
private function deaktiviereTimer(): void private function deaktiviereTimer(): void
{ {
foreach (['Statuszyklus', 'Meldezyklus', 'RueckmeldungVerzoegert', 'VorgabeTimeout'] as $timer) { foreach ([
'Statuszyklus',
'Meldezyklus',
'RueckmeldungVerzoegert',
'VorgabeTimeout',
'RegeluebergangTimeout',
] as $timer) {
$this->SetTimerInterval($timer, 0); $this->SetTimerInterval($timer, 0);
} }
} }
+633 -7
View File
@@ -26,6 +26,13 @@
"name": "LizenzPruefen", "name": "LizenzPruefen",
"caption": "Lizenz pruefen und binden", "caption": "Lizenz pruefen und binden",
"onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);" "onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);"
},
{
"type": "Button",
"name": "LizenzVerwalten",
"caption": "Lizenz verwalten",
"link": true,
"onClick": "echo 'https://license.enelix.ch';"
} }
] ]
}, },
@@ -120,6 +127,24 @@
], ],
"values": [] "values": []
}, },
{
"type": "Label",
"name": "PeakShavingTitel",
"caption": "Peak Shaving am Netzanschlusspunkt"
},
{
"type": "CheckBox",
"name": "EinspeisebegrenzungAktiv",
"caption": "Anlagenweite Einspeisebegrenzung aktivieren"
},
{
"type": "NumberSpinner",
"name": "Einspeisegrenze",
"caption": "Maximale Einspeisung der Gesamtanlage",
"suffix": " W",
"minimum": 0.0,
"digits": 1
},
{ {
"type": "CheckBox", "type": "CheckBox",
"name": "AutomatischeSuche", "name": "AutomatischeSuche",
@@ -199,6 +224,387 @@
} }
] ]
}, },
{
"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": "Label",
"caption": "Die Wechselrichter-Tabelle beschreibt die AC-Wandler der Anlage. PV-Flaechen und Batteriespeicher verweisen darauf, damit gemeinsame Leistungsgrenzen und die elektrische Kopplung eindeutig sind. Bei einem Hybridgeraet verwenden PV und Batterie dieselbe Wechselrichter-ID; bei einem AC-gekoppelten Speicher wird dessen separater Batteriewechselrichter erfasst."
},
{
"type": "List",
"name": "AnlagenWechselrichter",
"caption": "Wechselrichter / AC-Wandler",
"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": "Label",
"caption": "Der Batterieeintrag beschreibt den Speicher selbst: Kapazitaet, Leistung, Ladezustand und Energiezaehler. Die Wechselrichter-ID ist nur die Verknuepfung zum zugehoerigen AC-Wandler und keine doppelte Batteriekonfiguration."
},
{
"type": "List",
"name": "AnlagenBatterien",
"caption": "Batteriespeicher",
"rowCount": 4,
"add": true,
"delete": true,
"sortable": true,
"columns": [
{
"caption": "ID",
"name": "ID",
"width": "120px",
"add": "bat-1",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Name",
"name": "Name",
"width": "auto",
"add": "Batteriespeicher",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Nennkapazitaet",
"name": "Nennkapazitaet_kWh",
"width": "150px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kWh",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Nutzkapazitaet",
"name": "Nutzkapazitaet_kWh",
"width": "150px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kWh",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Max. Laden",
"name": "MaxLadeleistung_kW",
"width": "130px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kW",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Max. Entladen",
"name": "MaxEntladeleistung_kW",
"width": "140px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kW",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Wechselrichter-ID",
"name": "WechselrichterID",
"width": "150px",
"add": "wr-1",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Kopplung",
"name": "Kopplung",
"width": "130px",
"add": "ac",
"edit": {
"type": "Select",
"options": [
{"caption": "AC", "value": "ac"},
{"caption": "DC", "value": "dc"},
{"caption": "Hybrid", "value": "hybrid"}
]
}
},
{
"caption": "Leistung",
"name": "LeistungVariableID",
"width": "140px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Messfaktor",
"name": "Leistungsfaktor",
"width": "110px",
"add": 1.0,
"edit": {"type": "NumberSpinner", "digits": 4}
},
{
"caption": "Ladezustand",
"name": "SOCVariableID",
"width": "140px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Ladeenergie",
"name": "LadeenergieVariableID",
"width": "140px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Entladeenergie",
"name": "EntladeenergieVariableID",
"width": "150px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Energiefaktor",
"name": "Energiefaktor",
"width": "110px",
"add": 1.0,
"edit": {"type": "NumberSpinner", "digits": 6}
}
]
}
]
},
{ {
"type": "ExpansionPanel", "type": "ExpansionPanel",
"caption": "Erweiterte Einstellungen", "caption": "Erweiterte Einstellungen",
@@ -253,12 +659,176 @@
}, },
{ {
"type": "ExpansionPanel", "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"
},
{
"type": "CheckBox",
"name": "EnergiediagrammeAnzeigen",
"caption": "Leistungs- und Energiediagramm unter dem Manager anzeigen"
},
{
"type": "RowLayout",
"items": [
{
"type": "Select",
"name": "DiagrammPVModus",
"caption": "PV in Diagrammen",
"options": [
{"caption": "Summe", "value": 0},
{"caption": "Einzelne Anlagen", "value": 1},
{"caption": "Summe und einzelne Anlagen", "value": 2}
]
},
{
"type": "Select",
"name": "DiagrammBatterieModus",
"caption": "Batterien in Diagrammen",
"options": [
{"caption": "Summe", "value": 0},
{"caption": "Einzelne Speicher", "value": 1},
{"caption": "Summe und einzelne Speicher", "value": 2}
]
}
]
},
{
"type": "CheckBox",
"name": "LadestationenSeparatAnzeigen",
"caption": "Ladestationen separat in Energiefluss und Diagrammen anzeigen"
},
{
"type": "CheckBox",
"name": "VerbraucherSeparatAnzeigen",
"caption": "Uebrige Verbraucher separat in Energiefluss und Diagrammen anzeigen"
},
{
"type": "Label",
"caption": "Bestehende Energy Pies, Diagramme und Energiefluesse bleiben bei Apply unveraendert. Fuer eine Neuerzeugung den jeweiligen Schalter ausschalten, Apply ausfuehren, wieder einschalten und erneut Apply ausfuehren."
},
{
"type": "CheckBox",
"name": "FunFactsAnzeigen",
"caption": "Energy Facts unter dem Manager anzeigen"
},
{
"type": "CheckBox",
"name": "EnergieflussAnzeigen",
"caption": "Energiefluss unter dem Manager anzeigen"
},
{
"type": "Select",
"name": "EnergieflussLeistungseinheit",
"caption": "Leistungseinheit im Energiefluss",
"options": [
{"caption": "Watt (W)", "value": 0},
{"caption": "Kilowatt (kW)", "value": 1}
]
}
]
},
{
"type": "ExpansionPanel",
"name": "PrognoseForecast",
"caption": "Prognose / Forecast", "caption": "Prognose / Forecast",
"items": [ "items": [
{ {
"type": "ValidationTextBox", "type": "CheckBox",
"name": "PrognoseAnschluss", "name": "PrognoseAktiv",
"caption": "Konfiguration als JSON" "caption": "Prognosen aktivieren",
"onChange": "IPS_RequestAction($id, 'FormPrognoseAktiv', $PrognoseAktiv);"
},
{
"type": "Label",
"name": "PrognoseLizenzInformation",
"caption": "Prognoselizenz wird geprueft."
},
{
"type": "Label",
"caption": "Forecast verwendet dieselben Messquellen aus der Anlagentopologie und den daraus berechneten Hausverbrauch."
},
{
"type": "CheckBox",
"name": "NetzfahrplanAktiv",
"caption": "Intelligenten Netzfahrplan zur Vermeidung von PV-Abregelung verwenden"
},
{
"type": "NumberSpinner",
"name": "PrognoseSendeintervall",
"caption": "Aktuelle Messwerte an den Prognosedienst senden alle",
"minimum": 60,
"maximum": 3600,
"suffix": " Sekunden"
} }
] ]
}, },
@@ -266,6 +836,50 @@
"type": "ExpansionPanel", "type": "ExpansionPanel",
"caption": "SDL / VGT", "caption": "SDL / VGT",
"items": [ "items": [
{
"type": "CheckBox",
"name": "SDLAktiv",
"caption": "SDL aktiv",
"onChange": "IPS_RequestAction($id, 'FormSDLAktiv', $SDLAktiv);"
},
{
"type": "ExpansionPanel",
"name": "SDLKonfiguration",
"caption": "SDL / Regelenergie Messung",
"visible": false,
"items": [
{
"type": "SelectVariable",
"name": "SDLLeistungVariableID",
"caption": "SDL Istleistung"
},
{
"type": "NumberSpinner",
"name": "SDLLeistungsfaktor",
"caption": "SDL Leistungsfaktor nach Watt",
"digits": 3
},
{
"type": "SelectVariable",
"name": "SDLSOCVariableID",
"caption": "SDL SOC in Prozent (optional)"
},
{
"type": "CheckBox",
"name": "SDLEnergieflussAnzeigen",
"caption": "SDL mit SOC im Energiefluss anzeigen"
},
{
"type": "CheckBox",
"name": "SDLDiagrammeAnzeigen",
"caption": "SDL mit SOC in den Diagrammen anzeigen"
},
{
"type": "Label",
"caption": "Nach Skalierung: positiv = Laden / Bezug, negativ = Entladen / Abgabe. SDL muss separat gemessen sein und darf nicht bereits in der normalen Batterieleistung enthalten sein. Die Hauslast fuer Prognosen wird unabhaengig von den Anzeigeoptionen korrigiert."
}
]
},
{ {
"type": "ValidationTextBox", "type": "ValidationTextBox",
"name": "SDLAnschluss", "name": "SDLAnschluss",
@@ -278,9 +892,21 @@
"caption": "Stoerueberwachung", "caption": "Stoerueberwachung",
"items": [ "items": [
{ {
"type": "ValidationTextBox", "type": "CheckBox",
"name": "StoermeldeAnschluss", "name": "StoerueberwachungAktiv",
"caption": "Konfiguration als JSON" "caption": "Stoerungen sicher an license.enelix.ch uebertragen"
},
{
"type": "NumberSpinner",
"name": "StoerungsSendeintervall",
"caption": "Heartbeat-Intervall",
"minimum": 60,
"maximum": 3600,
"suffix": " Sekunden"
},
{
"type": "Label",
"caption": "Die Freischaltung als jaehrlich erneuerbare Lizenz ist vorbereitet und wird spaeter aktiviert."
} }
] ]
} }
@@ -296,6 +922,6 @@
{"code": 102, "icon": "active", "caption": "Aktiv"}, {"code": 102, "icon": "active", "caption": "Aktiv"},
{"code": 201, "icon": "inactive", "caption": "Netzleistungsmessung fehlt oder ist veraltet"}, {"code": 201, "icon": "inactive", "caption": "Netzleistungsmessung fehlt oder ist veraltet"},
{"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"}, {"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"},
{"code": 203, "icon": "error", "caption": "Lizenz fehlt oder ist abgelaufen"} {"code": 203, "icon": "error", "caption": "Lizenz nicht freigegeben oder Verbraucherkontingent ueberschritten"}
] ]
} }
+2369 -78
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -112,6 +112,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
parent::ApplyChanges(); parent::ApplyChanges();
$this->aktualisiereVariablen(); $this->aktualisiereVariablen();
$this->bereinigeUngueltigenSollwert();
try { try {
$this->pruefeKonfiguration(); $this->pruefeKonfiguration();
} catch (Throwable $fehler) { } catch (Throwable $fehler) {
@@ -150,7 +151,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
case 'Aktiv': case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert); $this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) { if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
} }
$this->regelzyklus(true); $this->regelzyklus(true);
return; return;
@@ -208,7 +209,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
if ((bool) $this->leseZustand('SollwertGueltig') if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true) && !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true)
) { ) {
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
$this->regelzyklus(false); $this->regelzyklus(false);
} }
$this->SetTimerInterval('RueckmeldungVerzoegert', 100); $this->SetTimerInterval('RueckmeldungVerzoegert', 100);
@@ -290,7 +291,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
if ($letzteVorgabe <= 0 if ($letzteVorgabe <= 0
|| $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout') || $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout')
) { ) {
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
} }
$ziel = 0; $ziel = 0;
@@ -301,7 +302,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
} elseif ((bool) $this->leseZustand('SollwertGueltig')) { } elseif ((bool) $this->leseZustand('SollwertGueltig')) {
$ziel = (int) $this->leseZustand('Sollleistung'); $ziel = (int) $this->leseZustand('Sollleistung');
if (!in_array($ziel, $angebot, true)) { if (!in_array($ziel, $angebot, true)) {
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
$ziel = 0; $ziel = 0;
} }
} }
+63 -12
View File
@@ -4,28 +4,69 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
## Status ## Status
Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die Version 0.1 Build 1 ist die erste Beta fuer kontrollierte Anlagen- und
Verbraucherbasis, der Manager, der mehrstufige Warmwassererwaermer, der Feldtests. Der gemeinsame Nachrichtenvertrag, die Verbraucherbasis, der Manager
1-stufige Verbraucher und die Ladestation Stand-Alone sind implementiert. Die und alle unten aufgefuehrten Module sind implementiert. Produktiver Einsatz
weiteren Module sind als Diskussionsentwürfe dokumentiert. setzt eine anlagenspezifische Pruefung, Sicherung und Rueckfallplanung voraus.
## Geplante Module ## Module
- Manager (implementiert) - Manager (implementiert)
- Batterie - Batterie (implementiert)
- Wassererwaermer (implementiert) - Wassererwaermer (implementiert)
- Pufferspeicher - Pufferspeicher (implementiert)
- Verbraucher 1-Stufig (implementiert) - Verbraucher 1-Stufig (implementiert)
- Waermepumpe - Waermepumpe (implementiert)
- Ladestation Stand-Alone (implementiert) - Ladestation Stand-Alone (implementiert)
- Ladestation Gateway - Ladestation Gateway (implementiert)
- Easee Gateway - Easee Gateway (implementiert)
Die Properties, Variablen, Zustandsdaten und offenen Punkte stehen in der Die Properties, Variablen, Zustandsdaten und offenen Punkte stehen in der
[Modulübersicht](docs/module/README.md). Gemeinsame Felder werden dort nicht [Modulübersicht](docs/module/README.md). Gemeinsame Felder werden dort nicht
abweichend neu definiert, sondern verweisen auf den zentralen Vertrag. abweichend neu definiert, sondern verweisen auf den zentralen Vertrag.
Die Verbraucher kommunizieren mit dem Manager ueber den gemeinsamen Vertrag in `libs/`. Der Manager waehlt die Verbraucher aus; in einem Verbraucher gibt es keine Manager-ID-Property. Die Verbraucher kommunizieren mit dem Manager ueber den gemeinsamen Vertrag
in `libs/`. Der Manager waehlt die Verbraucher aus; in einem Verbraucher gibt
es keine Manager-ID-Property.
## Betriebsartabhaengige Leistungsangebote
Der Nachrichtenvertrag `4.0` uebermittelt in beiden Richtungen verpflichtend
die aktuelle `Betriebsart`. Zulaessige Werte sind `PV` fuer Solarbetrieb und
`Peak` fuer die Lastspitzenbegrenzung. Dadurch kann jeder Verbraucher fuer
beide Betriebsarten unterschiedliche PowerSteps melden.
Ein Betriebsartwechsel wird synchronisiert:
1. Der Manager bestimmt die neue Betriebsart.
2. Er sendet sie mit `Sollleistung_W: null` an alle aktiven Verbraucher.
3. `null` ist nur eine Ankuendigung und kein Schaltbefehl.
4. Jeder Verbraucher berechnet sein zustandsabhaengiges Leistungsangebot neu
und meldet es mit derselben Betriebsart zurueck.
5. Der Manager verteilt konkrete Sollleistungen an alle bereits
synchronisierten Verbraucher.
6. Fehlende, veraltete oder noch nicht umgeschaltete Verbraucher bleiben von
der Verteilung ausgeschlossen und werden als Stoerung ausgewiesen.
Der Manager verwendet damit nie ein PV-Angebot fuer Peak oder umgekehrt. Ein
nicht antwortender Verbraucher blockiert die Regelung der uebrigen Anlage
nicht; sein aktueller Einfluss ist bereits in der Netzleistungsmessung
enthalten. Eine technische Schaltsperre oder eine Mindestzeit kann das Angebot
weiterhin auf die aktuell gehaltene Leistung begrenzen.
| Verbraucher | PV-Angebot | Peak-Angebot |
| --- | --- | --- |
| Verbraucher 1-Stufig | Normal `[0, Nennleistung]`; bei faelliger Tagesmindestlaufzeit `[Nennleistung]` | Normal `[0]`; bei faelliger Tagesmindestlaufzeit konfigurierbar `[0, Nennleistung]` oder `[Nennleistung]` |
| Ladestation Stand-Alone | Mit Solarladen `[0, ...Ladestufen]`, sonst maximale Ladeleistung | Mit Solarladen `[0]`, sonst `[0, ...Ladestufen]` |
| Ladestation Gateway | Mit Solarladen `[0, ...Ladestufen]`, sonst maximale Ladeleistung | Mit Solarladen `[0]`, sonst `[0, ...Ladestufen]` |
| Warmwassererwaermer | Unter Mindesttemperatur maximale Stufe, sonst zustandsabhaengig | Unter wirksamer Mindesttemperatur `[0, ...Leistungsstufen]`, sonst `[0]` |
| Pufferspeicher | Unter Einschaltschwelle `[0, ...Leistungsstufen]`, sonst `[0]` | Unabhaengig vom Zustand `[0]` |
| Batterie | SoC-abhaengiges Lade-/Entladeraster | SoC-, Reserve- und netzabhaengiges Angebot |
Die vollstaendigen Nachrichtenfelder und JSON-Beispiele stehen in der
[Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md). Hintergrund,
Alternativen und Folgen beschreibt
[ADR 0004](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md).
## Voraussetzungen ## Voraussetzungen
@@ -60,13 +101,23 @@ der Uebernahme in einen Freigabebranch erfolgreich sein.
## Dokumentation ## Dokumentation
- [Changelog](CHANGELOG.md)
- [Zentrale Liste offener Teamentscheidungen](docs/Offene-Punkte.md) - [Zentrale Liste offener Teamentscheidungen](docs/Offene-Punkte.md)
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md) - [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
- [Manager-Modul](docs/module/Manager/README.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) - [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md)
- [Pufferspeicher-Modul](docs/module/Pufferspeicher/README.md)
- [Batteriemodul](docs/module/Batterie/README.md)
- [Verbraucher-1-Stufig-Modul](docs/module/Verbraucher-1-Stufig/README.md) - [Verbraucher-1-Stufig-Modul](docs/module/Verbraucher-1-Stufig/README.md)
- [Ladestation-Stand-Alone-Modul](docs/module/Ladestation-Stand-Alone/README.md) - [Ladestation-Stand-Alone-Modul](docs/module/Ladestation-Stand-Alone/README.md)
- [Ladestation-Gateway-Modul](docs/module/Ladestation-Gateway/README.md)
- [Easee-Gateway-Modul](docs/module/Easee-Gateway/README.md)
- [Easee-Gateway-Schnittstelle](docs/Schnittstelle-Easee-Gateway.md)
- [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md) - [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md)
- [Migration Verbraucher 1-Stufig](docs/migration/Verbraucher-1-Stufig.md) - [Migration Verbraucher 1-Stufig](docs/migration/Verbraucher-1-Stufig.md)
- [Migration Batterie](docs/migration/Batterie.md)
- [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md) - [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md)
- [Modulübersicht](docs/module/README.md) - [Modulübersicht](docs/module/README.md)
+172
View File
@@ -0,0 +1,172 @@
# Verbraucher 1-Stufig
IP-Symcon-Modul fuer einen elektrischen Verbraucher, der genau zwei
Leistungszustaende kennt: aus (`0 W`) und ein (`Nennleistung`).
Das Modul ist fuer IP-Symcon ab Version 8.0 und den Enelix-Nachrichtenvertrag
`4.0` ausgelegt. Es arbeitet ereignisbasiert und besitzt weder einen
Regelzyklus noch eine konfigurierbare Zyklusanzahl oder Zykluszeit.
## Funktionen
- Schalten eines Boolean-Aktors auf `0 W` oder die konfigurierte Nennleistung
- optionale separate Schaltzustands-Rueckmeldung
- Mindest-Einschalt- und Mindest-Ausschaltdauer
- taegliche Mindestlaufzeit
- getrennte Prioritaeten fuer PV- und Peakbetrieb
- ereignisbasierte Zustandsmeldung an den Enelix Manager
- Diagnosevariablen und optionales Debug-Logging
- sichere lokale Deaktivierung ueber die Variable `Aktiv`
## Installation
1. Im IP-Symcon Module Control die Bibliothek
`https://git.belevo.ch/ENELIX/Enelix-EMS.git` installieren.
2. Fuer Entwicklung und Tests den Kanal beziehungsweise Branch `develop`
verwenden.
3. Unter **Instanz hinzufuegen** nach **Verbraucher 1-Stufig** suchen.
4. Eine Instanz anlegen und mindestens Nennleistung und Schaltkontakt
konfigurieren.
## Konfiguration
| Einstellung | Standard | Beschreibung |
| --- | ---: | --- |
| `PrioritaetPV` | `0` | Reihenfolge bei der PV-Leistungsverteilung. |
| `PrioritaetPeak` | `0` | Reihenfolge bei der Lastspitzenregelung. |
| `Meldeintervall` | `10 s` | Periodische Vollmeldung an zugeordnete Manager. |
| `VorgabeTimeout` | `120 s` | Gueltigkeitsdauer einer Manager-Vorgabe. |
| `Mindesteinschaltdauer` | `5 s` | Mindestdauer eines bestaetigten Ein-Zustands. |
| `Mindestausschaltdauer` | `5 s` | Mindestdauer eines bestaetigten Aus-Zustands. |
| `Nennleistung` | `0 W` | Leistungsaufnahme im eingeschalteten Zustand. |
| `SchaltkontaktVariableID` | `0` | Booleanvariable des zu schaltenden Aktors. |
| `SchaltkontaktInvertiert` | `false` | Invertiert die Aktorlogik. |
| `RueckmeldungVariableID` | `0` | Optionale Booleanvariable fuer den physischen Zustand. |
| `Mindestlaufzeit` | `0 s` | Geforderte Laufzeit pro lokalem Kalendertag. |
| `PeakSperreBeiMindestlaufzeitAnbieten` | `true` | Erlaubt im Peakbetrieb eine Sperre trotz faelliger Tagesmindestlaufzeit. |
| `DiagnosevariablenAnzeigen` | `false` | Blendet technische Diagnosevariablen ein. |
| `LoggingEin` | `false` | Aktiviert zusaetzliche Debug-Ausgaben. |
`Nennleistung` muss groesser als `0` sein. Der Schaltkontakt muss eine
Booleanvariable mit funktionsfaehiger Standard- oder benutzerdefinierter
Aktion sein. Beide Mindestzeiten duerfen auf `0` gesetzt werden.
Der fruehere allgemeine `Umschaltabstand` sowie Properties fuer Zyklusanzahl
und Zykluszeit existieren nicht.
## Verhalten ohne separate Rueckmeldung
Ist keine `RueckmeldungVariableID` konfiguriert, wird die Aktorvariable als
unmittelbare Schaltbestaetigung verwendet.
Nach einem erfolgreichen Schaltbefehl beginnt ab dem uebernommenen
Aktorzustand:
- beim Einschalten die `Mindesteinschaltdauer`,
- beim Ausschalten die `Mindestausschaltdauer`.
Uebernimmt die Aktorvariable den angeforderten Wert nicht, meldet das Modul
einen Schaltfehler und stellt sich dem Manager nicht als schaltbereit dar.
## Verhalten mit separater Rueckmeldung
Ist eine `RueckmeldungVariableID` konfiguriert, bestimmt ausschliesslich deren
Booleanwert den bestaetigten Schaltzustand. `true` muss dabei physisch
eingeschaltet bedeuten.
Zwischen Aktorbefehl und passender Rueckmeldung meldet das Modul:
- den bisherigen bestaetigten Schaltzustand und die bisherige Istleistung,
- `SchaltbefehlAusstehend=true`,
- `Schaltbereit=false`,
- `AenderungMoeglich=false`.
Die jeweilige Mindestzeit beginnt erst, sobald die Rueckmeldung den neuen
Zustand bestaetigt. Bleibt die Rueckmeldung aus, bleibt der Schaltbefehl
sichtbar ausstehend. Eine abweichende Rueckmeldung ohne laufenden
Schaltvorgang wird als Rueckmeldefehler gemeldet.
## Mindestzeiten und Leistungsangebot
Waerend einer Mindest-Ein- oder Mindest-Aus-Zeit bietet das Modul nur die
bestaetigte aktuelle Leistung an. Ein regulaerer Lastwechsel ist erst nach
Ablauf der Mindestzeit wieder moeglich.
Der Manager erhaelt dazu unter anderem:
| Zustand | Bedeutung |
| --- | --- |
| `Schaltzustand` | Bestaetigter Ein-/Aus-Zustand. |
| `SchaltbefehlAusstehend` | Aktorbefehl wartet auf physische Bestaetigung. |
| `Schaltbereit` | Ein weiterer regulaerer Lastwechsel ist moeglich. |
| `RestMindestzeit_s` | Verbleibende Mindestzeit in Sekunden. |
| `Tageslaufzeit_s` | Bestaetigte Laufzeit des aktuellen Tages. |
| `Rueckmeldefehler` | Aktor und Rueckmeldung widersprechen sich unerwartet. |
Die lokale Aktion `Aktiv=false` ist ein bewusster Sicherheits-Override. Sie
schaltet den Verbraucher auch waehrend einer laufenden
Mindesteinschaltdauer aus.
## Sichtbare Variablen
Immer vorhanden sind:
- `Aktiv`: lokale Freigabe fuer das Energiemanagement
- `Schaltzustand`: bestaetigter oder aus dem Aktor abgeleiteter Zustand
- `Tageslaufzeit`: bestaetigte Laufzeit des aktuellen Tages in Sekunden
Bei aktivierter Diagnose werden zusaetzlich Soll- und Istleistung,
Verfuegbarkeit, Schaltbereitschaft, Stoerung, Rueckmeldefehler,
ausstehender Schaltbefehl und verbleibende Mindestzeit angezeigt.
## Inbetriebnahme
1. Nennleistung und Schaltkontakt konfigurieren.
2. Falls vorhanden, die separate Rueckmeldung auswaehlen und ihre
`true`-Semantik pruefen.
3. Mindest-Ein- und Mindest-Ausschaltdauer passend zum angeschlossenen
Geraet festlegen.
4. Den Verbraucher im Enelix Manager manuell oder automatisch aktiv
zuordnen.
5. Fuer die Erstpruefung Diagnosevariablen und bei Bedarf Logging aktivieren.
6. Die Variable `Aktiv` einschalten.
7. Unter Aufsicht je eine Ein- und Aus-Vorgabe durch den Manager ausfuehren.
8. Kontrollieren, dass Rueckmeldung, Mindestzeiten und `Schaltbereit`
erwartungsgemaess wechseln.
## Fehlersuche
- **Konfiguration ungueltig:** Nennleistung, Variablentyp und Aktoraktion
pruefen.
- **Schaltbefehl bleibt ausstehend:** Separate Rueckmeldung und deren
`true`-Semantik pruefen.
- **Rueckmeldefehler:** Aktor- und Rueckmeldewert stimmen ausserhalb eines
laufenden Schaltvorgangs nicht ueberein.
- **Kein Lastwechsel moeglich:** `RestMindestzeit`, `Aktiv`,
`SollwertGueltig` und die Managerzuordnung kontrollieren.
- **Keine Manager-Vorgabe akzeptiert:** Der Verbraucher muss beim sendenden
Manager aktiv zugeordnet sein.
## Tests
Nur dieses Modul:
~~~bash
composer check:verbraucher-einstufig
~~~
Gesamtes Repository inklusive dieser Testsuite:
~~~bash
composer check
~~~
Die Modulsuite kann damit unabhaengig weiterentwickelt werden und bleibt
gleichzeitig Bestandteil des allgemeinen Tests.
## Weiterfuehrende Dokumentation
- [Ausfuehrliche Modulbeschreibung](../docs/module/Verbraucher-1-Stufig/README.md)
- [Manager-Verbraucher-Schnittstelle](../docs/Schnittstelle.md)
- [Migration von Enelix 1](../docs/migration/Verbraucher-1-Stufig.md)
- [Aufbau der separaten Testsuite](../tests/VerbraucherEinStufig/README.md)
+6 -5
View File
@@ -118,6 +118,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
parent::ApplyChanges(); parent::ApplyChanges();
$this->aktualisiereVariablen(); $this->aktualisiereVariablen();
$this->bereinigeUngueltigenSollwert();
$this->registriereMeldungen(); $this->registriereMeldungen();
try { try {
@@ -158,7 +159,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
case 'Aktiv': case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert); $this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) { if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
$this->SetTimerInterval('VorgabeTimeout', 0); $this->SetTimerInterval('VorgabeTimeout', 0);
} }
$this->aktualisiere(true); $this->aktualisiere(true);
@@ -214,7 +215,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
if ((bool) $this->leseZustand('SollwertGueltig') if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true) && !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)
) { ) {
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
} }
$this->planeMeldung(); $this->planeMeldung();
return; return;
@@ -254,7 +255,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
true true
) )
) { ) {
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
} }
$aktiv = (bool) $this->GetValue('Aktiv'); $aktiv = (bool) $this->GetValue('Aktiv');
@@ -474,7 +475,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
if ($letzteVorgabe <= 0 if ($letzteVorgabe <= 0
|| $jetzt - $letzteVorgabe >= $this->ReadPropertyInteger('VorgabeTimeout') || $jetzt - $letzteVorgabe >= $this->ReadPropertyInteger('VorgabeTimeout')
) { ) {
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
$this->SetTimerInterval('VorgabeTimeout', 0); $this->SetTimerInterval('VorgabeTimeout', 0);
} }
} }
@@ -528,7 +529,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
$this->WriteAttributeString('LaufzeitTag', date('Y-m-d', $jetzt)); $this->WriteAttributeString('LaufzeitTag', date('Y-m-d', $jetzt));
$this->WriteAttributeInteger('LaufzeitStandZeit', $jetzt); $this->WriteAttributeInteger('LaufzeitStandZeit', $jetzt);
$this->setzeZustand('Tageslaufzeit', 0); $this->setzeZustand('Tageslaufzeit', 0);
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
$this->setzeZustand('Rueckmeldefehler', false); $this->setzeZustand('Rueckmeldefehler', false);
$this->WriteAttributeBoolean('Initialisiert', true); $this->WriteAttributeBoolean('Initialisiert', true);
} }
+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
+14
View File
@@ -130,6 +130,20 @@
"minimum": 0.1, "minimum": 0.1,
"digits": 1 "digits": 1
}, },
{
"type": "NumberSpinner",
"name": "TemperaturUntergrenze",
"caption": "Untertemperaturgrenze",
"suffix": " °C",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "TemperaturObergrenze",
"caption": "Übertemperaturgrenze",
"suffix": " °C",
"digits": 1
},
{ {
"type": "List", "type": "List",
"name": "Zeitplan", "name": "Zeitplan",
+35 -14
View File
@@ -56,6 +56,8 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
$this->RegisterPropertyString('Zeitplan', '[]'); $this->RegisterPropertyString('Zeitplan', '[]');
$this->RegisterPropertyInteger('LastwechselSperrzeit', 5); $this->RegisterPropertyInteger('LastwechselSperrzeit', 5);
$this->RegisterPropertyInteger('TemperaturMaxAlter', 30); $this->RegisterPropertyInteger('TemperaturMaxAlter', 30);
$this->RegisterPropertyFloat('TemperaturUntergrenze', 0.0);
$this->RegisterPropertyFloat('TemperaturObergrenze', 100.0);
$this->RegisterPropertyFloat('Hysterese', 5.0); $this->RegisterPropertyFloat('Hysterese', 5.0);
$this->RegisterPropertyBoolean('LegionellenfunktionAktiv', true); $this->RegisterPropertyBoolean('LegionellenfunktionAktiv', true);
$this->RegisterPropertyInteger('LegionellenMinimalintervallTage', 4); $this->RegisterPropertyInteger('LegionellenMinimalintervallTage', 4);
@@ -120,6 +122,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
$this->initialisiereTemperaturen(); $this->initialisiereTemperaturen();
$this->aktualisiereVariablen(); $this->aktualisiereVariablen();
$this->bereinigeUngueltigenSollwert();
try { try {
$this->pruefeKonfiguration(); $this->pruefeKonfiguration();
@@ -217,7 +220,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
if ((bool) $this->leseZustand('SollwertGueltig') if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true) && !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true)
) { ) {
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
$this->regelzyklus(false); $this->regelzyklus(false);
} }
$this->SetTimerInterval('RueckmeldungVerzoegert', 100); $this->SetTimerInterval('RueckmeldungVerzoegert', 100);
@@ -262,7 +265,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
if ($letzteVorgabe <= 0 if ($letzteVorgabe <= 0
|| $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout') || $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout')
) { ) {
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
} }
$ziel = 0; $ziel = 0;
@@ -274,7 +277,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
} elseif ((bool) $this->leseZustand('SollwertGueltig')) { } elseif ((bool) $this->leseZustand('SollwertGueltig')) {
$ziel = (int) $this->leseZustand('Sollleistung'); $ziel = (int) $this->leseZustand('Sollleistung');
if (!in_array($ziel, $angebot, true)) { if (!in_array($ziel, $angebot, true)) {
$this->setzeZustand('SollwertGueltig', false); $this->verwerfeSollwert();
$ziel = 0; $ziel = 0;
} }
} }
@@ -537,6 +540,10 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
if (!$temperaturGueltig) { if (!$temperaturGueltig) {
$stoerungen[] = 'Boilertemperatur fehlt oder ist veraltet.'; $stoerungen[] = 'Boilertemperatur fehlt oder ist veraltet.';
} }
$untertemperatur = $this->istUntertemperatur($temperaturGueltig);
if ($untertemperatur) {
$stoerungen[] = 'Untertemperatur erkannt.';
}
$uebertemperatur = $this->istUebertemperatur($temperaturGueltig); $uebertemperatur = $this->istUebertemperatur($temperaturGueltig);
if ($uebertemperatur) { if ($uebertemperatur) {
$stoerungen[] = 'Uebertemperatur erkannt.'; $stoerungen[] = 'Uebertemperatur erkannt.';
@@ -555,19 +562,18 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
} }
} }
private function istUntertemperatur(bool $temperaturGueltig): bool
{
return $temperaturGueltig
&& (float) $this->GetValue('Boilertemperatur')
< $this->ReadPropertyFloat('TemperaturUntergrenze');
}
private function istUebertemperatur(bool $temperaturGueltig): bool private function istUebertemperatur(bool $temperaturGueltig): bool
{ {
if (!$temperaturGueltig) { return $temperaturGueltig
return false; && (float) $this->GetValue('Boilertemperatur')
} > $this->ReadPropertyFloat('TemperaturObergrenze');
$grenze = (float) $this->GetValue('Maximaltemperatur');
if ($this->ReadPropertyBoolean('LegionellenfunktionAktiv')) {
$grenze = max($grenze, $this->ReadAttributeFloat('ZustandLegionellentemperatur'));
}
return (float) $this->GetValue('Boilertemperatur')
> $grenze + $this->ReadPropertyFloat('Hysterese');
} }
private function sendeVerbraucherdaten(): void private function sendeVerbraucherdaten(): void
@@ -592,6 +598,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
{ {
$temperaturGueltig = (bool) $this->leseZustand('TemperaturGueltig'); $temperaturGueltig = (bool) $this->leseZustand('TemperaturGueltig');
$schaltfehler = $this->ReadAttributeString('Schaltfehler'); $schaltfehler = $this->ReadAttributeString('Schaltfehler');
$untertemperatur = $this->istUntertemperatur($temperaturGueltig);
$uebertemperatur = $this->istUebertemperatur($temperaturGueltig); $uebertemperatur = $this->istUebertemperatur($temperaturGueltig);
return [ return [
@@ -655,6 +662,13 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
'Einheit' => '', 'Einheit' => '',
'Text' => $temperaturGueltig ? '' : 'Boilertemperatur fehlt oder ist veraltet.', 'Text' => $temperaturGueltig ? '' : 'Boilertemperatur fehlt oder ist veraltet.',
], ],
[
'Kennung' => 'Untertemperatur',
'Art' => 'Stoerung',
'Wert' => $untertemperatur,
'Einheit' => '',
'Text' => $untertemperatur ? 'Untertemperatur erkannt.' : '',
],
[ [
'Kennung' => 'Uebertemperatur', 'Kennung' => 'Uebertemperatur',
'Art' => 'Stoerung', 'Art' => 'Stoerung',
@@ -745,6 +759,13 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
if ($this->ReadPropertyFloat('Hysterese') <= 0) { if ($this->ReadPropertyFloat('Hysterese') <= 0) {
throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.'); throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.');
} }
if ($this->ReadPropertyFloat('TemperaturUntergrenze')
>= $this->ReadPropertyFloat('TemperaturObergrenze')
) {
throw new InvalidArgumentException(
'Die Temperaturuntergrenze muss kleiner als die Temperaturobergrenze sein.'
);
}
$minimum = (float) $this->GetValue('Mindesttemperatur'); $minimum = (float) $this->GetValue('Mindesttemperatur');
$maximum = (float) $this->GetValue('Maximaltemperatur'); $maximum = (float) $this->GetValue('Maximaltemperatur');
$legionellen = (float) $this->ReadAttributeFloat('ZustandLegionellentemperatur'); $legionellen = (float) $this->ReadAttributeFloat('ZustandLegionellentemperatur');
+1
View File
@@ -2,6 +2,7 @@
"name": "belevo/enelix-ems", "name": "belevo/enelix-ems",
"description": "Energiemanagement und steuerbare Verbraucher fuer IP-Symcon", "description": "Energiemanagement und steuerbare Verbraucher fuer IP-Symcon",
"type": "library", "type": "library",
"license": "proprietary",
"require": { "require": {
"php": ">=8.0" "php": ">=8.0"
}, },
+98
View File
@@ -0,0 +1,98 @@
# Forecast- und SDL-Integration, 6. Oktober 2026
## Auftrag und Umfang
Daniel Haefliger hat die Weiterfuehrung passender unveroeffentlichter Arbeiten,
die Integration der Prognosebedienung, SDL-Anzeigen und die Veroeffentlichung
auf develop und beta ausdruecklich beauftragt. main bleibt unveraendert.
- Manager: Prognose / Forecast ist der gemeinsame Bedienort. Die bisherigen
separaten Diagnosevariablen sind umbenannt und in der Visualisierung verborgen.
- Batterie: die bereits integrierte Bereinigung des separaten V4-Formulars bleibt
erhalten. Bestehende Idents, APIs und gespeicherte Freigaben werden aus
Kompatibilitaetsgruenden nicht umbenannt oder geloescht.
- Portal: derselbe Planner erscheint im vorhandenen Prognosebereich. Die exakte
ausgewaehlte Eingangsprognose wird als forecastPoints eingefroren, unabhaengig
vom durch publizierte Preise begrenzten Optimierungshorizont. Netzplan,
Batteriestellwerte und Kosten werden nicht kuenstlich verlaengert.
- Diagramme zeigen PV, Last, SDL-Szenario, Netz, Batterie, SOC, Preise und Kosten.
Fehlende Werte bleiben Luecken. Prognosewerte sind keine gemessenen Erfolge.
- Utils kapselt die generische Darstellung nicht bestimmbarer Energieanteile.
EMS entscheidet bei SDL-Bilanzierung, dass die Haus-PV-Anteile nicht eindeutig
aus dem SDL-haltigen Netzbezug hergeleitet werden koennen. Zaehler bleiben sichtbar.
## Migration und Erhaltung
Utils vor EMS aktualisieren. Bei alten Utils blendet EMS den eigenen Energy Pie
bei aktiver SDL aus und protokolliert den Aktualisierungsbedarf, statt falsche
Prozentwerte anzuzeigen oder die Manager-Regelung zu stoppen.
Bestehende Energy-Pie-Konfiguration, individuelle Flow-Knoten und Nicht-SDL-
Diagrammreihen bleiben erhalten. Visualisierungsschalter nicht aus-/einschalten:
das wuerde teilweise eine Neuerzeugung ausloesen. Historien, Energiezaehler,
Mess-Outbox und Betriebsfreigaben werden nicht zurueckgesetzt.
Die Testanlage wurde mit gesicherten, hashgeprueften Quelldateien aktualisiert.
Manager 17004 verwendet SDL-Leistung 25085 (W, Faktor +1) und SOC 23879 (%).
Diese Werte gehoeren zur vorhandenen virtuellen EV/SDL-Aufteilung und sind kein
unabhaengiger physischer SDL-Messnachweis. Der einzelne Batterie-Flow-Knoten wurde
von Proxy 19274 auf 42728 umgestellt; alle anderen Felder wurden erhalten.
Flow 57933 enthaelt zusaetzlich SDL. Leistungsdiagramm 53754 wuchs von vier auf
sechs Reihen, Energiediagramm 19668 von sechs auf neun. Keine Alt-Reihe entfernt.
Private Rueckfallsicherung auf der Anlage:
`/var/lib/symcon/backup/enelix-forecast-sdl-20261006T171851Z`.
Sie enthaelt Konfigurationen und darf nicht in Git, Logs oder Chat kopiert werden.
Der temporaere Beobachtungshook 12555 wurde wiederhergestellt.
## Pruefungen
- EMS PHPUnit: 360 Tests, 1771 Assertions, erfolgreich.
- Utils PHPUnit: 58 Tests, 350 Assertions, erfolgreich.
- Backend: 312 Tests, erfolgreich, isolierter aktueller develop-Stand mit Patch.
- Formular: 71 Pruefungen; RequestAction: 26; Regeltests: 96; Empfaenger: 32.
- Chart- und Energy-Pie-JavaScript: 14 Tests, erfolgreich.
- Portal-Mount: acht Tests, erfolgreich, inklusive Anlagen-/Ansichtswechsel,
Abmeldung, verspaeteter Antworten und unabhaengiger Fehlerbehandlung.
- Echter Chromium: Desktop 1280 px, Mobil 390 und 320 px; Kurven, Auswahl,
Maus/Tastatur, Lebenszyklus, CSS-Abgrenzung und Screenshots erfolgreich.
- Testanlage nach Installation: Manager, Batterie, Energy Pie und Flow Status 102;
SDL-Quellen und erhaltene Diagrammreihen kontrolliert.
## Offene Betriebsschritte, keine Produktionsfreigabe
Die Nachkontrolle 2026-10-06 17:26:21 UTC zeigte den bekannten Symcon-Timerfehler
nach Module Control Reload: alter Batterie-Timer 70 blieb laufend, neue
Melde-/Rueckmeldetimer hatten noch keinen Lauf. Ein kontrollierter Symcon-Neustart
und danach erneute Pruefung sind erforderlich. Status 102 allein ist kein Beleg
fuer funktionierende Timer. Die neue Netzfahrplansteuerung 46716 bleibt false.
Portal-Assets wurden ohne Containerneustart aktualisiert. Das Backend-Image ist
noch nicht aktualisiert: Docker-Socketzugriff fuer agent ist nicht erlaubt.
Vorbereitete, erfolgreich vorgepruefte Administratoruebergabe auf enelix-services:
```sh
sudo python3 /srv/agent/forecast-completion-20261006/deploy_forecast_backend.py --apply
```
Das Skript prueft zwei exakte Quelldateien, sichert Datenbank und Quellen,
baut/testet das Image vor dem Austausch und bewahrt die bisherige Image-ID fuer
Rollback. Keine Socketrechte werden geaendert. Keine Stellfreigabe wird erteilt.
Nach erfolgreicher Installation Neuberechnung und neue forecastPoints pruefen.
Bis dahin kennzeichnet die Anzeige alte Plaene und zeigt keine erfundenen Werte.
Unbefristeter Betrieb ist NICHT freigegeben: Nachweis eines geraeteseitigen
Ausfallschutzes, physischer Soll-/Ist-Nachlauf, sichere Null-/Stop-Reaktion und
Fortfuehrung ueber mehrere Planerneuerungen fehlen weiterhin. Die bestehende
befristete Zustimmung bis 7. Oktober 2026, 17:38:07 Europe/Zurich wird nicht
verlaengert. EV-Konto bleibt 161.44 kWh / 39 kW; physische Gesamtkapazitaet und
virtuelle SDL-Energie werden nicht als zusaetzliche EV-Energie angesetzt.
## Pull-Request-Text
Forecast-Bedienung im Manager konsolidieren, vollstaendige gespeicherte
Eingangsprognose im bestehenden Portal einbetten und SDL-Anzeigen vervollstaendigen.
Individuelle Visualisierungen und kompatible Kennungen bleiben bestehen;
nicht bestimmbare Energieanteile werden ehrlich als unbekannt dargestellt.
Automatisierte Tests und responsive Browserpruefung erfolgreich. Zwei explizite
Betriebsblocker bleiben dokumentiert; kein unbegrenzter Anlagenbetrieb aktiviert.
+406
View File
@@ -0,0 +1,406 @@
# ENELIX Netzfahrplan V4 – verbindlicher Gesprächs- und Arbeitskontext
## Fortsetzung 06.10.2026, 17:32 UTC: Forecast/SDL installiert, Betriebsblocker
Massgeblicher neuer Bericht: `docs/FORECAST_SDL_RELEASE_20261006.md`.
Portal-Assets sind auf license.enelix.ch im vorhandenen Prognosebereich aktiv
und ueber HTTPS hashgleich geprueft. Vollstaendige forecastPoints benoetigen noch
das vorbereitete Backend-Image-Update durch einen Administrator. agent hat keine
Docker-Socketrechte; keine Umgehung, kein ausgefuehrtes Backend-Deployment.
Testanlage: Utils und EMS aus gesichertem Kandidat geladen, SDL-Messung und
Anzeigen eingerichtet, individuelle Reihen/Knoten und Historien erhalten.
Alte Diagnoseanzeigen und die zwei V4-Diagnosekategorien verborgen, nichts
geloescht oder deren Beobachter abgeschaltet. Hook 12555 wiederhergestellt.
Nach Reload haengt erneut alter Batterietimer 70; neue Batterie-Meldetimer
laufen nicht. Kontrollierter Symcon-Neustart und Nachkontrolle erforderlich.
Schalter 46716 bleibt false; keine neue Stellprobe, keine Fristverlaengerung.
Unbefristete Steuerung ist ohne unabhaengigen Ausfallschutz nicht freigegeben.
Die automatisierten Softwaretests und responsive Chromium-Pruefung sind gruen;
dies ist ausdruecklich keine erfolgreiche physische Anlagenabnahme.
## Integration 06.10.2026: Freigabe fuer develop und beta, kein Anlagenrollout
Daniel hat nach der Bedienintegration Merge, Commit und Push auf **develop und
beta** ausdruecklich beauftragt. Der getestete Integrationsstand verbindet die
22 lokalen V4-Commits bis ce525a1 mit origin/develop bis 5437f3f und den
zugehoerigen V4-/Formularkorrekturen. Originalarbeitsverzeichnisse bleiben
unangetastet; Integration in /srv/agent/prognose-integration-20261006/repo auf
ubuntu. Keine Feature-Branches, kein Force-Push, main bleibt unveraendert.
Die Bedienung liegt im bisherigen Bereich Prognose / Forecast; der separate
V4-Bereich entfaellt. Einzelheiten und verbleibender Umfang stehen in
docs/prognose-bedienintegration.md. Es bleibt der vorhandene begrenzte Testpfad,
kein regulaerer Dauerbetrieb. Keine Laufzeitordner geloescht, keine Dienste
neu gestartet, keine Module geladen und kein neuer Stellversuch ausgefuehrt.
Die unten dokumentierte Batterietimerblockade bleibt offen. Die ausdrueckliche
Branchfreigabe ersetzt keine physische Anlagenabnahme oder Stellfreigabe.
Isoliert bestanden: 357 PHPUnit-Tests / 1758 Assertions, Syntax aller 150 PHP-
Dateien, 310 Backend-Tests, 16 Portal-Tests und die Bedien-/V4-Einzelchecks.
Kein neuer Symcon-Kernel-/GUI-Test, weil Docker im Agentzugang nicht nutzbar war.
Eine lokale Backend-Aenderung, die ungepruefte Messkonfigurationen automatisch
mit accountingEvidenceId versah, wurde nach fehlgeschlagenem Schutztest nicht
uebernommen; Originalarbeitskopie und laufendes Backend bleiben erhalten.
Commit-Autor ist gemaess bestehender Teamklarstellung dh_Agent <dh@belevo.ch>,
konfiguriertes Gitea-Konto dh. Die Repository-Abfrage bestaetigte Pushrecht;
die Profilabfrage war mangels read:user-Scope nicht verfuegbar. Fuer den
tatsaechlichen Veroeffentlichungsstand die Remote-Refs und den Merge-Commit
pruefen, nicht historische Ahead-/Behind-Angaben weiter unten verwenden.
## Kontext-Sicherung 06.10.2026: Lesereihenfolge und Quellen
Auf Daniels ausdruecklichen Wunsch wird der Arbeitsstand dauerhaft verlinkt.
Fuer den Laufzeitstand ist der unmittelbar folgende Abschnitt **06.10.2026,
12:01 UTC** massgeblich, nicht die aelteren Abschnitte 1, 10, 11 oder 15.
Insbesondere ist der bestaetigte Geraeteabruf bereits implementiert/installiert;
der naechste Schritt ist die dort belegte instanzbezogene Batterietimerblockade.
Der Abschlussnachweis `/srv/agent/v4-recovery-20261006/finished.json` auf
iot-symcon01 wurde erneut gelesen, aber kein neuer Stellversuch ausgefuehrt.
Git-Nachkontrolle in dieser Kontext-Sicherung: nach erfolgreichem Fetch steht
das Server-89-develop **22 Commits vor / 3 hinter origin/develop**; Remote-Stand
bei Fetch `58f91e4`. Kein sicherer Fast-Forward. Bestehende fremde/lokale
Aenderungen bleiben erhalten; kein Merge, Projekt-Commit oder Projekt-Push.
Das ist vom anderen Checkout auf ubuntu und dessen separat dokumentierter
Manager-Leistungsverteilung zu unterscheiden.
Die mitgesendeten Screenshots haben kein bestaetigtes Aufnahmedatum und sind
historische Anschauung, kein neuer Laufzeitnachweis. Der Aktualisierungsdialog
ist keine Freigabe, lokale Modulaenderungen zu verwerfen. Es wurden keine
Module aktualisiert, Dienste neu gestartet oder Anlagenfreigaben veraendert.
## Fortsetzung 06.10.2026, 12:01 UTC: Code installiert, Batterietimer blockieren Live-Abnahme
- Daniel hat nach der ausdruecklichen Rueckfrage den Test ohne zusaetzliches +/-5-kW-Limit bestaetigt. Dynamische Geraetegrenzen (hier bis 39 kW), SOC- und Netzgrenzen bleiben verbindlich. Die urspruengliche Freigabe endet unveraendert am 07.10.2026 um 17:38:07 Europe/Zurich (1791387487). Kein Neustart der 48-h-Frist. Der fehlende unabhaengige Hardware-Watchdog bleibt nur durch die bereits genehmigte Testanlagen-Ausnahme abgedeckt.
- Zusaetzliche allgemeine Arbeitsfreigabe: Routinemaessige Schreibzugriffe auf Lihrenmoos, tunel100 und Server89 benoetigen keine erneute Rueckfrage. Das hebt Sicherheitsgrenzen, technische Berechtigungen, Git-Regeln und konkrete Anlagenfreigaben nicht auf.
- Der vorstehend beschriebene Kandidat wurde inzwischen installiert und getestet. Weitere eng begrenzte Korrekturen dieser Fortsetzung: (1) Batteriebefehl meldet den konkreten Ablehnungsgrund; (2) physische Geraeteabfrage aus dem Batterie-Stellmutex getrennt, eigenstaendiger 1-s-Samplingtimer mit nicht wartender Lesesperre; (3) Stellpfad nutzt nur unveraenderten bestaetigten Cache, maximal 2 s alt nach Wand- und monotoner Zeit, mit Konfigurations-/Quellenbindungspruefung; (4) passendes Server-Pending fuehrt auch vor Aktivstart zu 15-s-Retry statt zum Verpassen des fertigen Plans, HTTP-429 behaelt normalen Backoff. Cache-90-s-Grenze und physische Zeitstempel wurden nicht verlaengert oder erfunden.
- Live-Installationen mit SHA-256-Pruefung und Backups: Fehlerdiagnose 11:31:18 UTC; Sampler 11:38:43 UTC; Empfang um 11:50 UTC. Arbeits- und Sicherungsordner: /srv/agent/v4-recovery-20261006 auf iot-symcon01. Berichte deployed.json, sampler-deployed.json, receiver-deployed.json und test-results.json. Geaenderte Quellen und Tests sind auch im bestehenden develop-Arbeitsverzeichnis auf enelix-services, fremde Aenderungen sind erhalten.
- Letzter Aktivversuch: 11:50:52 UTC, Session 13395dbe-0dbb-42b8-9063-511bd7166f41. Manager meldete danach controller_command_sent (seq 5, 18693 W); Batterie wurde wegen feedback_confirmed_cache_stale_or_invalid gestoppt. Danach Managerabbruch wegen ungueltiger lokaler Betriebsdaten, controllerStopAccepted=true. Kein belastbarer Nachweis von physischem Tracking oder >90-s-Dauerbetrieb. Ein gesendeter Befehl ist ausdruecklich keine Anlagenabnahme.
- KONKRETE OFFENE BLOCKADE: Saemtliche Timer der Batterieinstanz 44234 zeigen LastRun=0. Meldezyklus (5000 ms) und neuer Rueckmeldungstimer (1000 ms) sind lange ueberfaellig, Running=false. Manager- und uebrige Anlagentimer laufen. Instanzstatus 102; Instanz und alle Elternobjekte nicht deaktiviert; keine dauerhaft belegten Skriptthreads nachgewiesen. IPS_ApplyChanges(44234) bei bestaetigtem Null-Stopp um 11:57:31 UTC gab TRUE zurueck, hat die Timer aber nicht repariert. Die Ursache ist NICHT geklaert. Auch der Batterie-Watchdog hat bisher keinen automatischen Lauf nachgewiesen. Deshalb kein weiterer Aktivstart und keine Umgehung durch einen externen Tick-Hook.
- Weitere beobachtete Abbruchbedingung: Durch Reloads/Messluecken fehlte zeitweise laufende Viertelstundenenergie; der Planner verwirft Luecken >120 s weiterhin korrekt. Nach einem Quartalswechsel wurden wieder optimale Plaene empfangen. Zum Schluss meldete der Betriebssender zudem veraltete Verbraucher-Rueckmeldung, passend zum nicht laufenden Batterie-Meldezyklus. Keine Energiestaende, Cursor oder Datenbankwerte wurden in dieser Fortsetzung umgeschrieben.
- Endzustand 12:01:11 UTC: Schalter 46716=false, alter Netzfahrplan=false, Batterie status=stopped, EV-Sollwert 19651=0 W. SDL-Sollwert war 7600 W aus dem unabhaengigen bestehenden Betrieb und wurde nicht auf null gezwungen. Kein physischer V4-Regelungsnachweis. Verbindlicher Abschlussbericht: /srv/agent/v4-recovery-20261006/finished.json.
- Alle temporaeren Start-, Deploy- und Diagnosehooks wurden aus 12555.ips.php entfernt. Original aus /srv/agent/12555.ips.php.v4hook-backup wiederhergestellt, SHA-256 c702a4947ae200b23c3eecccabeb843ad6a55e97509292584ca2de779d5e4940. Nur v4viewsRun des urspruenglichen getrennten Beobachters verbleibt. Inhaltsuche in /var/lib/symcon/scripts fand keine Verweise auf die V4-Recovery-/Continuation-Hooks. Keine unbeobachtete Wiederanlaufautomatik hinterlassen.
- Tests erneut erfolgreich: 96 Regel-/Cache-/Lockpruefungen, 32 Empfangspruefungen, 8 Rueckmeldungs-Empfangspruefungen, 9 Geraeteabrufpruefungen und 1 Test der echten Registermethode mit synthetischen Aktionen, insgesamt 146. Syntaxpruefungen erfolgreich; git diff --check sauber. Das sind automatisierte Tests, kein Hardwareabnahmetest.
- Git bleibt develop, ahead 22 / behind 1 gegen origin/develop, kein sicherer Fast-Forward. Keine Projekt-Commits, Merges oder Pushes. Konto nicht fuer Schreibaktion verifiziert; weder beta noch main geaendert. Die fehlende Live-Abnahme schliesst eine Beta-Freigabe zusaetzlich aus.
- Naechster fachlicher Schritt: Instanzbezogene Symcon-Timerblockade beheben, automatische Sampler-/Melde-/Watchdog-Ausfuehrung belegen und erst dann einen kontrollierten Neustart des V4-Tests versuchen. Ein etwa erforderlicher Symcon-Dienstneustart betrifft weitere Anlagenfunktionen und wurde hier NICHT ausgefuehrt. Danach physisches Tracking, Planwechsel >90 s und bestaetigten Null-Stopp nachweisen. Alte Startmarker oder Installationshooks keinesfalls einfach loeschen/reaktivieren.
## Fortsetzung 06.10.2026: gepruefter Kandidat, Live-Eingriff noch gesperrt
- Daniel hat die Fortsetzung und die Aufhebung der 5-kW-Testgrenze beauftragt. Die bestehende 48-h-Frist, SOC-/Netz-/Geraetegrenzen und der bereits bekannte fehlende unabhaengige Hardware-Watchdog bleiben unveraendert.
- Aktuell auf iot-symcon01 gelesen: Refresh-Deployment 07:43:14 UTC; Full-Power-Deployment 08:46:59 UTC. Diese waren schon vor dieser Fortsetzung installiert. Snapshot 09:30:41 UTC: Schalter AUS, Batterie disabled, Manager stopped_fallback_pending wegen ungueltiger lokaler Betriebsdaten. Keine physische V4-Regelung belegt.
- Neuer Kandidat korrigiert ignorierte FALSE-Rueckgaben von IPS_RequestAction/RequestAction (Start, Command, Stop und Register), den Vor-I/O-Zeitvergleich beim Batterie-Scharfschalten und den blockierenden HTTP-Refresh im Regel-Tick. Separater Empfangstimer: 10 s Prueftakt, aktiver Abruf fruehestens nach 45 s, Fehler-Retry 15 s; Cache-Lebensdauer und Serverzeit bleiben maximal 90 s.
- Kandidat liegt auf develop im Arbeitsverzeichnis und isoliert unter /srv/agent/v4-continuation-20261006 auf Lihrenmoos. 87 Regeltest-, 29 Empfangs-, 8 Rueckmeldungspruefungen plus 1 echter Modulmethoden-Test mit synthetischen Aktionen bestanden; 5 geaenderte Live-PHP-Dateien syntaxgeprueft. Keine Hardwaretests daraus ableiten. Testbericht: test-results.json im Stage.
- Neue Tests decken insbesondere FALSE ohne Exception, Sekundengrenze beim Start, bytegleich behaltenen aktiven Cache, 15-s-Retry, 90-s-Ablauf und zukuenftige Empfangszeit ab. git diff --check erfolgreich.
- Die automatische Sicherheitspruefung hat die Ausfuehrung von /srv/agent/activate_v4_continuation.py abgelehnt, weil diese Live-Installation und Aktivstart koppelt. Der Benutzer wurde um ausdrueckliche Freigabe fuer Installation und Start ohne 5-kW-Limit bis zum bisherigen Ende (07.10.2026 17:38 Europe/Zurich) gebeten. NICHT ueber andere Werkzeuge umgehen. Die Aktivierung wurde nicht ausgefuehrt; 12555.ips.php ist unveraendert.
- Achtung: Der bestehende temporaere v4_direct_battery_command_hook.php sendet alle 30 s den letzten Managerbefehl erneut. Das kann den Replay-Schutz ausloesen. Vor freigegebenem Neustart kontrolliert entfernen. Vorbereitete Aktivierung sichert den alten Timer und ersetzt alte Schreib-/Diagnosehooks durch eine einmalige gesicherte Installation, genau einen Startversuch und reine Beobachtung. Sie ist noch nicht ausgefuehrt. Bei Live-Drift abbrechen.
- Git nach erfolgreichem Fetch: develop ist 22 Commits vor und 1 Commit hinter origin/develop (fremder Commit 37addf4). Fremde Lade-/GUI-Aenderungen sind erhalten. Kein Fast-Forward moeglich; kein Commit, Merge oder Push in dieser Fortsetzung. Kandidatenpatch: /srv/agent/v4-continuation-20261006.patch auf enelix-services.
- Naechster Schritt erst nach Freigabe: Stage/Live-Hashes und Zustand erneut pruefen, Kandidat installieren, genau einen Startversuch beobachten und dessen echte Batterie-Antwort pruefen. Danach >90 s mit Planwechsel, reale Leistungs-/Netzreaktion und sicheren Nullstell-Stopp nachweisen. Erst anschliessend regulaeren Testlauf belassen und temporaere Hooks entfernen. Kein stabiler Aktivbetrieb zugesichert.
Übergabe erstellt am 04.10.2026 auf ausdrücklichen Wunsch von Daniel Häfliger (BELEVO AG). Sie fasst die Anforderungen, Entscheidungen, Fehlerbehebungen und den zuletzt belegten Stand dieses Gesprächs zusammen. Sie ist **kein wörtliches Chatarchiv, keine neue Stellfreigabe und kein Nachweis der Produktionsreife**.
**Zeitbezug:** Die letzten hier belegten Betriebsberichte stammen vom **03.10.2026, ca. 14:02 Uhr Europe/Zurich**. Am 04.10. wurden für diese Übergabe die genannten Berichte und der Git-Stand erneut gelesen, aber keine neue Anlagenabnahme durchgeführt. Alte Messzahlen deshalb niemals als heutige Live-Werte ausgeben.
**Fortsetzung Prognose am 04.10.2026:** Der aktuelle Runtime-Stand wurde gelesen, ohne die alten Diagnose- oder Installationsschritte zu wiederholen. Der abgeleitete Datensatz `lihrenmoos-physical-published-v2` ist `model_ready` und erfüllt mit mehr als 24 nutzbaren äquivalenten Stunden die Trainingsschwelle. Die V4-Einstellungen stehen seit Revision 2 in `shadow` auf `forecastSource=corrected_profile` und `measurementDataset=lihrenmoos-physical-published-v2`; `liveEnabled` und Stellfreigabe bleiben aus. Der erste echte Lauf zeigte einen kompatibilitätsbedingten Stopp bei mikrosekundengenauen `observedAt`-Zeitstempeln. Die Korrektur rundet ausschliesslich die kausale Verfügbarkeit von Prognoseereignissen auf die nächste volle Sekunde auf; Rohmessungen bleiben streng ganzsekündlich. 310 Python- und 16 Portal-Tests sind erfolgreich. Repository und Runtime-Build-Kontext enthalten bytegleich den geprüften Fix; der laufende Container enthält ihn noch nicht, weil beide Agentzugänge weder Docker-Socket noch `sudo` erhalten. Der vorbereitete, syntaktisch geprüfte Root-Rollout liegt unter `/home/agent/services/netplan-v4-shadow/commissioning/finish_corrected_forecast_rollout.sh` und baut/testet vor dem Austausch, hält das vorige Image als Rollback fest und ändert keine Stellfreigabe. Daniel hat danach einen Aktivbetrieb angefragt; der aktuelle V4-Kern ist jedoch technisch `shadow-only`, und die weiterhin unbelegte Rückmeldung (`feedback_source_skew`, `usableForTrial=false`, `canDispatch=false`) verbietet ein blosses Umstellen. Keine Live-Freigabe wurde erteilt oder eingebaut. Git-Klarstellung durch Daniel: Commit-Autor `dh_Agent <dh@belevo.ch>`, Gitea-Konto `dh`; Commit/Push auf `develop` und `beta` am 04.10.2026 ausdrücklich freigegeben. Der Agentzugang besitzt derzeit keine HTTPS-Schreibanmeldung für `dh`.
**Fortsetzung bestätigte Rückmeldung am 04.10.2026:** Daniels Freigabe des modellierten `virtual_split` gilt für den ausdrücklich begrenzten Feldtest. Die physische Rückmeldung wurde um synchrone, durch den Gerätetreiber bestätigte Lesevorgänge erweitert: M-Bus verwendet `MBUS_UpdateValues`, ModBus Device und ModBus Address verwenden `ModBus_RequestRead`. Nur ein erfolgreicher Geräteaufruf erzeugt `confirmedAt`; `VariableUpdated` bleibt getrennte Herkunftsinformation. Die Regeltest-Gates verlangen `deviceReadConfirmed=true`. Das reproduzierbare inkrementelle Paket liegt auf der Testanlage unter `/srv/agent/netplan-v4-confirmed-feedback-20261004`; 18 Paketprüfsummen, 37 Rückmeldetests, 7 Geräteabruf-Tests und 71 Regeltest-Prüfungen sind erfolgreich. Es ist noch **nicht im IP-Symcon-Kernel installiert**, weil der lokale JSON-RPC-Zugang ohne Anmeldung mit HTTP 401 antwortet. Die Installation selbst lässt alten Netzfahrplan und beide Regeltestfreigaben aus. Ein realer Stellversuch bleibt zusätzlich durch den nicht belegten unabhängigen Geräte-Watchdog und die fehlende Serverfreigabe blockiert; der vorhandene Symcon-`VorgabeTimeout` ist kein unabhängiger Hardware-Nachweis. Auf `enelix-services` und der Testanlage ist kein nutzbarer Git-HTTPS-Credential-Helper hinterlegt; die Prüfung des Tunnel-Servers lief zweimal in eine externe Connector-Freigabezeitüberschreitung. Tokeninhalt wurde weder gelesen noch ausgegeben. Der Commit des Folgepakets ist lokal mit der vereinbarten Identitaet `dh_Agent <dh@belevo.ch>` erstellt. Der atomare Push auf `develop` und `beta` scheiterte ohne Teilaktualisierung, weil fuer `https://dh@git.belevo.ch` kein Passwort/Token bereitstand.
**Fortsetzung Aktivbereitschaft am 04.10.2026:** Die reale Stellkette wurde in den gespeicherten Testanlagen-Settings bis zu den Aktionszielen geprüft. Die ENELIX-Batterieinstanz 44234 schreibt über das Aktionsskript 31800 auf den EV-Sollwerteingang 19651 des Gateways 58448. Dieses Gateway verteilt im Zwei-Sekunden-Takt auf zwei GoodWe-Pfade über reine SetValue-Aktionsskripte und auf drei SolarEdge-Modbus-Adressen. Der ENELIX-`VorgabeTimeout` setzt bei laufendem Symcon nach 30 Sekunden auf 0 W zurück. Weder im Gateway noch in den drei GoodWe-Aktionsskripten wurde jedoch ein vom Symcon-Kernel unabhängiger Geräte-Watchdog gefunden; auch die SolarEdge-Modbus-Konfiguration weist nur Poll-/Write-Parameter, keinen Geräte-Timeout aus. Ein unbeaufsichtigter 1-2-Tage-Stellbetrieb bleibt deshalb gesperrt. Beide lokalen Regeltestfreigaben sind weiterhin aus, es wurde kein Stellbefehl ausgelöst. Der bestätigte Rückmeldeinstaller ist für einen gestoppten Kernel vorbereitet und geprüft, konnte mangels privilegierter `systemctl`-Berechtigung des Connector-Benutzers aber noch nicht ausgeführt werden. Auf `tunel100` ist ein Credential-Helper für das Konto `dh` vorhanden und ein `develop`-Dry-Run war erfolgreich; die danach nötige Connector-Freigabe lief erneut ab, bevor der lokale 19-Commit-Stand übertragen und veröffentlicht werden konnte.
## 1. Zuerst lesen: Wo wir tatsächlich stehen
- Daniel möchte die **fertige, produktionsgeeignete Anwendung**, nicht weitere isolierte Sammler, Diagnosekategorien oder wiederholte Bestätigungsrunden. Er hat die fortlaufende Umsetzung mehrfach beauftragt. Probleme im Code selbst beheben, Tests und Auslieferung bündeln; ihn nur für wirklich notwendige Root-/Symcon-Ausführung oder echte Anlagenfreigaben einbeziehen.
- Datenaufnahme, bestätigter Versand, historische Aufbereitung, Modellaufbau, V4-Optimierer, Planempfang und ein ausdrücklich begrenzter Regeltest sind implementiert. Der zuletzt installierte Teil verbindet korrigierte physische Rückmeldung mit Vorschau und dem **begrenzten** Test-Stellpfad.
- Der Versandblocker durch `100.0`/`100` ist **behoben und der Nachversand hat aufgeholt**. Nicht erneut beim Cursor oder der alten Kennungsdiagnose anfangen.
- Die letzte Feedback-Installation ist **erfolgreich abgeschlossen**, nicht mehr `waiting_for_registration`. Bericht: `corrected_feedback_installed_trial_disabled`, Rückmeldung `unavailable`, Grund **`feedback_source_skew`**.
- **Nächster konkreter Entwicklungsauftrag:** bestätigte, zum tatsächlich gelesenen Wert passende Geräte-Lesezeitpunkte an den lokalen Rückmeldepfad anschliessen. Bisher wird `VariableUpdated` als Live-Zeitbasis verwendet; unveränderte Modbus-Werte werden aber nicht zwingend bei jedem erfolgreichen Abruf erneut publiziert. Geräteabruf, Variablenpublikation und aktueller Lesezugriff müssen auseinandergehalten werden.
- Keine künstliche Zeitstempelverjüngung, pauschale Toleranzerhöhung, Nullersetzung, historische Interpolation als Live-Messung oder Rückkehr zum festgehaltenen virtuellen EV-Wert.
- **Weiterhin keine produktive V4-Batterieausführung:** alter intelligenter Netzfahrplan AUS; beide lokalen Regeltestfreigaben AUS; kein gestarteter Stellversuch. Bestehende lokale Regelung und SDL laufen unabhängig weiter.
- Ein uneingeschränkter Produktions-Dauerregler ist **noch offene Umsetzung**, nicht nur ein fehlender Haken. Automatik, reale Modellqualität, Geräteausfallverhalten und Mehranlagenfreigabe haben ebenfalls verbleibende Grenzen.
- Letzter fachlicher EMS-Commit: **`9878197`**, `develop`, bei Übergabeprüfung sauber und **15 Commits vor lokalem `origin/develop`**. Kein Remote-Fetch in dieser Prüfung. Vorherige Pushversuche scheiterten an Authentifizierung. Dokumentations-Commits dieser Übergabe können danach folgen.
## 2. Arbeitsweise und Freigaben
Daniel ist technisch versiert, will aber keine unnötige manuelle Kleinarbeit. Deutsch, Schweizer Schreibweise, konkrete nutzbare Befehle. Ein abgeschlossener Test ist nicht automatisch Installation, Installation nicht Stellfreigabe, `optimal` nicht reale Einsparung. Immer getrennt berichten: implementiert / isoliert getestet / Zielcontainer getestet / installiert / im Kernel geprüft / am Gerät erprobt / veröffentlicht.
**Nicht wieder in die alte Schleife zurückfallen:**
1. Keine neuen Diagnosekategorien als Ersatz für die eigentliche Integration.
2. Keine erneuten Fragen nach bereits bestätigten EV-Kapazitäten oder pauschal immer wieder nach Wechselrichtertypen. Bestehende Quellen nutzen. Eine tatsächlich fehlende sicherheitsrelevante Eigenschaft darf trotzdem nicht erfunden werden.
3. Nicht einfach „24 Stunden warten“, solange ein Software- oder Übertragungsfehler das Training verhindert.
4. Keine alten Einzelinstaller erneut ausführen; neueren Quellstand und parallele GUI-Arbeit nicht überschreiben.
5. Nicht behaupten, im Hintergrund weiterzuarbeiten. Nur tatsächlich ausgeführte Arbeit und belegte Ergebnisse berichten.
6. Die Regel „ein einziger konkreter Punkt“ betraf Einträge in **Offene Punkte**, nicht Entwicklungsaufträge. Implementierungsaufträge dürfen zusammenhängende Anforderungen bündeln.
**Git:** Keine Feature-Branches. Entwicklung auf `develop`; nach Tests `beta`; nach Feldtest `main`. Daniel hat im Gespräch Commit/Push für `develop` und damals auch `beta` ausdrücklich freigegeben. Diese Freigabe nicht als Auftrag interpretieren, einen aktuell ungetesteten Kandidaten blind auf `beta`/`main` zu schieben. `develop`-Push ohne erneute Nachfrage im bestehenden Rahmen erlaubt. Nicht force-pushen, nicht unzusammenhängende Änderungen übernehmen, keine Zugangsdaten verlangen/ausgeben. Historische Agenten-Commitidentität war `ENELIX Agent <agent@enelix.invalid>`; keine globale Git-Konfiguration ungeprüft ändern.
Die aktuelle Nachricht beauftragt **Kontextübergabe**. Sie erteilt keine zusätzliche Anlagen-, Runtime- oder Stellfreigabe. Bei späterer Fortsetzung greifen die bestehenden Sicherheits- und Branchregeln weiter.
## 3. Ursprüngliches fachliches Ziel
### 3.1 Prognosefamilien und Erweiterbarkeit
Im GUI manuell eine Familie wählen oder `auto`:
| Familienschlüssel / Fahrplan | PV | Last | Fahrplanvariable |
|---|---|---|---|
| `3` | `prog_var_1` | `prog_var_2` | `prog_var_3` |
| `13` | `prog_var_10` | `prog_var_11` | `prog_var_13` |
| `23` | `prog_var_21` | `prog_var_22` | `prog_var_23` |
Die neun Zahlen sind nicht neun unabhängige Fahrplanmodelle. Daniel akzeptierte die Auswahl der vollständigen Modellfamilie. Registry und Datenadapter so gestalten, dass weitere Familien und später weitere flexible Anlagen ergänzt werden können. Nicht behaupten, drei bereits vorhandene Lastmethoden seien identisch mit den neu aufgebauten physischen Profilen; Datenquelle und Modellversion müssen sichtbar sein.
`auto` soll jene Familie wählen, die in der jüngeren Vergangenheit **wirtschaftlich am besten abgeschnitten hätte**, mit gleichen Anfangsbedingungen, Tarifen, SOC-/Restenergiebewertung und Randbedingungen. Keine Auswahl allein nach R², keine Zukunftsinformation im historischen Replay. Mindestabdeckung, Mindestdauer, Rückblick und Wechselmarge verhindern häufige/schlecht belegte Wechsel.
### 3.2 Netzfahrplan / Optimierung
- Bis zu **48 Stunden**, grundsätzlich **5-Minuten-Schritte** (höchstens 576), rollende Neuberechnung.
- Ausgangsverlauf: korrekt abgegrenzte Verbraucherlast minus PV plus separat berücksichtigte externe Flüsse. Darauf flexible Verbraucher optimieren; aktuell EV-Batterie, später Ladestationen, Boiler usw.
- Batterie: aktueller virtueller EV-SOC, dessen zugehörige EV-Gesamtkapazität, aktuelle maximale Lade-/Entladeleistung, Min-/Max-SOC, Reserve, Hysterese, Verfügbarkeit und lokale Einschränkungen. Keine zukünftige Energie vor dem Laden nutzen.
- **90 % Roundtrip-Wirkungsgrad**, nicht zweimal 90 %. Implementierung verteilt Verluste mit `sqrt(0.9)` auf Laden/Entladen.
- Netzladen ist ausdrücklich erlaubt/gewünscht, wenn freigegeben und wirtschaftlich sinnvoll. Nicht generell Nachtbezug verbieten oder tagsüber Entladung verbieten.
- Kosten pro Intervall: Bezugsenergie mal Bezugspreis minus Einspeiseenergie mal Einspeisepreis. Leistungswerte in W/kW korrekt über Zeit in kWh umrechnen. Gesamtkosten minimieren, nicht mathematisch auf exakt null zwingen; negative Kosten sind möglich, nicht garantiert.
- Statische oder dynamische, in der Anlage konfigurierte Bezugspreise und Einspeisevergütungen verwenden. GUI-Preisänderungen speichern und Neuberechnung anfordern. PV-Vergütung, Netzladekosten, Verluste und Peakpreis gehören in dieselbe Wirtschaftlichkeitsbetrachtung.
- Wenn dynamische Preise noch nicht veröffentlicht sind, den **ausführbaren wirtschaftlichen Horizont auf den bekannten Preisbereich begrenzen** (`published_only`); kein frei erfundener Preis für den Rest der 48 h. Volle Prognose und ausführbaren Preishorizont unterscheiden; Ende passend zur vollständigen Abrechnungsviertelstunde.
- Restenergie am Horizont fair bewerten/absichern, damit die Batterie nicht am Planende oder zu fast wertlosen Mittagstarifen grundlos leerverkauft wird.
- Bezug/Einspeisung sowie Laden/Entladen nicht als unphysikalische gleichzeitige Arbitrage zulassen.
- Einspeisebegrenzung, zulässige Abregelung/entgangene Vergütung und lokale harte Netzgrenzen berücksichtigen. Einen unerreichbaren Plan nicht als eingehalten darstellen.
### 3.3 Monatspeak
- Leistungstarif auf den relevanten **15-Minuten-Netzbezug**; persistenter Monatsmaximalwert, nicht momentane Spitzenleistung. Für den laufenden Monat zusätzliche Kosten nur für Erhöhung über den bereits erreichten Peak; Monatswechsel korrekt behandeln.
- Bereits verstrichene Energie der aktuellen Viertelstunde mitnehmen. Fehlende Messbasis nicht durch eine konfigurierte Grenze ersetzen.
- Am Monatsanfang nicht künstlich jede Aktivität verhindern: Restmonatsbewertung/empirische Aussicht ist vorgesehen, mit ausgewiesener Unsicherheit. Ein erwarteter künftiger Peak ist **keine bereits bezahlte kostenlose Freigabe**.
- Anlagen können **keine feste Bezugsgrenze** haben. Falls im Manager statische oder monatliche Peakgrenzen konfiguriert sind, diese zusätzlich respektieren.
- Tarifeinheiten und tatsächliche Abrechnungsdefinition prüfen; gemessene, geschätzte und nur konfigurierte Peakwerte getrennt kennzeichnen.
### 3.4 Training, Reaktion und Validierung
Tägliches oder wöchentliches Nachtraining nach GUI-Einstellung; Parameter-/Preisänderungen sollen Neuberechnung auslösen. Getrennte versionierte Historien und Modelle, keine alten belasteten Daten als bereinigt umbenennen. Ein Cold-Start-Modell nach Mindestdatenbasis ist noch keine validierte Feldprognose. Vergleich gegen die bisherige Batterie-Betriebsweise, nicht nur gegen „Anlage ohne Batterie“.
## 4. Lihrenmoos: bestätigte Anlage und massgebliche Korrekturen
- Installation: **`e3a08f9e-af12-4695-99bd-8b51c0520021`**.
- Symcon Manager **`17004`**, ENELIX-Batterieinstanz **`44234`**, virtuelles Asset **`anlage01-virtual-ev`**, bestehendes EV-/SDL-Gateway **`58448`**.
- **Massgeblich bestätigt: EV-Kapazität `161.44 kWh`, EV-Leistung `39 kW`.** Die vorher genannten `160 kWh`, `30 kW` und „je 10 kW pro WR“ hat Daniel ausdrücklich als Erinnerungskorrektur zurückgenommen. Daraus keine neue Verteilung auf Einzelgeräte ableiten.
- Virtueller EV-SOC und die EV-Kapazität bilden ein gemeinsames Energiekonto. Keine SOC-/Energie-Resets oder Umrechnung auf 322 kWh. **SDL-Reserve ist bereits ausgegliedert** und darf nicht nochmals von EV abgezogen werden.
- Vom Nutzer genannte physische Ausstattung: zwei GoodWe à **50 kW AC**, je **156 kWh** Batterie; SolarEdge **10 kW AC / 10 kWh** Batterie. Summe physische Kapazitäten rechnerisch 322 kWh, nicht die EV-Kapazität. AC-Nennleistung ist kein unabhängiger Nachweis jeder aktuell zulässigen Batterie-Ladeleistung.
- Dach-PV ungefähr **20 kWp Ost + 20 kWp West**.
- Exakte Gerätetypen/Firmware, jede AC-/DC-Messgrenze und autonomes Verhalten beim Befehlsausfall sind durch diese Nennwerte nicht automatisch bestätigt. Vorhandene Dokumente/Quellen verwenden, statt Angaben zu erfinden oder Daniel wiederholt pauschal zu befragen.
- Historischer Tarif-Snapshot: Bezug `CKW Dynamisch Home`, statischer Fallbackwert 0.246 CHF/kWh, Einspeisung „Preis selber eingeben“ 0.10 CHF/kWh, Peakparameter 5.0. **Nicht als aktuelle Tarifvorgabe neu setzen**, vor Verwendung aktuelle Konfiguration lesen.
## 5. Bilanzierung und SDL: nicht wieder die alte Fehlerquelle einbauen
Der alte Prognosesender verwendete sinngemäss `PV + Netz − virtuelle EV-Batterieleistung`. Weil nur die virtuelle EV-Batterie in der Topologie stand, konnte **SDL als Hausverbrauch gelernt** werden. Zusätzlich hält das bestehende Gateway unter bestimmten Bedingungen gefilterte virtuelle EV-/SDL-Istleistungen fest und schreibt sie erneut. Neuer Variablenzeitstempel allein machte diese Werte nicht zur frischen physikalischen Messung.
**Neue Grundidee, bei passender elektrischer Messgrenze:**
`Verbraucherlast = Netzbezug + PV-Erzeugung − gesamte physische Batterieladung`
`Grundlast = Verbraucherlast − separat geplante flexible Verbraucher`
Grundlast bedeutet hier zeitabhängiger nicht separat geplanter Verbrauch, **keine Konstante**. Nicht separat geplante Boiler/Wallboxen bleiben vorerst darin. Später beim Übergang zu eigenständiger Planung genau einmal herausrechnen.
**SolarEdge-Sonderfall:** PV-Variable 20335 war bereits aus skaliertem AC-Ausgang plus Batterieleistung berechnet und auf null begrenzt. Neuer Lastadapter `solar_terminal_v1` verwendet den AC-Ausgang aus 37975/41853 direkt und zieht den SolarEdge-Batteriesaldo nicht nochmals ab. Herkunft, Vorzeichen, AC/DC und Null-Clipping beachten. Algebraischer Bilanzschluss allein ist kein unabhängiger Zählernachweis.
SDL wird **nicht vom V4-Flexibilitätsoptimierer gesteuert**. Bekannten externen Fahrplan berücksichtigen; bei unbekanntem Verlauf nicht still null einsetzen. Die vorhandene Schattenplanung kann den aktuellen SDL-Auftrag als ausdrücklich gekennzeichnete Fortschreibung verwenden. Das ist kein veröffentlichter Zukunftsfahrplan. Für Dauerproduktion sind belastbare externe Grenzen/Reservebehandlung und Konfliktauflösung noch zu prüfen. Ein aktueller SDL-Wert null garantiert keine SDL-freie kommende halbe Stunde.
Bestehendes Gateway: `/var/lib/symcon/modules/Symcon_Belevo_Energiemanagement_testing/Bat_EV_SDL_V4/module.php`. Aktionsbrücke historisch `scripts/31800.ips.php`. `State=false` war dort **kein nachgewiesener Nullstell-/Notstopp**. EV-Stopp darf nicht pauschal unabhängige SDL-Aufträge löschen. Virtuelle Energiekonten/Filter nicht ungeprüft umstellen.
## 6. Quellzuordnung für die nächste Arbeit
Werte/Identitäten mit aktueller Quellenkonfiguration abgleichen; Tabelle ist zuletzt geprüfte Zuordnung, keine Erlaubnis, Register umzulegen.
| Grösse | Variable | Ursprung/Umrechnung |
|---|---:|---|
| Original-Netzleistung | 40348 | Parent 11490, `Power_8`, kW ×1000 → W, Bezug positiv |
| Netz-Anzeige | 49301 | Abgeleitet; nicht mit Originalquelle verwechseln |
| GoodWe 1 PV | 48459 | Parent 19742, `A_3_3_35301`, W |
| GoodWe 2 PV | 53802 | Parent 57658, `A_3_3_35301`, W |
| GoodWe 1 Batterie physisch | 47725 | Parent 19742, `A_6_3_35182`, Faktor −1 → Laden positiv |
| GoodWe 2 Batterie physisch | 35724 | Parent 57658, `A_6_3_35182`, Faktor −1 |
| SolarEdge Batterie physisch | 21447 | Parent 30789, `Value`, Faktor +1 |
| SolarEdge abgeleitete PV | 20335 | Parent 36915; nicht unabhängige PV-Messung |
| SolarEdge AC-Rohwert | 37975 | Parent 35514, `Value`, ReadAddress 40083 |
| SolarEdge AC-Skalierung | 41853 | Parent 48996, `Value`, ReadAddress 40084 |
| Virtuelle EV-Istleistung | 52020 | Parent 58448, `Aktuelle_Leistung_EV`; Filterhaltewert möglich |
| Virtuelle SDL-Istleistung | 25085 | Parent 58448, `Aktuelle_Leistung_SDL` |
| EV-Auftrag | 19651 | Parent 58448, `Nennleistung_Soll_EV` |
| SDL-Auftrag | 38943 | Parent 58448, `Nennleistung_Soll_SDL` |
| Gateway aktiv | 23483 | Parent 58448, `State`; kein Gerätewatchdog-Nachweis |
| EV-SOC | 32871 | Parent 58448, `SoC_EV` |
| SDL-SOC | 23879 | Parent 58448, `SDL_Pos` |
| EV verfügbare Ladeleistung | 50230 | Parent 58448, `P_EV_laden` |
| EV verfügbare Entladeleistung | 43899 | Parent 58448, `P_EV_entladen` |
| GoodWe 1 / 2 / SolarEdge SOC | 27361 / 23109 / 51938 | Originalzuordnung siehe Capture-Konfiguration |
| Wirkenergie Bezug T1 | 59607 | Parent 11490, `Energy_0`, kWh |
| Wirkenergie Bezug T2 | 26620 | Parent 11490, `Energy_1`; Zählerarchivierung aktiviert |
**Nicht wieder 53476 als nachgewiesenen Bezugszähler verwenden:** Im Gespräch zeigte 53476 / Quelle 32797 (`Energy_6`) einen unplausibel anderen Verlauf gegenüber Netzleistung und T1. Alte Historie 53476 wurde bewusst nicht überschrieben. Archivinstanz 16207. Neuzeitlicher Peak muss aus geeigneter T1/T2-Summe bzw. ausdrücklich zugelassener vollständiger Viertelstundenschätzung stammen.
Poller laut zuletzt gelesenem Snapshot: GoodWe 1 Parent 19742 `60000 ms`, GoodWe 2 Parent 57658 `5000 ms`; SolarEdge Batterie Parent 30789 `2000 ms`, AC Parent 35514 `1000 ms`, Skalierung Parent 48996 `20000 ms`. M-Bus Parent 11490 hatte `Interval=0`, trotzdem laufend frische Originalwerte. Nicht daraus schliessen, die Erfassung sei aus; zusätzliche bestehende Aufrufwege prüfen. Poller nie allein als erfolgreichen Geräteabruf interpretieren.
## 7. Hosts, Code, Dienste und Berechtigungen
### enelix-services / Connector Server_89
- Host `enelix-services`, IPv4 `87.106.60.89`, Agentbenutzer `agent`. Erlaubte Wurzeln `/home/agent/services`, `/srv/agent`.
- Entwicklung: **`/srv/agent/repos/Enelix-EMS`**, Gitea `ENELIX/Enelix-EMS` auf `git.belevo.ch`. Weiteres Projekt `ENELIX/Enelix-Utils`; historische Referenz `dh/Symcon_Belevo_Energiemanagement_testing`. Andere Hosts/Klone nicht ohne Nachweis gleichsetzen.
- Versionierter neuer Servercode: **`services/netplan-v4/`** im EMS-Repo.
- Runtime/Build-Staging: **`/home/agent/services/netplan-v4-shadow`**, SQLite `data/netplan-v4.sqlite`, Compose `compose.yaml`, interner V4-Port 9100.
- Bisheriger Forecast/API-Dienst: `/home/agent/services/prognosis-manager-enelix2`; Forecast Engine interner Port 9000. Nicht durch neue V4-Arbeit unbemerkt ersetzen.
- Portal: `/home/agent/services/license`, `server.mjs`, `public/`, `compose.yaml`. Parallele GUI-Arbeit möglich. Unified-RC1 aktualisierte nur V4 und zugehöriges Planner-JS, nicht pauschal alle Portalprozesse.
- Vor Veränderungen `/srv/agent/AGENT_CONTEXT.md` lesen. Agent hatte zuletzt keinen Docker-Socket-/Root-Zugriff; den Nutzer Root-Ausführung nur für tatsächlich benötigte Deployment-Schritte erledigen lassen. Keine Berechtigungen auf Docker-Socket ausweiten.
### iot-symcon01 / Connector Testanlage_Lihrenmoos
- IP-Symcon 8.0, Dienst `symcon.service`, Kerneldateien `/var/lib/symcon`, Logs `/var/log/symcon`, Programme `/usr/share/symcon`.
- Installiertes EMS-Repo: `/var/lib/symcon/modules/Enelix-EMS`. Das ist produktionsnah laufender Code, nicht der Entwicklungscheckout auf Server_89.
- Staging, Tests, Diagnose: `/srv/agent/netplan-v4-...`.
- Hostregeln `/srv/agent/AGENT_CONTEXT.md` lesen. Keine allgemeinen Settings-/Credential-Exports. Für gezielte schreibgeschützte Zustandsprüfung nur erlaubte Felder extrahieren, niemals ganze secret-bearing Konfiguration ausgeben.
- **PHP mit IPS-Funktionen gehört in Symcon**, nicht in die Linux-Bash und nicht in gewöhnliches CLI-PHP. Isolierte CLI-Tests mit simulierten IPS-Funktionen umgekehrt niemals im echten Kernel ausführen.
- `MC_ReloadModule`/`IPS_ApplyChanges` können bestehende Initialisierungen und deren Nebenwirkungen ausführen. Nicht pauschal als ohne Auswirkungen versprechen. Code- und Settings-Sicherung, Hash-/Driftprüfung, Rücksetzweg nötig.
- Bei verzögerter Modulregistrierung wurde ein Wiederholen angefordert. Die letzte Installation ist inzwischen fertig: **jetzt nicht wiederholen**.
## 8. Installierter Daten-/Prognosepfad
1. Manager erfasst 23 numerische Quellen alle 30 s, mit originalen Zeitstempeln und Qualitätsmerkmalen. Keine zusätzlichen Modbus-Abfragen durch den bisherigen Sammler.
2. Lokale append-only Tagesdateien/Outbox. Versand regulär höchstens einmal/min, bis 120 Aufnahmen pro Batch; geordneter, bestätigter Cursor. Backoff bei Fehlern; Originale bleiben erhalten. `scheduled` kann reguläre Sendepause oder Backoff bedeuten; getrennte letzte erfolgreiche Bestätigung beachten.
3. Server-Datensatz **`lihrenmoos-physical-v1`** speichert Originalaufnahmen. Abgeleiteter Datensatz **`lihrenmoos-physical-published-v2`** referenziert dieselben Daten für die verbesserte historische Zeitbehandlung.
4. `equal_endpoint_v1`: begrenzte rückblickende Schätzung bei gleichen Werten an getrennten Originalzeitpunkten. Verfügbarkeit der späteren Bestätigung wird mitgeführt; keine Leckage in damalige Entscheidungen. Unterschiedliche Werte, lange Ausfälle, widersprüchliche Quellen oder Sammellücken nicht unbemerkt überbrücken. Live-Grenzen unverändert.
5. Worker verarbeitet im 5-Minuten-Takt. Mindestbasis 24 **verwertbare äquivalente Stunden**, nicht bloss seit Start verstrichene Zeit. Neue Tagesprofile, versionierte Modelle, konfigurierbare tägliche/wöchentliche Neubildung. Kaltstart/Validierung sind getrennt.
6. Neue Datenquelle muss als `corrected_profile` mit passendem `measurementDataset` gewählt werden, sobald verwendbar; zuletzt blieb **`forecastSource=legacy`, `family=3`**. Nicht bei Installation automatisch umgeschaltet. `optimal` auf der Legacy-Quelle ist kein Nachweis der neuen Prognosequalität.
7. Wirtschaftlicher Vergleich Unified-RC1 ist angeschlossen, aber v1 ist **eingefrorener Tagesplan + simulierte lokale Netzzielnachführung**, nicht vollständiges rollendes MPC-Replay. Eine netzladefähige Batterie; gleiche Anfangsbedingungen, Restenergie, Verluste und einmalige Monatspeakabrechnung. Historischer SDL-Auftrag als gekennzeichnete Schätzung. Vollständige Modellautomatik/Produktversprechen nicht über diesen Scope hinaus behaupten.
8. Archivierung ist implementiert, **AUS als Standard** (native `NetzfahrplanV4ArchivAktiv`, serverseitig `NETPLAN_V4_ARCHIVE_ENABLED`). Verifizierte komprimierte Archive ersetzen kein externes Backup und lösen keine unendliche Speicherkapazität. Keine unbestätigten Outbox-Daten löschen.
## 9. Behobene Fehler – nicht erneut aufrollen
- Früherer numpy-Fehler `assignment destination is read-only`, falscher Import `battery_optimizer_new`, Dateirechte im unprivilegierten Container: damalige Deployment-/Codefehler, nicht aktuelle Blocker.
- Direkt nach Start `Connection refused`, danach HTTP 409 „bereits ein Lauf aktiv“: Start-/Parallelitätszustand, nicht durch wiederholte Neustarts lösen.
- Native Betriebsdaten fehlten; anschließend Sender installiert. Tarifimporter und Portal-Rate-Limit wurden repariert. Alte 429-Meldungen nicht ohne neue Evidenz zur heutigen Ursache erklären.
- Klasse `NetzfahrplanV4Messaufnahme` doppelt aus Staging und Modulpfad geladen: Installer auf eindeutige installierte Bibliothek umgestellt. `require_once` verhindert nicht verschiedene Dateien mit derselben Klasse.
- Gemischter 120er-Block: 45 importierte/75 native Aufnahmen, keine doppelten/rückwärtslaufenden Zeiten, aber andere Kennung allein durch `splitToleranceW:100.0` vs `100`. Normalisierung korrigiert; beide vollständigen Konfigurationen exakt geprüft. Kompatibilität nur für diese anlagen- und datensatzgebundene Gleichwertigkeit registriert; Ursprungskennung/Evidence gespeichert. Keine Hashs in Originaljournalen ersetzt, kein Cursor-Sprung.
- **Nachweis der Reparatur:** am 03.10. 12:58 Schweizer Zeit 1'782 statt 1'320 Aufnahmen, jüngste Aufnahme 12:58:09, 25 s alt, vollständiger ehemals blockierter Batch vorhanden, Manager `acknowledged`/Fehlerzahl 0. Quelle `[F]` unten. Keine Garantie über den danach liegenden Zeitraum ableiten.
- Historische Datenqualität verbesserte sich von 0 brauchbaren Fenstern auf 104/8.66 h, nach Nachversand 130/10.82 h; zuletzt Worker 140/11.65 h. Prozentangaben zur zeitlichen Abdeckung sind **keine Messgenauigkeit und keine Prognosegüte**.
## 10. Installierter Rückmelde- und begrenzter Stellpfad
Gemeinsamer Code: `NetzfahrplanV4Rueckmeldung.php`, `BatterieNetzfahrplanV4RueckmeldungTrait.php`, `ManagerNetzfahrplanV4EmpfangTrait.php`, `ManagerNetzfahrplanV4TestTrait.php`, `BatterieNetzfahrplanV4TestTrait.php`, `NetzfahrplanV4Regeltest.php`, Manager-/Batterie-Module.
- Zweifaches Lesen mit Identitäts- und Originalzeitprüfung; momentan Live-Alter maximal 60 s, Quellenversatz maximal 30 s.
- Modus in Lihrenmoos **`virtual_split`**, **`allowEstimatedForTrial=false`**. Auch gültige Rückmeldung oder Vorschau erteilt keine Freigabe.
- Wenn SDL-Auftrag exakt null, physische Batteriesumme statt gefilterter virtueller EV-Anzeige verwenden; unveränderter Nullauftrag ist Zustand, kein Messgeräte-Heartbeat. Eine neue EV-Vorgabe bedeutet noch keine sofortige physische Reaktion.
- Bei SDL ungleich null: zeitliche Abdeckung von Auftragsänderung, Tracking-Toleranz und explizite Schätzkennzeichnung. Gegenläufige EV-/SDL-Flüsse sind nicht eindeutig identifizierbar und bleiben für diesen Test gesperrt.
- Netzziel wird in **Gesamt-Batterieleistung**, nicht zusätzlichen Ladeauftrag übersetzt. Erwartete Änderungen anderer Verbraucher genau einmal berücksichtigen.
- Separate interne Server-Testsitzung, leere Default-Allowlist `NETPLAN_V4_CONTROL_TRIAL_PLANTS`, manuelle Familie, Anlagen-/Asset-/Instanz-/Revisionsbindung, beide lokalen Freigaben `NetzfahrplanV4RegeltestErlaubt`, bewusster lokaler Start, gültiger Plan, Bilanz- und Gerätewatchdog-Nachweis.
- Test max. 30 min / 5 kW je Richtung; Serverauthority maximal 90 s; Batteriebefehl maximal 10 s; Batterie-Softwarewatchdog 1 s, Manager-Testtakt 2 s. Nicht einfach Limits für Produktion hochsetzen.
- Batterie liest vor Ausgabe unabhängig neu und prüft SOC, Reserve, Hysterese, Verfügbarkeit, Sperrzeiten und Netzgrenzen. Unmögliches Netzziel → Testabbruch, keine Erfolgsbehauptung.
- Abbruch widerruft Sitzung vor Nullstellversuch; Stoppfehler bleibt sichtbar. Danach maximal eine **frisch berechnete** normale Zuteilung, keine Rückkehr zu gecachtem Test-/Vor-Testwert. Keine Wiederbelebung nach Neustart oder doppeltem Befehl.
- `shadow_seen` bedeutet Empfang, **nicht ausgeführt**. `canDispatch=false` in Vorschau bedeutet keine Vorschau-Freigabe. Bei einer separat gestarteten Testsitzung wäre `runMode=shadow` alleine trotzdem kein hinreichender Nachweis für null Stellbefehle.
- Softwarewatchdog funktioniert nur bei laufendem Kernel/Kommunikation. Kein Nachweis für Rechnerabsturz, Stromausfall oder unerreichbaren Wechselrichter. Bereits vorhandenes Verhalten prüfen, nicht erfinden und nicht Schutzgates entfernen.
## 11. Aktueller Blocker: bestätigter Geräteabruf statt falscher Zeitbasis
Letzter Installationsbericht `[A]`:
```
status: corrected_feedback_installed_trial_disabled
feedback.status: unavailable
feedback.reason: feedback_source_skew
batteryW: null
gridW: null
usableForTrial: false
canDispatch: false
```
Das ist **kein Installationsfehler**. Die Meldung „Modellanteil: nein“ im Installer ist im Fehlerfall irreführend: fehlendes Feld wird als false dargestellt, obwohl die Berechnung gar nicht erfolgreich war. Kleine UI-/Diagnosekorrektur mitnehmen.
Letzte Zeituntersuchung `[B]` (237 Aufnahmen / 2 h): 48 Aufnahmen mit Versatz >30 s. Netz median 1 s/max 2 s, GoodWe 1 median 1 s/max 48 s, GoodWe 2 median 1 s/max 46 s, SolarEdge median 12 s/max 59 s; SolarEdge in 223 Aufnahmen unter den ältesten Quellen. Keine der vier Quellen in genau diesem Zeitfenster über 60 s. Beispiel 13:58:38: drei Quellen neu, SolarEdge 31 s alt → Ablehnung. Frühere mehrminütige Lücken trotzdem nicht damit wegargumentieren.
**Nächste Umsetzung, ohne neue Diagnosekategorie:**
1. Installierten Gerätemodultyp und unterstützten Leseweg verifizieren. Im Gespräch wurde `ModBus_RequestRead()` als möglicher Ansatz genannt. API-/Erfolgs-/Zeitsemantik und Unterschied `ModBus Address` vs `ModBus Device` anhand offizieller aktueller Dokumentation und vorhandenem Code prüfen; nicht voraussetzen, dass ein boolescher Erfolg alle Register atomar aktualisiert oder einen unabhängigen Gerätezeitpunkt liefert.
2. Erfolgreiche vollständige Antwort mit genau den daraus stammenden Werten, Zuordnung, Status und Bestätigungszeit binden. `VariableUpdated`, Geräteabrufbestätigung und Sammlerzeit getrennt erhalten.
3. Begrenzte Abfragefrequenz, Timeout, Serialisierung, Kommunikationslast und Lesekohärenz berücksichtigen. Keine Netz-I/O in jedem Vorschau-/Regelcallback vervielfachen, keine blockierenden Abfragen unter dem Stellregler-Lock. Existierende erfolgreiche Polls nach Möglichkeit nutzen.
4. Wenn keine echte Bestätigung vorliegt, unsicher/gesperrt bleiben. Keine künstlichen `SetValue`-Herzschläge und keine permissive Freigabe bloss wegen des Pollerintervalls.
5. In bestehenden Batterie-/Managerpfad integrieren; Tests: unveränderte Werte mit erfolgreichem Abruf, fehlgeschlagene/partielle Antwort, Zeitversatz, Race, Wiederholung, Neustart, erneute unabhängige Batterieprüfung und Rückfall.
6. Zusammenhängenden Kandidaten bauen und testen; neue Runtime-Installation nur im bekannten gesicherten Verfahren. Bisherige Installer nicht nochmals ausführen.
Diese Arbeit wurde am Gesprächsende **noch nicht implementiert oder installiert**. Sie ist nicht durch die vorliegende Dokumentationsübergabe erledigt.
## 12. Produktionsreife: verbleibende echte Arbeiten
- Verlässlicher bestätigter Live-Messpfad wie oben; unklares AC/DC/externes SDL nicht durch Labels als verifiziert deklarieren.
- Neue Lastprofile mit tatsächlicher ausreichender Datenbasis/Validierung aufbauen und gezielt zur Schattenrechnung schalten. Aktuelle Einstellungen/Modelle vorher lesen, nicht alte Momentaufnahmen fortschreiben.
- **Dauerbetrieb** mit sauberem Authority-/Zustands-/Neustart-/Fallback-Verhalten implementieren. Bisher nur begrenzter Pilot, keine fertige kontinuierliche Produktionsfreigabe.
- Reale Callback-/Registerstrecke und separat freigegebener Stellversuch; korrekte Vorzeichen, verfügbare Leistungen, Netzziel/Peak-/Exportgrenzen, Abschalten/Verbindungsausfall, lokale Priorität und SDL-Konflikte prüfen.
- Modellautomatik über den deklarierten Frozen-Day-v1-Scope hinaus nur nach Erweiterung/Nachweis bewerben; Grenzen transparent halten.
- Lebenszyklus/Backups/Recovery/Observability, Mehranlagenisolierung und parametrisierte Inbetriebnahme. Einige Installer sind bewusst auf Lihrenmoos gebunden; für andere Anlagen nicht IDs blind übernehmen.
- Git-Authentifizierung/Veröffentlichung lösen; `develop` → geprüfte `beta` → Feldabnahme `main`. Browser-Login, Tailscale oder Portforwarding stellen nicht automatisch Git-Schreibauthentifizierung bereit. Keine Veröffentlichung behaupten, die nicht passiert ist.
## 13. Wichtige Meilensteine und nicht erneut auszuführende Installer
| Paket | Status bis Gesprächsende |
|---|---|
| V4-Schattenserver / Native Operation / Tarife | Installiert, Betrieb historisch belegt |
| Passiver Empfänger `/srv/agent/netplan-v4-receiver-stage/install.php` | Installiert, automatische `shadow_seen`-ACKs belegt |
| Rohsammler `/srv/agent/netplan-v4-data-capture-stage` | Separater früherer Sammler; Originaldateien erhalten |
| Leseexport `/srv/agent/netplan-v4-data-review-stage` | Einmaliger Export wegen restriktiver Originalrechte; heute keine erneute allgemeine Exportpflicht |
| Getrennter Beobachter `/srv/agent/netplan-v4-separated-observer-stage` | 23 Quellen, Dateien gezielt lesbar; weiterhin Originalbestand |
| Managerdaten `/srv/agent/netplan-v4-application-build/install.php` | Installiert, Klasse-/Kennungsfehler später behoben |
| `deploy_history_timing.py` | Installiert, neue Historienversion v2 |
| `deploy_unified_release.py --approve-numeric-mapping-compatibility` + `/srv/agent/netplan-v4-unified-release/install.php` | Installiert, Aliasfreigabe exakt registriert, Nachversand aufgeholt |
| `/srv/agent/netplan-v4-feedback-stage/install.php` | **Fertig installiert nach einmaliger Registrierungsschleife**, aktuell Zeitversatzprüfung |
Fachliche Commitfolge (lokales `develop`; nicht als Remote-/Runtime-Gleichstand missverstehen):
`b5c1fa6` Empfänger, `9394fe3` begrenzter Test, `e0e52d2` Bilanzierung, `76f8b06` Capture, `44362e4` getrennte Beobachtung, `fe3f46b` Datenanwendung, `6d9aba7` Klassenladen, `5890093` Historienzeit, `6c91d6b` Zahlennormalisierung, `8eb9688` Unified RC1, **`9878197` korrigierte Rückmelde-/Testkette**. Einige weitere Commits können dazwischenliegen; `git log` ist massgeblich.
## 14. Quellenindex und Tests – zuerst Nachweise lesen, nicht neu installieren
**[A] Letzte erfolgreiche Rückmeldeinstallation (Testhost):**
`/srv/agent/netplan-v4-feedback-stage/INSTALL_RESULT.json` – Ende `2026-10-03T11:59:58Z`. Der frühere `waiting_for_registration`-Bericht ist überholt. Die Sicherung stammt vom ersten Dateidurchlauf, nicht vom zweiten `already_installed`-Durchlauf.
**[B] Letzte Zeit-/Pipeline-Auswertung (Server):**
`/home/agent/services/qa/feedback-timing-20261003/TIMING_REVIEW.json` – `2026-10-03T12:02:13Z`, 140 brauchbare Fenster/11.646 h, Quelle v2 noch `collecting`.
**[C] Unified-Serverinstallation:**
`/home/agent/services/netplan-v4-shadow/unified-releases/20261003T105407.036202Z/REPORT.json`; Runtime `unified-rc1`, Mappingkompatibilität registriert, Einstellungen unverändert.
**[D] Unified-Managerinstallation (Testhost):**
`/srv/agent/netplan-v4-unified-release/INSTALL_RESULT.json`.
**[E] Historieninstallation:**
`/home/agent/services/netplan-v4-shadow/history-timing-releases/20261003T092245.175100Z/REPORT.json`.
**[F] Erfolgreiches Aufholen des Datenwegs:**
`/home/agent/services/qa/unified-release-20261003/FLOW-20261003T105834.324945Z.json`.
**[G] Installiertes und versioniertes Verhalten:**
`docs/netplan-v4-corrected-feedback.md`, `docs/netplan-v4-unified-release.md`, `docs/netplan-v4-history-timing.md`, `docs/netplan-v4-numeric-mapping-fix.md`, `docs/netplan-v4-application.md`, `docs/netplan-v4-controlled-trial.md`, `docs/netplan-v4-accounting.md`. Alte „noch nicht installiert“-Abschnitte dieser Entwicklungsdokumente durch aktuelle Installationsberichte einordnen; nicht mit echten Runtime-Nachweisen verwechseln.
**[H] Rückmelde-/Testsuite (Testhost):**
`/srv/agent/netplan-v4-feedback-stage/PREPARATION.json`, `TEST_RESULTS.txt`, `INSTALLER_TEST_RESULTS.json`, `ORDINARY_OFFER_TEST.json`, `MANIFEST.json`, `PACKAGE_HASHES.json`, `feedback-config.json`.
125 isolierte Funktionsprüfungen, 4 vollständige Modul-Linkageprüfungen, 10 Installationsszenarien, 13'824 übereinstimmende normale Leistungsangebotsfälle. Keine Aussage, dass dieselbe Zahl Tests im echten Kernel/Wechselrichter gelaufen sei.
**[I] Unified-Suite (Server):**
`/home/agent/services/qa/unified-release-20261003/PYTHON_TEST_RESULTS.txt`, `NODE_TEST_RESULTS.txt`, `PUBLICATION.json`, `DELIVERY_STATUS.json`.
306 Python- und 16 Portaltests; native separate Tests. Spätere Änderungen brauchen erneute relevante Tests, nicht bloss Übernahme dieser Zahlen.
**[J] Wiederaufbau und Code:**
EMS `examples/V4CorrectedFeedback/`, `examples/V4UnifiedRelease/`, `services/netplan-v4/commissioning/`. Staging ist nicht dieselbe Quelle wie das laufende Modul. Dateien, Hashs und Abhängigkeiten vor Änderungen vergleichen; Paralleländerungen abbrechen statt überschreiben.
**[K] Quellen-/Konfigurationsdiagnose (Testhost):**
`/srv/agent/netplan-v4-application-build/inspect_source_update_policy.py`, `inspect_delivery_state.py`, `MAPPING_IDENTITY_DIAGNOSIS.json`, `MAPPING_EQUIVALENCE.json`; `netplan-v4-feedback-stage/review_current_feedback_state.py`. Diese Skripte zuerst lesen; manche erzeugen nur einen Bericht, manche lesen gespeicherte statt live aktuelle Settings. Alte Settings-Snapshots können eine bereits installierte Eigenschaft noch nicht enthalten.
**Sicherheitsnotiz:** In einem alten Upload-Skript wurden Klartextzugangsdaten bemerkt. Nicht lesen, verbreiten, in Dokumentation/Git übernehmen oder für neue Integration verwenden. Separate autorisierte Rotation/Secret-Auslagerung bleibt Aufgabe; die Werte gehören nicht in diese Übergabe.
## 15. Übernahmeroutine für einen neuen Agenten
1. Host-`AGENT_CONTEXT.md`, Repo-`AGENTS.md` und diese Datei vollständig lesen; bei fehlendem Remote-Stand zuerst den Servercheckout prüfen.
2. `git status`/Branch/letzte Commits sowie `[A]`, `[B]`, `[C]` lesen. Zeitstempel nennen; keine alten Zähler als heutige Werte ausgeben. Diese Übernahme selbst ändert keine Anlage.
3. Nur notwendige gezielte Read-only-Prüfungen. Kein weiterer vollständiger Neustart der Analyse oder Wiederholung bereits erledigter Installer.
4. Bei Fortsetzungsauftrag direkt Abschnitt 11 umsetzen und anschliessend echte Restarbeiten aus Abschnitt 12 abarbeiten. Anforderungen, Testfall, Implementierung und Rollout in einem Paket halten.
5. Nach jedem Abschluss diese Übergabe mit Datum, Codecommit, tatsächlichem Installations-/Teststatus und nächstem konkreten Arbeitsschritt ergänzen. Keine gescheiterten Schritte in „fertig“ umdeuten. Bei Widerspruch aktuelle geprüfte Laufzeit und ausdrückliche spätere Nutzerkorrektur nennen.
**Kurzübernahme:** „Netzfahrplan V4 in ENELIX fortsetzen. Datenkennung/Versand repariert, Unified RC1 und korrigierter Feedback-/Testpfad installiert. Aktuell `feedback_source_skew`; bestätigte Geräte-Lesezeitpunkte in die bestehende lokale Rückmeldung integrieren. EV 161.44 kWh/39 kW. Keine neue Diagnosekategorie, keine alten Installer, kein automatischer Stelltest. Dauerproduktion ist noch nicht fertig; Tests/Abnahme/Veröffentlichung sauber getrennt halten.“
+47 -21
View File
@@ -1,34 +1,60 @@
# Obere Anschlüsse des Managers # Obere Anschlüsse des Managers
> Status: Lizenzierung implementiert; die übrigen Anbieterformate sind noch > Status: Lizenzierung, Prognose und Stoerueberwachung sind an
> nicht festgelegt. > `license.enelix.ch` angebunden. SDL/VGT bleibt als optionaler Anschluss
> vorbereitet.
Prognose, SDL/VGT und Stoerueberwachung sind optional. Die Lizenzierung ist fuer Die Anschluesse werden ausschliesslich im Manager konfiguriert. Sie erzeugen
den Betrieb des Managers erforderlich. Sie werden im Manager konfiguriert und erzeugen keine Abhaengigkeit zwischen Enelix EMS und Enelix Utils.
keine Abhängigkeit zwischen Enelix EMS und Enelix Utils.
| Anschluss | Aufgabe | Gegenstelle | | Anschluss | Aufgabe | Gegenstelle |
| --- | --- | --- | | --- | --- | --- |
| SDL/VGT | Zeitlich gültige Leistungsaufträge und Rückmeldungen | Optional das unabhängige Utils-Modul VGT-Schnittstelle | | SDL/VGT | Zeitlich gueltige Leistungsauftraege und Rueckmeldungen | Optional das unabhaengige Utils-Modul VGT-Schnittstelle |
| Prognose / Forecast | Vorhersage für Produktion und bei Bedarf Verbrauch | Externer Anbieter; kein Repo-Modul | | Prognose / Forecast | Topologie, Telemetrie und Netzfahrplan | `license.enelix.ch/api/v1/installations/{id}/prognosis/*` |
| Lizenzierung | Pruefung freigeschalteter Manager-Funktionen | `POST https://license.enelix.ch/api/v1/licenses/activate` | | Lizenzierung | Pruefung freigeschalteter Manager-Funktionen | `POST https://license.enelix.ch/api/v1/licenses/activate` |
| Störüberwachung | Weitergabe von Zuständen und Störungen | Externer Empfänger; kein Repo-Modul | | Stoerueberwachung | Vollsnapshot aktiver Manager- und Geraetestoerungen | `PUT https://license.enelix.ch/api/v1/installations/{id}/faults` |
## Gemeinsame Grundsätze ## Stoerungsdatenfluss
- Die Auswahl der Gegenstelle erfolgt ausschliesslich im Manager. 1. Jedes Geraetemodul meldet seinen Zustand ueber den bestehenden
- Anbieterformate werden an diesem Anschluss übersetzt und gelangen nicht in Verbraucher-Nachrichtenvertrag an den Manager.
2. Der Manager sammelt aktive Eintraege mit `Art = Stoerung`, fuegt eigene
Regelungsstoerungen hinzu und normalisiert sie.
3. Der Manager speichert lokal einen deterministisch sortierten Vollsnapshot.
4. Bei Zustandsaenderung oder spaetestens im konfigurierten Heartbeat-Intervall
sendet er den Snapshot mit dem vorhandenen Geraete-Bearer-Token.
5. Ein leerer Snapshot loest zuvor aktive Stoerungen im Portal auf.
Jede Meldung besteht aus `sourceType`, `sourceId`, `sourceName`, `code`,
`severity` und `message`. Die stabile Kombination aus `sourceId` und
`code` identifiziert ein Ereignis ueber mehrere Snapshots hinweg.
Fehler der externen Uebertragung blockieren die lokale EMS-Regelung nicht. Der
Manager wiederholt mit exponentiellem Abstand zwischen 30 und 900 Sekunden.
Nach HTTP 401 oder 403 verwirft er den Geraetezugang und fordert ihn bei der
naechsten Lizenzaktivierung neu an.
## Lizenzvorbereitung
Die Stoerueberwachung ist technisch als separat schaltbare Manager-Funktion
gekapselt. Das Portal kann spaeter eine Berechtigung mit dem Katalogschluessel
`fault_monitoring`, einer Laufzeit von einem Jahr und einem erneuerten
`validUntil` ausliefern. In der ersten Ausbaustufe wird diese zusaetzliche
Jahresberechtigung noch nicht erzwungen; Voraussetzung bleibt eine gueltige
Managerlizenz samt Geraetezugang.
## Gemeinsame Grundsaetze
- Anbieterformate werden am Manageranschluss uebersetzt und gelangen nicht in
die Verbraucher-Schnittstelle. die Verbraucher-Schnittstelle.
- Jeder Anschluss meldet im Manager `NichtVerwendet`, `Verbunden` oder `Fehler`. - Jeder Anschluss meldet `NichtVerwendet`, `Wartet`, `Verbunden` oder
- Fehlende optionale Anschlüsse dürfen die lokale EMS-Grundfunktion nicht `Fehler`.
- Fehlende optionale Anschluesse duerfen die lokale EMS-Grundfunktion nicht
blockieren. blockieren.
- Eine fehlende oder abgelaufene Lizenz blockiert die Managerregelung. Eine - Zugangstoken werden weder als Property noch in Diagnosevariablen oder Logs
bestaetigte Entwicklungs-Lease bleibt bei Verbindungsunterbruch bis ausgegeben.
`offlineUntil` gueltig.
## Noch festzulegen ## Noch festzulegen
- Anbieter und Datenformat für Prognose und Störüberwachung - Aufbau, Gueltigkeitszeitraum und Rueckmeldung eines SDL-Auftrags
- Aufbau, Gültigkeitszeitraum und Rückmeldung eines SDL-Auftrags - Produkt- und Zahlungsmodell der jaehrlichen Stoerueberwachungslizenz
- Authentisierung der noch optionalen Anschlüsse - Verhalten nach Ablauf einer spaeter aktivierten Stoerueberwachungslizenz
- Ablösung der Entwicklungs-Lease durch den signierten produktiven Lizenzvertrag
+24 -27
View File
@@ -47,25 +47,13 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
| ID | Status | Bereich | Kurzthema | | ID | Status | Bereich | Kurzthema |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| OP-001 | Entschieden | Lizenzierung | Betriebsort des Lizenzservers | | OP-001 | Entschieden | Lizenzierung | Betriebsort des Lizenzservers |
| OP-002 | Offen | Grundeinrichtung | Generator und kostenpflichtige Ersteinrichtung | | OP-002 | Erledigt | Grundeinrichtung | Generator und kostenpflichtige Ersteinrichtung |
| OP-003 | Offen | Lizenzportal | VGT-Integration in license.enelix.ch | | OP-003 | Offen | Lizenzportal | VGT-Integration in license.enelix.ch |
| OP-004 | Offen | Lizenzierung | Lizenzmodell des Verbrauchskostenreports | | OP-004 | Entschieden | Lizenzierung | Lizenzmodell des Verbrauchskostenreports |
| OP-005 | Offen | Teststrategie | Standort fuer Feldtests | | OP-005 | Offen | Teststrategie | Standort fuer Feldtests |
## Offene Punkte ## Offene Punkte
### OP-002: Generator und kostenpflichtige Ersteinrichtung
- **Entscheidungsfrage:** Soll der automatische Generator fuer die
Grundeinrichtung zugleich die kostenpflichtige Ersteinrichtung abbilden?
- **Kontext:** Der vorgesehene automatische Generator fuer die Grundeinrichtung
muss gegenueber der kostenpflichtigen Ersteinrichtung abgegrenzt werden.
- **Auswirkung:** Die Entscheidung legt fest, ob beide als derselbe Vorgang oder
als getrennte Leistungen behandelt werden.
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
### OP-003: VGT-Integration in license.enelix.ch ### OP-003: VGT-Integration in license.enelix.ch
- **Entscheidungsfrage:** Soll die VGT-Anwendung als Bestandteil in die - **Entscheidungsfrage:** Soll die VGT-Anwendung als Bestandteil in die
@@ -78,19 +66,6 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
- **Ziel-Sprint:** Offen - **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen - **Ergebnis/Verweis:** Offen
### OP-004: Lizenzmodell des Verbrauchskostenreports
- **Entscheidungsfrage:** Soll der Verbrauchskostenreport nach dem Lizenzmodell
des Managers oder mit einer einzigen, von der Anzahl angeschlossener Zaehler
unabhaengigen Modullizenz angeboten werden?
- **Kontext:** Fuer den Verbrauchskostenreport muss zwischen dem Lizenzmodell des
Managers und einer zaehlerunabhaengigen Modullizenz entschieden werden.
- **Auswirkung:** Die Entscheidung legt fest, ob die Anzahl angeschlossener
Zaehler den Lizenzumfang des Verbrauchskostenreports beeinflusst.
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
### OP-005: Standort fuer Feldtests ### OP-005: Standort fuer Feldtests
- **Entscheidungsfrage:** An welchem Standort koennen die Module aus Enelix EMS - **Entscheidungsfrage:** An welchem Standort koennen die Module aus Enelix EMS
@@ -113,6 +88,28 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
- **Ergebnis/Verweis:** [Obere Anschluesse](Obere-Anschluesse.md) und - **Ergebnis/Verweis:** [Obere Anschluesse](Obere-Anschluesse.md) und
[Manager](module/Manager/README.md) [Manager](module/Manager/README.md)
### OP-002: Generator und kostenpflichtige Ersteinrichtung
- **Entscheidung:** Der automatische Systemgenerator und die kostenpflichtige
Ersteinrichtung sind derselbe Vorgang.
- **Abrechnung:** Eine Bestellung aus dem Systemkonfigurator enthaelt
automatisch die noch nicht bezahlten Einrichtungskosten fuer den Manager und
die konfigurierten Verbrauchermodule. Bereits bezahlte Einrichtungsmengen
werden je Anlage angerechnet. Direkte Lizenzbestellungen enthalten keine
Einrichtungskosten.
- **Status:** Erledigt
- **Ergebnis/Verweis:** Umsetzung im Lizenzportal unter
`https://license.enelix.ch`.
### OP-004: Lizenzmodell des Verbrauchskostenreports
- **Entscheidung:** Der Verbrauchskostenreport verwendet eine Grundlizenz sowie
getrennte Kontingente fuer Stromzaehler und Nebenzaehler. Die Anzahl der
konfigurierten Zaehler wird bei der Lizenzpruefung beruecksichtigt.
- **Status:** Entschieden und implementiert
- **Ergebnis/Verweis:** Enelix Utils, Commit `1f945be` und
`Verbrauchskostenreport/module.php`.
## Vorlage fuer neue Punkte ## Vorlage fuer neue Punkte
```markdown ```markdown
+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.
+50 -2
View File
@@ -41,6 +41,29 @@ berechnet sein Leistungsangebot neu und meldet es zurueck. Eine vorhandene
Sollleistung wird dabei nur verworfen, wenn sie im neuen Angebot nicht mehr Sollleistung wird dabei nur verworfen, wenn sie im neuen Angebot nicht mehr
zulaessig ist. zulaessig ist.
### Verworfene Sollwerte und Anzeige
Beim Verwerfen einer gueltigen Vorgabe setzen alle Verbrauchermodule sowohl
`SollwertGueltig=false` als auch den gespeicherten und gegebenenfalls sichtbaren
Wert `Sollleistung=0`. An den Manager geht weiterhin `Sollleistung_W=null`;
die Anzeige 0 W ist keine neue gueltige Manager-Vorgabe und kein Nachweis einer
physisch ausgeschalteten Last.
Lokale Schutzprogramme, Mindestlaufzeiten und bestehende Lade-Uebergaenge
bleiben unveraendert. Berechnet ein Modul danach eine lokale Sollleistung,
darf es diese weiterhin anzeigen. Bei Ladestationen bleibt waehrend eines
Regeluebergangs insbesondere der letzte Geraetebefehl erhalten, auch wenn die
alte Manager-Vorgabe bereits verworfen und ihre Anzeige auf 0 gesetzt wurde.
Wiederholte Verwerfungen einer bereits ungueltigen Manager-Vorgabe lassen eine
inzwischen neu berechnete lokale Schutzleistung unveraendert.
Nach einem Modulupdate bereinigt `ApplyChanges` bereits gespeicherte ungueltige
Altwerte. Gueltige Vorgaben einschliesslich 0 W und negativer Batterieleistung
bleiben erhalten. Keine neuen Properties, Variablen-IDs oder Vertragsversion;
es ist keine manuelle Konfigurationsmigration erforderlich. Das Update ist
kontrolliert anzuwenden, da die regulaere Instanzinitialisierung wie bisher
Geraeteaktionen ausloesen kann.
## Verbraucher an Manager ## Verbraucher an Manager
```json ```json
@@ -78,8 +101,11 @@ zulaessig ist.
`Sollleistung_W=null`. `Sollleistung_W=null`.
4. Jeder Verbraucher berechnet und meldet seine PowerSteps fuer diese 4. Jeder Verbraucher berechnet und meldet seine PowerSteps fuer diese
Betriebsart. Betriebsart.
5. Erst wenn alle aktiven Verbraucher synchronisiert sind, verteilt der Manager 5. Der Manager verteilt Sollleistungen an alle bereits synchronisierten
Sollleistungen. Verbraucher.
6. Fehlende, veraltete oder noch nicht umgeschaltete Verbraucher werden nicht
angesteuert und als Stoerung ausgewiesen; sie blockieren die aktuellen
Verbraucher nicht.
Damit kann jeder Verbrauchertyp unterschiedliche Angebote fuer PV und Peak Damit kann jeder Verbrauchertyp unterschiedliche Angebote fuer PV und Peak
melden, ohne dass der Manager seine interne Geraetelogik kennen muss. melden, ohne dass der Manager seine interne Geraetelogik kennen muss.
@@ -87,6 +113,12 @@ melden, ohne dass der Manager seine interne Geraetelogik kennen muss.
## Feste Regeln ## Feste Regeln
- Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet hoehere Prioritaet. - Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet hoehere Prioritaet.
- Jeder verfuegbare, synchronisierte Verbraucher mit einem nicht leeren
`Leistungswerte_W`-Angebot erhaelt einen Sollwert aus genau diesem Angebot.
- `AenderungMoeglich=false` kennzeichnet ein fixes Angebot und ist kein
Ausschlussgrund. Ein Angebot `[11000]` fuehrt deshalb zwingend zu `11000 W`.
- Prioritaeten verteilen nur die ueber den jeweiligen Mindestwert hinaus
verfuegbare Leistung; sie duerfen keinen angebotenen Mindestwert verdrängen.
- `Leistungsquelle`: 0 nicht vorhanden, 1 berechnet, 2 gemessen. - `Leistungsquelle`: 0 nicht vorhanden, 1 berechnet, 2 gemessen.
- Bei Leistungsquelle 0 ist `Istleistung_W` zwingend `null`. - Bei Leistungsquelle 0 ist `Istleistung_W` zwingend `null`.
- Leistungsbereiche enthalten jeden ganzen Wattwert von `Von_W` bis `Bis_W`. - Leistungsbereiche enthalten jeden ganzen Wattwert von `Von_W` bis `Bis_W`.
@@ -132,6 +164,22 @@ Symcon-Datenpunkte registriert `VerbraucherBasisTrait`.
`Leistungswerte_W`, `Betriebsart` und `Zustand` werden intern gehalten `Leistungswerte_W`, `Betriebsart` und `Zustand` werden intern gehalten
und direkt in die Nachricht geschrieben. und direkt in die Nachricht geschrieben.
## Batteriespezifische Erweiterung
Die Batterie verwendet denselben Vertrag 4.0 und ergaenzt Zustandseintraege
fuer Ladezustand, Hysterese, Steuerungsmodus, Messwertfehler und
Registerfehler. Positive Leistung bedeutet Laden, negative Leistung
Entladen. Die vollstaendige Semantik und Registeranbindung beschreibt die
[Schnittstelle Batterie](Schnittstelle-Batterie.md).
## Easee-Gateway-Transport
Der technische JSON-Vertrag zwischen `EaseeGateway` und
`LadestationGateway` ist getrennt vom fachlichen Managervertrag dokumentiert:
[Easee-Gateway-Schnittstelle](Schnittstelle-Easee-Gateway.md). Die
Ladestation uebersetzt Gateway-Ereignisse in den hier beschriebenen
Verbrauchervertrag `4.0`.
## Zeitverhalten ## Zeitverhalten
- Rueckmeldung nach Start, relevanten Aenderungen und alle `Meldeintervall` - Rueckmeldung nach Start, relevanten Aenderungen und alle `Meldeintervall`
@@ -0,0 +1,76 @@
# ADR 0005: Anlagentopologie als Manager-Stammdaten
## Kontext
Die Prognose benoetigt technische Stammdaten zu PV-Flaechen,
Wechselrichtern und Batteriespeichern. Diese Daten beschreiben die reale
Installation und werden auch von der lokalen Regelung benoetigt. Eine
unabhaengige Pflege im Prognoseportal wuerde zwei konkurrierende Wahrheiten
erzeugen.
Hybridwechselrichter benoetigen eine ausdrueckliche Topologie. PV und Batterie
duerfen nicht als zwei unabhaengige AC-Quellen mit jeweils voller
Wechselrichterleistung behandelt werden.
## Entscheidung
Der Enelix-Manager ist die fuehrende Quelle fuer die technische
Anlagentopologie. Er speichert drei getrennte Listen:
- Wechselrichter mit Typ, AC-Nennleistung und optionalen AC-Grenzen,
- PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichterbezug,
- Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und
Wechselrichterbezug.
Jede Komponente besitzt eine innerhalb ihrer Liste eindeutige, stabile ID.
PV-Flaechen und Batterien referenzieren einen Wechselrichter ueber diese ID.
Die AC-Nennleistung gehoert zum Wechselrichter. Die DC-Leistung gehoert zur
PV-Flaeche. Modulanzahl und Modulleistung sind optionale Detailangaben und
muessen gemeinsam gepflegt werden.
Bei einem Hybridwechselrichter bleiben PV-Flaechen und Batterie getrennte
Komponenten. Der Exportvertrag liefert zusaetzlich eine gemeinsame AC-Grenze,
die beide Seiten demselben Wechselrichter zuordnet. Damit darf ihre kombinierte
AC-Leistung die Wechselrichtergrenze nicht unabhaengig mehrfach ausschoepfen.
Der Manager exportiert die normalisierte Struktur mit Vertragsversion '1.0'
und seiner stabilen Lizenz-Installations-ID. Eine aggregierte Zusammenfassung
erleichtert die schrittweise Anbindung bestehender Prognoseberechnungen.
Tarife, Prognosevarianten und rein prognosespezifische Annahmen bleiben im
Prognoseportal. Dort werden die vom Manager gelieferten technischen Stammdaten
standardmaessig nur angezeigt.
## Alternativen
### Vollstaendige Pflege im Prognoseportal
Diese Variante waere fuer den Prognosedienst einfach, erzeugt aber
Doppelpflege und kann von der lokal tatsaechlich installierten Anlage
abweichen.
### Batterie und PV als ein Hybridobjekt speichern
Ein einzelnes Objekt waere kompakt, bildet mehrere PV-Flaechen, MPPT-Eingaenge
und spaetere Erweiterungen jedoch schlecht ab. Ausserdem gingen die getrennten
Kapazitaets- und Leistungsgrenzen verloren.
### AC- und DC-Leistung an jeder PV-Flaeche speichern
Das wuerde bei mehreren Flaechen an einem Wechselrichter die AC-Leistung
mehrfach zaehlen. Deshalb wird AC am Wechselrichter und DC an der Flaeche
gespeichert.
## Folgen
- Bestehende Installationen bleiben mit drei leeren Listen gueltig.
- Fehlerhafte Referenzen oder unplausible technische Grenzen sperren die
Manager-Konfiguration kontrolliert.
- Ein Hybridwechselrichter kann mehrere PV-Flaechen und Batteriespeicher
verbinden, besitzt aber nur eine gemeinsame AC-Nennleistung.
- Das Prognoseportal muss die technischen Stammdaten aus dem Managervertrag
lesen und eigene Eingaben dafuer als schreibgeschuetzt behandeln.
- Fuer den automatischen Upload ist ein eigener, widerrufbarer
Installationszugang erforderlich. Der Lizenzcode selbst wird nicht als
dauerhaftes API-Secret verwendet.
+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.
+5 -3
View File
@@ -13,9 +13,11 @@
| `Interval` | entfaellt | Die Regelung wird ereignisbasiert ausgeloest. | | `Interval` | entfaellt | Die Regelung wird ereignisbasiert ausgeloest. |
| `IdleCounterMax` | `LastwechselSperrzeit` | Zykluszaehler wird durch eine Sperrzeit in Sekunden ersetzt; Standard `5`. | | `IdleCounterMax` | `LastwechselSperrzeit` | Zykluszaehler wird durch eine Sperrzeit in Sekunden ersetzt; Standard `5`. |
Die neuen Properties `TemperaturMaxAlter`, `Hysterese`, die Parameter der Die neuen Properties `TemperaturMaxAlter`, `TemperaturUntergrenze`,
Legionellenfunktion und `DiagnosevariablenAnzeigen` ersetzen feste Werte und `TemperaturObergrenze`, `Hysterese`, die Parameter der Legionellenfunktion und
nicht pruefbare Annahmen des Altmoduls. `DiagnosevariablenAnzeigen` ersetzen feste Werte und nicht pruefbare Annahmen
des Altmoduls. Die Stoergrenzen liegen standardmaessig bei 0 und 100 Grad C und
sind von den Regel-Sollwerten `Mindesttemperatur` und `Maximaltemperatur` getrennt.
## Variablen ## Variablen
+118 -47
View File
@@ -1,59 +1,130 @@
# Batterie # Batterie
> Status: Diskussionsentwurf. Positiv bedeutet Laden, negativ Entladen. > Status: implementiert. Zielplattform ist IP-Symcon ab Version 8.0,
> Nachrichtenvertrag 4.0.
Die Auswahl des Batterietyps bestimmt den Adapter und die sichtbaren ## Verantwortung
Anschlussfelder. Ohne Logging bleibt von den zusätzlichen Variablen nur
`Ladestatus` sichtbar.
## Zusätzliche Variablen Das Modul bildet einen Batteriespeicher als bidirektionalen Enelix-Verbraucher
ab. Es berechnet das betriebsart- und ladezustandsabhaengige
Leistungsangebot, empfaengt Sollleistungen vom Manager und uebersetzt sie in
herstellerspezifische Registerwerte.
| Ident | Typ / Zugriff | Beschreibung | Positive Leistung bedeutet Laden, negative Leistung Entladen. Die
| --- | --- | --- | Geraeteanbindung erfolgt ausschliesslich ueber vom Benutzer ausgewaehlte
| `Ladestatus` | Integer / Anzeige | `0` unbekannt, `1` ruhend, `2` laden, `3` entladen. | numerische IP-Symcon-Variablen. Schreibziele muessen eine Aktion besitzen.
| `Ladezustand` | Float / Logging | Physischer Ladezustand in %. |
| `LadezustandGueltig` | Boolean / Logging | Gültigkeit unabhängig von der Leistungsmessung. |
| `Steuerungsmodus` | Integer / lokal bedienbar | `0` Batterie, `1` Manager, `2` Virtuelle Batterie führt. |
| `NotnachladungAktiv` | Boolean / Logging | Aus Ladezustand ermittelter lokaler Bedarf. |
| `LadeleistungVerfuegbar` | Float / Logging | Aktuell nutzbare Ladeleistung in W. |
| `EntladeleistungVerfuegbar` | Float / Logging | Aktuell nutzbare Entladeleistung als positiver Betrag in W. |
| `MaxLadeleistung` | Float / lokal bedienbar | Wirksamer Parameter in W. |
| `MaxEntladeleistung` | Float / lokal bedienbar | Wirksamer Parameter in W. |
| `Mindestladezustand` | Float / lokal bedienbar | Wirksamer Parameter in %. |
| `Maximalladezustand` | Float / lokal bedienbar | Wirksamer Parameter in %. |
| `Nachladeschwelle` | Float / lokal bedienbar | Wirksamer Parameter in %. |
| `Nachladehysterese` | Float / lokal bedienbar | Wirksamer Parameter in %. |
| `Notnachladeleistung` | Float / lokal bedienbar | `0` deaktiviert; sonst Leistung in W. |
## Zusätzliche Properties ## Ereignismodell
| Ident | Typ | Standard / Beschreibung | Fachliche Neuberechnungen werden ausgeloest durch:
| --- | --- | --- |
| `MaxLadeleistung` | Float | `0*` W; einzige einstellbare obere Ladegrenze. |
| `MaxEntladeleistung` | Float | `0` W; einzige einstellbare obere Entladegrenze. |
| `Mindestladezustand` | Float | `0*` %; Bereich 0–100. |
| `Maximalladezustand` | Float | `100` %; grösser als Mindestladezustand. |
| `Nachladeschwelle` | Float | `0*` %; zwischen Mindest- und Maximalladezustand. |
| `Nachladehysterese` | Float | `2` %; Schwelle plus Hysterese höchstens Maximalladezustand. |
| `Notnachladeleistung` | Float | `0` W; sonst höchstens MaxLadeleistung. |
| `Batterietyp` | Auswahl | `Unkonfiguriert`, `Herstellerunabhaengig`, `Goodwe`, `SolarEdge`, `SigEnergy`. |
| `BatterieInstanzID` | Integer | `0`; für kompatible Herstelleradapter. |
| `LadezustandVariableID` | Integer | `0`; nur herstellerunabhängig. |
| `IstleistungVariableID` | Integer | `0`; nur herstellerunabhängig. |
| `SollleistungVariableID` | Integer | `0`; nur herstellerunabhängig, vorzeichenbehaftet in W. |
| `LadezustandMaxAlter` | Integer | `60` s; Aktualität des Ladezustands. |
| `VirtuelleBatterieID` | Integer | `0`; Pflicht bei Steuerungsmodus 2. |
Anschlussart, Batteriespannung, Gerätezuordnung, Leistungsschritt, - Aktualisierung eines der fuenf Messwerte
Sollwertquelle und externe Sollwerte entfallen. - Managerdaten mit Betriebsart oder Sollleistung
- lokale Aenderung der Variablen Aktiv
- Ablauf von Vorgabe-Timeout oder Aenderungssperre
- manuelle Aktualisierung in der Konfigurationsmaske
## Zustand Der Meldezyklus verschickt eine Vollmeldung und dient nicht als Regelzyklus.
Ein periodischer Berechnungstimer oder Idle-Counter existiert nicht.
`Ladezustand_Prozent` als Istwert; `Steuerungsmodus` und ## Properties
`NotnachladungAktiv` als Status; `LadezustandFehlt` und `Geraetefehler` als
Störungen.
## Offene Punkte ### Gemeinsame Verbraucherproperties
- Michis konkrete Fehlerliste nachliefern und gegen den Entwurf prüfen. PrioritaetPV, PrioritaetPeak, Meldeintervall, VorgabeTimeout,
- Datenpunkte und Leistungsgrenzen der drei Herstelleradapter verifizieren. EinstellungenInVisu und LoggingEin stammen aus VerbraucherBasisTrait.
### Batteriespezifische Properties
| Property | Typ | Standard | Bedeutung |
| --- | --- | ---: | --- |
| Batterietyp | Integer | 0 | 0 unkonfiguriert, 1 generisch, 2 GoodWe, 3 SolarEdge, 4 Sigenergy |
| Batteriemanagement | Integer | 1 | 1 Wechselrichter, 2 Enelix |
| MaxLadeleistungVariableID | Integer | 0 | Dynamische Ladegrenze in W |
| MaxEntladeleistungVariableID | Integer | 0 | Dynamische Entladegrenze in W |
| LadezustandVariableID | Integer | 0 | SoC in Prozent |
| NetzleistungVariableID | Integer | 0 | Positiv Netzbezug, negativ Einspeisung |
| IstleistungVariableID | Integer | 0 | Positiv Laden, negativ Entladen |
| ManagementRegisterVariableID | Integer | 0 | Steuerungsquelle des Wechselrichters |
| ModusRegisterVariableID | Integer | 0 | Laden-/Entladen-Code |
| LeistungsRegisterVariableID | Integer | 0 | GoodWe-Leistungsregister in W |
| LadeleistungRegisterVariableID | Integer | 0 | Ladeleistung in W beziehungsweise kW |
| EntladeleistungRegisterVariableID | Integer | 0 | Entladeleistung in W beziehungsweise kW |
| ReserveLadezustand | Float | 20 | Peakshaving-Reserve in Prozent |
| MindestLadezustand | Float | 10 | Untere Entladegrenze in Prozent |
| LadezustandHysterese | Float | 2 | Hysterese oberhalb der Reserve |
| NachladenMitMaximalleistung | Boolean | true | Verwendet fuer schutzbedingtes Nachladen die dynamische maximale Ladeleistung |
| MaximaleNachladeleistung | Integer | 0 | Benutzerdefinierte Nachladegrenze in W; bei deaktivierter Maximalleistung muss der Wert groesser als 0 sein |
| 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. Bei schutzbedingtem Nachladen innerhalb oder unterhalb der Reserve
begrenzt MaximaleNachladeleistung die positive Angebotsgrenze, wenn
NachladenMitMaximalleistung deaktiviert ist. Der normale Ladebereich oberhalb
der Reserve bleibt unveraendert. 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 # Easee Gateway
> Status: Diskussionsentwurf. Kommunikationsmodul, kein Verbraucher und keine > Status: implementiert fuer IP-Symcon 8 und die Easee Cloud API.
> Verwendung der Verbraucherbasis.
Das bestehende Gateway wird übernommen. Anzeigen werden deutsch; technische Das Modul stellt pro Easee-Nutzerkonto genau eine gemeinsame Verbindung bereit.
Alt-Idents bleiben zur Kompatibilität erhalten. Eine passende vorhandene Benutzername, Passwort, Access Token und Refresh Token verbleiben im Gateway.
Verbindung soll bei der Instanziierung wiederverwendet werden. Mehrere Instanzen von **Ladestation Gateway** koennen denselben Elternknoten
verwenden.
## Variablen ## Funktionsumfang
| Technischer Ident / Anzeige | Typ / Zugriff | Beschreibung | - Anmeldung mit Easee-Benutzerkonto und automatische Token-Erneuerung,
| --- | --- | --- | - gemeinsame Ereignisverbindung zu `streams.easee.com`,
| `Connected` / Verbunden | Boolean / Anzeige | SignalR-Verbindungsstatus. | - Abonnement mehrerer Ladestationen mit aktuellem Zustand,
| `SubscriptionCount` / Angemeldete Ladestationen | Integer / Anzeige | Anzahl registrierter Geräte. | - Verteilung der Easee-Observations an die passenden Kindinstanzen,
| `LastError` / Letzter Fehler | String / Anzeige | Diagnose ohne Zugangsdaten. | - Stromvorgabe ueber `set_dynamic_charger_current`,
- Wiederverbindung und erneute Anmeldung aller Stationen nach Unterbrechungen,
- TLS-Zertifikatspruefung standardmaessig aktiv.
## Properties ## Properties
| Technischer Ident / Anzeige | Typ | Standard / Beschreibung | | Ident | Typ | Standard | Beschreibung |
| --- | --- | --- | --- |
| `Active` | Boolean | `true` | Aktiviert Anmeldung und Ereignisverbindung. |
| `Username` | String | leer | E-Mail-Adresse oder Telefonnummer des Easee-Kontos. |
| `Password` | Passwort | leer | Passwort, nur im Gateway gespeichert. |
| `VerifyCertificate` | Boolean | `true` | Prueft TLS-Zertifikate fuer REST und WebSocket. |
Die interne Property `Testmodus` ist nicht im Formular sichtbar und wird nur
vom automatisierten IP-Symcon-Test verwendet.
## Variablen
| Ident | Typ | Beschreibung |
| --- | --- | --- | | --- | --- | --- |
| `Active` / Aktiv | Boolean | `true`; Verbindungsbetrieb, keine Ladefreigabe. | | `Connected` | Boolean | Ereignisverbindung ist betriebsbereit. |
| `Username` / Benutzername | String | leer; Easee-Konto. | | `SubscriptionCount` | Integer | Anzahl angemeldeter Seriennummern. |
| `Password` / Passwort | String | leer; vertraulich. | | `LastError` | String | Letzter Fehler ohne Zugangsdaten oder Tokens. |
| `VerifyCertificate` / Zertifikat prüfen | Boolean | `true`; TLS-Prüfung. |
## Verhalten ## API und Ereignisse
Nach einer Wiederverbindung werden Gerätezustände erst nach neuer gültiger Der Gateway-Transport ist in
Rückmeldung verwendet. Das Gateway sendet keine EMS-Verbrauchermeldung. [Easee-Gateway-Schnittstelle](../../Schnittstelle-Easee-Gateway.md)
vollstaendig beschrieben. Fuer Fahrzeug- und Phasenstatus werden insbesondere
Observation `109` und `110` verteilt. Nach jeder Wiederverbindung wird der
Cache verworfen und durch `SubscribeWithCurrentState` neu aufgebaut.
## Offene Punkte ## Fehlerbehandlung
- Instanziierung und Wiederverwendung bestehender Verbindungen testen. | Status | Bedeutung |
- Aktuelle Easee-Endpunkte und Ereignisfelder vor Übernahme verifizieren. | ---: | --- |
| `102` | Verbindung aktiv. |
| `104` | Gateway deaktiviert. |
| `201` | Benutzername oder Passwort fehlt. |
| `202` | Anmeldung oder Token-Erneuerung fehlgeschlagen. |
| `203` | Ereignisverbindung fehlgeschlagen. |
REST-Aufrufe verwenden 5 Sekunden Verbindungs- und 30 Sekunden
Gesamt-Timeout. HTTP-401 fuehrt einmalig zu einer Token-Erneuerung und
Wiederholung. Tokens werden nie als Variable oder Debugtext ausgegeben.
## Inbetriebnahme
1. Eine Instanz **Easee Gateway** erstellen.
2. Benutzername und Passwort des Easee-Kontos eintragen.
3. TLS-Pruefung aktiviert lassen.
4. Speichern und Status `102` sowie `Connected=true` abwarten.
5. Fuer jede Station eine Kindinstanz **Ladestation Gateway** anlegen.
6. Bei mehreren Konten je Konto eine eigene Gateway-Instanz verwenden.
## Tests
`composer check` prueft Syntax und Unit-Tests. Der Funktionstest laeuft mit:
```bash
tests/Symcon/bin/run-symcon-tests.sh single EaseeGateway
```
+111 -29
View File
@@ -1,41 +1,123 @@
# Ladestation Gateway # Ladestation Gateway
> Status: Diskussionsentwurf. Eigenständiges Verbrauchermodul mit zugeordnetem > Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
> Easee Gateway; keine gemeinsame Ladestations-Basisklasse.
## Zusätzliche Variablen Das Modul bindet genau eine Easee-Ladestation als EMS-Verbraucher an. Es ist
Kind eines **Easee Gateway** und besitzt keine Zugangsdaten.
| Ident | Typ / Zugriff | Beschreibung | ## Varianten
| Betriebsmodus | Verhalten |
| --- | --- |
| `Easee` | Solarladen kann als Startwert konfiguriert und lokal umgeschaltet werden. |
| `Easee - Nur Solarladen` | Solarladen ist fest aktiv und kann nicht abgeschaltet werden. |
Die alte eCarUp-Zusatzabhaengigkeit wird nicht uebernommen. Konto,
Ladestatus und Steuerung laufen ausschliesslich ueber Easee.
## Ereignisbasierter Status
Das Modul pollt die Ladestation nicht. Es verarbeitet unmittelbar die vom
Gateway gelieferten Easee-Observations:
| Observation | Verwendung |
| ---: | --- |
| `47` | Maximalstrom der Ladestation. |
| `104` | Kabelstromgrenze. |
| `109` | Fahrzeugerkennung und Ladezustand. |
| `110` | Aktive Ausgangsphase: Werte 10 bis 15 ergeben 1 Phase, Wert 30 ergibt 3 Phasen. |
| `119` | Easee-Fehlercode. |
| `120` | Gemessene Gesamtleistung in kW. |
| `182..185` | Gemessene Leiterstroeme; der groesste Betrag ist der angezeigte Ladestrom. |
Damit entfallen die alte 60-Sekunden-Erkennung, die 7500-W-Schwelle und die
Voll-Erkennung aus einem unterschaetzten Strom. Fahrzeug, Ladeende und
Phasenzahl stammen direkt aus der Easee API. Zweiphasige oder noch nicht
zugewiesene Ausgangsphasen sowie unbekannte Betriebszustaende werden sicher
als ungueltig behandelt und ergeben bis zur gueltigen 1-/3-Phasenmeldung nur
`[0]`.
## Properties
| Ident | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `Betriebsmodus` | Integer | `0` | `0` Easee, `1` Easee - Nur Solarladen. |
| `Ladestationskennung` | String | leer | Easee-Seriennummer. |
| `MaximalerLadestrom` | Integer | `16 A` | Lokale Obergrenze von 6 bis 32 A. API- und Kabelgrenze wirken zusaetzlich. |
| `Ladefreigabe` | Boolean | `false` | Startwert der lokalen Ladefreigabe. |
| `Solarladen` | Boolean | `true` | Startwert im normalen Easee-Modus. |
| `PrioritaetPV` | Integer | `0` | Prioritaet im PV-Betrieb. |
| `PrioritaetPeak` | Integer | `0` | Prioritaet im Peakbetrieb. |
| `Meldeintervall` | Integer | `10 s` | Periodische Vollmeldung an den Manager. |
| `VorgabeTimeout` | Integer | `120 s` | Ablauf einer nicht erneuerten Manager-Vorgabe. |
| `EinstellungenInVisu` | Boolean | `false` | Zeigt Ladefreigabe, Solarladen, Istleistung und Sollleistung. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Zeigt technische Diagnosewerte. |
| `LoggingEin` | Boolean | `false` | Aktiviert Debugmeldungen ohne Geheimnisse. |
## Variablen
Immer sichtbar sind `Aktiv`, `FahrzeugVerbunden`, `FahrzeugGeladen`,
`Ladestrom` und `Phasenzahl`. Mit `EinstellungenInVisu=true` erscheinen
zusaetzlich `Ladefreigabe`, `Solarladen`, `Istleistung` und `Sollleistung`.
Mit `DiagnosevariablenAnzeigen=true` werden der numerische `Fahrzeugstatus`
und die gemeinsamen EMS-Diagnosewerte sowie
`GatewayVerbunden`, `ApiMaximalstrom`, `EaseeAktiviert`,
`AutorisierungErforderlich`, `RemoteStartErforderlich`, `SmartCharging`,
`GrundKeinLadestrom`, `LetzterGeraetebefehl`, `LetzterStartbefehl` und
`LeistungsangebotDiagnose` angezeigt. `GrundKeinLadestrom=55` bedeutet bei Easee, dass
die Autorisierung fehlt.
## Regelung
Die Leistungsstufen und das PV-/Peak-Verhalten entsprechen der
Ladestation Stand-Alone:
| Betriebsart | Solarladen | Angebot |
| --- | --- | --- | | --- | --- | --- |
| `FahrzeugVerbunden` | Boolean / Anzeige | Nur bei gültigem Gatewaystatus aussagekräftig. | | PV | ein | `[0, ...Ladestufen]` |
| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` nicht verbunden, `2` bereit, `3` lädt, `4` voll, `5` Fehler. | | PV | aus | nur maximale Ladestufe |
| `Ladestrom` | Float / Anzeige | Aktueller Ladestrom in A. | | Peak | ein | `[0]` |
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. | | Peak | aus | `[0, ...Ladestufen]` |
| `Ladefreigabe` | Boolean / lokal bedienbar | Lokale Ladeerlaubnis zusätzlich zu `Aktiv`. |
| `Solarladen` | Boolean / lokal bedienbar | Überschussorientiertes Laden ein/aus. |
## Zusätzliche Properties Die niedrigste Grenze aus Property, Observation `47` und Observation `104`
bestimmt den angebotenen Maximalstrom. Eine neue Observation berechnet das
Angebot sofort neu und meldet die Aenderung kurz gebuendelt an den Manager.
Beim Aktivieren oder Umschalten von Solarladen bleibt der bisherige
Geraetestrom bis zur neuen Manager-Vorgabe unveraendert. Ohne Managerantwort
greift nach zwei Sekunden das lokale Ersatzverhalten. Sicherheitsabschaltungen
werden nicht verzoegert.
| Ident | Typ | Standard / Beschreibung | Bei einer Gateway-Unterbrechung werden Fahrzeug- und Phasenstatus sofort
| --- | --- | --- | verworfen; erst ein neuer aktueller Gateway-Zustand gibt die Regelung wieder
| `Ladefreigabe` | Boolean | `false`. | frei. Die eigentliche Vorgabe wird als dynamischer Ladestrom mit `minutes=0`
| `Solarladen` | Boolean | `true`. | an Easee gesendet. Meldet Easee bei positiver Stromvorgabe weiterhin
| `MaximalerLadestrom` | Float | `0*` A; Installations- und Gerätegrenze. | `Awaiting Start`, sendet das Modul einmal pro Fahrzeugverbindung zusaetzlich
| `Ladestromschritt` | Float | `1` A; muss von Easee unterstützt sein. | `start_charging`. Dieser Befehl autorisiert den Ladevorgang, uebersteuert aber
| `MindestEinzeit` | Integer | `0` s. | keinen vorhandenen Easee-Ladeplan.
| `MindestAuszeit` | Integer | `0` s. |
| `GatewayID` | Integer | `0`; kompatibles Easee Gateway, erforderlich. |
| `Ladestationskennung` | String | leer; eindeutige Station/Seriennummer im Gateway. |
Es gibt hier keine Zugangsdaten; sie liegen ausschliesslich im Easee Gateway. ## Managerkommunikation
Ladefreigabe und Solarladen werden über `EinstellungenInVisu` eingeblendet.
## Zustand 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.
`Fahrzeugstatus`, `FahrzeugVerbunden`, `Ladefreigabe`, `Solarladen`, ## Inbetriebnahme
`Ladestrom_A`, `Ladefehler` und `Gatewayfehler`.
## Offene Punkte 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.
- Reaktion aller zugeordneten Stationen bei Gatewayausfall im Praxistest festlegen. ## Tests
- Lokalen Mindestladebedarf bei deaktiviertem Solarladen genau festlegen.
```bash
composer check
tests/Symcon/bin/run-symcon-tests.sh single LadestationGateway
```
+311 -43
View File
@@ -1,63 +1,331 @@
# Ladestation Stand-Alone # Ladestation Stand-Alone
> Status: Implementiert. Eigenstaendiges Verbrauchermodul mit direkter > Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
> Geraeteanbindung; keine Beziehung zur Ladestation Gateway.
Das Modul bindet eine einzelne Ladestation direkt an Enelix EMS an. Es liest
den Fahrzeug- und Ladezustand ueber die Geraete-API, ermittelt die Phasenzahl
wie Enelix 1 und setzt den vom Manager gewaehlten Ladestrom. Es benoetigt keine
Gateway-Instanz und hat keine technische Beziehung zum separaten Modul
**Ladestation Gateway**.
## Funktionsumfang
- direkte Statusabfrage und Steuerung der unterstuetzten Ladestationen,
- Fahrzeug-, Lade- und Phasenerkennung nach der Enelix-1-Logik,
- Leistungsangebote fuer PV- und Peakbetrieb,
- lokale Freigabe und Umschaltung zwischen Solar- und Normalbetrieb,
- Kommunikation mit dem Enelix Manager ueber den Nachrichtenvertrag `4.0`,
- optional sichtbare Diagnosevariablen und schaltbares Debug-Logging,
- automatisierte Tests mit Fake-HTTP-Transport ohne reale Ladestation.
## Unterstuetzte Geraete ## Unterstuetzte Geraete
| Geraetetyp | Status | Steuerung | | Geraetetyp | Statusabfrage | Steuerung | Erforderliche Konfiguration |
| --- | --- | --- | --- |
| go-e Charger, alte API | `GET /mqtt?payload=` | `alw` und `amp` | IP-Adresse oder Hostname |
| go-e Charger Gemini / Gemini flex | `GET /api/status` | `frc` und `amp` | IP-Adresse oder Hostname |
| smart-me Pico | Pico-Charging-API | Load-Management-Current-API | Geraete-ID, Seriennummer, Benutzername und Passwort |
Bei go-e wird die lokale HTTP-API verwendet. Die Geraeteadresse wird ohne
`http://`, Pfad oder Parameter eingetragen. Die Pico-Anbindung verwendet HTTPS
und HTTP Basic Auth gegen `api.smart-me.com`. Zugangsdaten werden weder als
Variable noch im Debug-Protokoll oder im Diagnosefeld
`LetzterGeraetebefehl` ausgegeben.
## Fahrzeug- und Phasenerkennung
Die Auswertung folgt bewusst dem Verhalten der bisherigen Enelix-1-Ladestation:
1. Bei go-e gilt das Fahrzeug als verbunden, wenn `car != 1` ist. Bei Pico
wird entsprechend `State != 1` ausgewertet.
2. Die gemessene Leistung wird bei der alten go-e-API mit Faktor 10, bei
Gemini direkt in Watt und bei Pico von kW in Watt umgerechnet.
3. go-e meldet die drei Phasenstroeme einzeln. Sobald mindestens zwei Phasen
Strom fuehren, wird dreiphasiges Laden direkt erkannt.
4. Sind beim Anstecken noch keine belastbaren Phasenwerte vorhanden, gibt das
Modul zunaechst nur den Mindeststrom von 6 A als Erkennungsstrom vor. Bis zur
abgeschlossenen Erkennung bleibt `Phasenzahl=0` und das EMS-Angebot `[0]`.
5. Nach `Phasenerkennungszeit` wird die gemessene Leistung relativ zum
angeforderten Erkennungsstrom ausgewertet. Dadurch lassen sich eine und drei
Phasen bereits bei 6 A unterscheiden. Die erkannte Phasenzahl bleibt bis
zum Ausstecken erhalten und kippt waehrend einer Ladepause nicht zurueck.
6. Der Ladestrom wird aus der Leistung mit `230 W/A` einphasig beziehungsweise
`684 W/A` dreiphasig berechnet.
7. Ein Ladeende wird nur nach zuvor tatsaechlich gemessener Ladeleistung
erkannt. Eine vom EMS angeordnete Solarpause gilt deshalb nicht als
`FahrzeugGeladen`.
Der normalisierte `Fahrzeugstatus` verwendet folgende Werte:
| Wert | Bedeutung |
| ---: | --- |
| `0` | unbekannt oder noch nicht ermittelt |
| `1` | kein Fahrzeug verbunden |
| `2` | Fahrzeug verbunden und bereit |
| `3` | Fahrzeug laedt |
| `4` | Fahrzeug geladen |
## Leistungsregelung
Ein Leistungsangebot groesser als `0 W` wird nur erzeugt, wenn das Modul
aktiviert, die Ladefreigabe gesetzt, ein Fahrzeug verbunden und das Fahrzeug
noch nicht als geladen erkannt ist. Andernfalls meldet das Modul `[0]` und
stoppt die Ladestation.
| Betriebsart | Solarladen | Leistungsangebot |
| --- | --- | --- | | --- | --- | --- |
| go-e Charger, alte API | `GET /mqtt?payload=` | `alw` und `amp` | | PV | ein | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum |
| go-e Charger Gemini / Gemini flex | `GET /api/status` | `frc` und `amp` | | PV | aus | ausschliesslich die maximale Ladeleistung |
| smart-me Pico | Pico-Charging-API | Load-Management-Current-API mit Basic Auth | | Peak | ein | ausschliesslich `[0]`; die Ladestation wird gestoppt |
| Peak | aus | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum |
Die Routen, Leistungsfaktoren und Erkennung wurden gezielt aus Enelix 1 Die Leistungsstufen entsprechen Enelix 1: `230 W` pro Ampere einphasig und
uebernommen. Zugangsdaten werden weder als Variable noch im Diagnoseprotokoll `684 W` pro Ampere dreiphasig. Ohne gueltige Manager-Vorgabe faehrt das Modul
ausgegeben. bei aktivem Solarladen mit `0 A`; bei ausgeschaltetem Solarladen verwendet es
die maximale angebotene Leistung.
## Verhalten Beim Aktivieren oder Umschalten von Solarladen bleibt ein bereits gesetzter
Geraetestrom zunaechst unveraendert. Das neue Angebot wird zuerst an den
zugeordneten Manager gemeldet; dessen neue Vorgabe wird anschliessend direkt
uebernommen. Antwortet kein Manager, greift nach zwei Sekunden das lokale
Ersatzverhalten. Sicherheitsabschaltungen wirken weiterhin sofort.
- `FahrzeugVerbunden` entspricht bei go-e `car != 1` und bei Pico `State != 1`. Nach einer Leistungsaenderung wird die aktuelle Stufe fuer
- Die Phasenerkennung verwendet wie Enelix 1 die gemessene Ladeleistung: `ZeitZwischenZustandswechseln` gehalten. Waehrend der
ueber 7500 W sind dreiphasig, sonst einphasig. `Mindesteinschaltdauer` bleiben positive Leistungsstufen regelbar, `0 W`
- Ein bereits verbundenes Fahrzeug gilt bei einem ermittelten Maximalstrom wird jedoch nicht angeboten. Nach dem Abschalten meldet das Modul waehrend der
unter 6 A als geladen. `Mindestausschaltdauer` ausschliesslich `[0]`. Sicherheitsabschaltungen
- PV mit Solarladen bietet `0 W` und alle Ladestufen von 6 A bis zum durch Deaktivierung, fehlende Ladefreigabe, Ausstecken oder ein erkanntes
konfigurierten Maximum an. Ladeende greifen sofort.
- PV ohne Solarladen bietet ausschliesslich die Maximalleistung an.
- Peak mit Solarladen bietet nur `[0]` an und stoppt die Ladestation. ## Bedienung
- Peak ohne Solarladen bietet `[0, ...Ladestufen]` an, damit der Manager die
Ladeleistung wie in Enelix 1 stufenweise reduzieren kann. | Ident | Wirkung |
- Ohne gueltige Manager-Vorgabe wird bei Solarladen mit 0 A und ohne | --- | --- |
Solarladen mit maximaler Leistung gefahren. | `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`, `Solarladen`, `Istleistung` und `Sollleistung` 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. |
| `Phasenerkennungszeit` | Integer | `60 s` | Dauer der 6-A-Probe vor der leistungsbasierten Phasenerkennung. |
| `FahrzeugstromErkennungszeit` | Integer | `90 s` | Zeit, die eine stabile Stromunterschreitung anliegen muss, bevor die Fahrzeuggrenze mit 2,5 A Reserve uebernommen wird. |
| `ZeitZwischenZustandswechseln` | Integer | `1 min` | Mindestabstand zwischen zwei Leistungsaenderungen. |
| `Mindesteinschaltdauer` | Integer | `0 min` | Mindestdauer nach dem Einschalten, in der kein Abschalten auf 0 W angeboten wird. |
| `Mindestausschaltdauer` | Integer | `0 min` | Mindestpause nach dem Abschalten, in der nur 0 W angeboten werden. |
| `Abfrageintervall` | Integer | `5 s` | Intervall der Geraetestatusabfrage. |
| `Ladefreigabe` | Boolean | `true` | Startwert der lokalen Ladefreigabe. |
| `Solarladen` | Boolean | `true` | Startwert der lokalen Solarlogik. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Blendet gemeinsame und ladestationsspezifische Diagnosevariablen ein. |
Die internen Properties `Testmodus` und `Testantwort` gehoeren ausschliesslich
zum automatisierten Symcon-Funktionstest und erscheinen nicht im
Konfigurationsformular.
## Variablen ## Variablen
Immer sichtbar:
| Ident | Typ / Zugriff | Beschreibung | | Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- | | --- | --- | --- |
| `FahrzeugVerbunden` | Boolean / Anzeige | Geraet meldet ein verbundenes Fahrzeug. | | `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; der Initialwert ist `false`. |
| `FahrzeugGeladen` | Boolean / Anzeige | Enelix-1-Erkennung unter 6 A. | | `FahrzeugVerbunden` | Boolean / Anzeige | Zeigt, ob die API ein verbundenes Fahrzeug meldet. |
| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` getrennt, `2` bereit, `3` laedt, `4` geladen. | | `FahrzeugGeladen` | Boolean / Anzeige | Ergebnis der kompatiblen Enelix-1-Voll-Erkennung. |
| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Strom in A. | | `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Ladestrom in A. |
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. | | `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig oder `3` dreiphasig. |
| `Ladefreigabe` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; lokale Ladeerlaubnis. |
| `Solarladen` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; variable Ladestufen ein/aus. |
## Properties Nur mit `EinstellungenInVisu=true` sichtbar; Ladefreigabe und Solarladen sind bedienbar:
Neben den gemeinsamen Verbraucher-Properties werden Geraetetyp, Adresse, - `Ladefreigabe`
Pico-ID, Seriennummer, Zugangsdaten, maximaler Ladestrom, Abfrageintervall - `Solarladen`
sowie die Startwerte fuer Ladefreigabe und Solarladen konfiguriert. - `Istleistung`
- `Sollleistung`
Die gemeinsamen Verbraucher- und Diagnosevariablen entsprechen der Nur mit `DiagnosevariablenAnzeigen=true` sichtbar:
EMS-Schnittstelle Version 4.0. `LoggingEin` steuert ausschliesslich das
laufende Debug-Protokoll. | Ident | Beschreibung |
| --- | --- |
| `Leistungsquelle` | Immer `2` fuer eine gemessene Leistung. |
| `Fahrzeugstatus` | Normalisierter Status von `0` bis `4`. |
| `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` | Letzter Steuerbefehl mit HTTP-Methode und URL ohne Zugangsdaten; Statusabfragen ueberschreiben ihn nicht. |
| `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 bei unbekannten Phasen
zunaechst der Mindeststrom von 6 A zur Erkennung angefordert.
- Nach der Erkennungszeit werden eine oder drei Phasen relativ zum Pruefstrom
gespeichert. Bei go-e koennen die einzelnen Phasenstroeme die Erkennung
bereits vorher abschliessen.
- Die erkannte Phasenzahl bleibt bei einer anschliessenden Ladepause stabil.
- Die gemessene Leistung wird plausibel in `Istleistung` und `Ladestrom`
abgebildet.
- `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.
- Leistungswechsel-, Mindest-Ein- und Mindest-Aus-Zeiten werden im gemeldeten
Angebot sichtbar und vom Manager eingehalten.
- 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`.
- Phasenzahl bleibt laenger auf `0`: Waehrend der Erkennungsphase muessen
Ladestation und Fahrzeug den angeforderten Maximalstrom tatsaechlich
freigeben. Bei Pico muss fuer eine sichere Dreiphasenerkennung waehrend der
Erkennung mehr als 7500 W anliegen; go-e kann zusaetzlich anhand der
einzelnen Phasenstroeme erkennen.
- Manager-Vorgabe wird abgewiesen: aktive Zuordnung im Manager sowie
`LeistungsangebotDiagnose` und die Betriebsart kontrollieren.
## Tests ## Tests
Die Adaptertests verwenden einen injizierten Fake-HTTP-Transport. Dadurch Die Unit- und Adaptertests verwenden einen injizierten Fake-HTTP-Transport.
werden fuer alle drei Geraetevarianten Statusantworten, URL, HTTP-Methode, Damit werden fuer go-e alt, go-e Gemini und smart-me Pico Statusantworten,
Authentisierung und Steueraufrufe geprueft, ohne ein reales Geraet anzusprechen. URL, HTTP-Methode, Authentisierung und Steueraufrufe ohne reale Hardware
Der Symcon-Funktionstest nutzt den internen, nicht im Konfigurationsformular geprueft.
sichtbaren `Testmodus` und prueft zusaetzlich Fahrzeug-/Phasenerkennung,
die vier PV-/Peak-Angebote sowie die Umschaltung ueber den `Solarladen`-Button. Der Symcon-Funktionstest verwendet den internen `Testmodus` mit simulierten
API-Antworten. Er prueft alle drei Geraetevarianten, die Mindeststrom-Probe
beim Anstecken, stabile Phasen waehrend einer Ladepause, die Schaltsperren,
Fahrzeugstrom-Erkennung, 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.
+335 -17
View File
@@ -8,15 +8,28 @@ kommen aus den Verbrauchermeldungen.
## Variablen ## Variablen
Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netzleistung`. Die drei Regelungsvariablen `Aktiv`, `Betriebsart` und `Netzleistung` sind immer sichtbar. Die vom Manager gefuehrten Mess- und Energievariablen werden immer angelegt und archiviert, koennen aber gemeinsam ausgeblendet werden.
| Ident | Typ / Zugriff | Beschreibung | | Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- | | --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Regelung ein/aus; Start `false`. | | `Aktiv` | Boolean / bedienbar | Regelung ein/aus; Start `false`. |
| `Betriebsart` | String / Anzeige | `Inaktiv`, `PV` oder `Peak`. | | `Betriebsart` | String / Anzeige | `Inaktiv`, `PV` oder `Peak`. |
| `Netzleistung` | Float / Anzeige | Aktuelle Netzleistung in W; positiv Bezug, negativ Einspeisung. | | `Netzleistung` | Float / Anzeige | Aktuelle Netzleistung in W; positiv Bezug, negativ Einspeisung. |
| `PVLeistungArchiv` | Float / Logging | Normalisierte PV-Leistung in W. |
| `HausverbrauchLeistungArchiv` | Float / Logging | Normalisierter Hausverbrauch in W. |
| `NetzleistungArchiv` | Float / Logging | Normalisierte Netzleistung in W; positiv Bezug, negativ Einspeisung. |
| `BatterieleistungArchiv` | Float / Logging | Batterieleistung in W; positiv Laden, negativ Entladen. |
| `PVEnergie` | Float / Zaehler | Integrierte PV-Erzeugung in kWh. |
| `Hausenergie` | Float / Zaehler | Integrierter Hausverbrauch in kWh. |
| `NetzbezugEnergie` | Float / Zaehler | Integrierter Netzbezug in kWh. |
| `EinspeisungEnergie` | Float / Zaehler | Integrierte Einspeisung in kWh. |
| `BatterieLadenEnergie` | Float / Zaehler | Integrierte Batterieladung in kWh. |
| `BatterieEntladenEnergie` | Float / Zaehler | Integrierte Batterieentladung in kWh. |
| `NetzleistungGueltig` | Boolean / Logging | Messquelle vorhanden und aktuell. | | `NetzleistungGueltig` | Boolean / Logging | Messquelle vorhanden und aktuell. |
| `WirksameLastspitzengrenze` | Float / Logging | Aktuelle feste oder monatliche Grenze in W. | | `WirksameLastspitzengrenze` | Float / Logging | Aktuelle feste oder monatliche Bezugsgrenze in W. |
| `WirksameEinspeisegrenze` | Float / Logging | Anlagenweite Einspeisegrenze in W. |
| `Abregelbedarf` | Float / Logging | Aktuell erforderliche PV-Leistungsreduktion in W. |
| `Wechselrichterstatus` | String/JSON / Logging | Verteilte Grenzen und Zustand der PV-Regelung. |
| `Verteilbudget` | Float / Logging | Aktuell verfügbares Budget in W. | | `Verteilbudget` | Float / Logging | Aktuell verfügbares Budget in W. |
| `VerbraucherAnzahl` | Integer / Logging | Anzahl zugeordneter Verbraucher. | | `VerbraucherAnzahl` | Integer / Logging | Anzahl zugeordneter Verbraucher. |
| `VerbraucherVerfuegbar` | Integer / Logging | Anzahl aktuell verfügbarer Verbraucher. | | `VerbraucherVerfuegbar` | Integer / Logging | Anzahl aktuell verfügbarer Verbraucher. |
@@ -47,24 +60,300 @@ Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netz
| `Monatsgrenzen` | String/JSON | Editierbare Liste mit zwölf Monatswerten in W. | | `Monatsgrenzen` | String/JSON | Editierbare Liste mit zwölf Monatswerten in W. |
| `SollwertSolarladen` | Float | `0` W; gewünschte Netzleistung im Solarladebetrieb. | | `SollwertSolarladen` | Float | `0` W; gewünschte Netzleistung im Solarladebetrieb. |
| `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. | | `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. |
| `PrognoseAnschluss` | String/JSON | Optionaler Forecast-Anschluss. | | `EinspeisebegrenzungAktiv` | Boolean | `false`; gemeinsame Exportgrenze am Netzanschlusspunkt aktivieren. |
| `Einspeisegrenze` | Float | Maximale Einspeisung der Gesamtanlage in W; `0` bedeutet Nulleinspeisung. |
| `PrognoseAktiv` | Boolean | `false`; Prognosetelemetrie und Topologiesynchronisation mit `forecast_pv` plus `forecast_load` oder `grid_schedule` aktivieren. |
| `NetzfahrplanAktiv` | Boolean | `false`; lizenzierten, tarif- und prognosebasierten Netzzielwert verwenden. |
| `NetzbezugEnergieVariableID` | Integer | Optionaler kumulativer Netzbezugszaehler. |
| `NetzeinspeisungEnergieVariableID` | Integer | Optionaler kumulativer Einspeisezaehler. |
| `NetzbezugEnergiefaktor`, `NetzeinspeisungEnergiefaktor` | Float | Umrechnung der Netzenergiezaehler nach kWh. |
| `AnlagenWechselrichter` | String/JSON | `[]`; Wechselrichter mit Typ, AC-Nennleistung, Leistungs- und Erzeugungsenergiemessung sowie optionalem Begrenzungsregister. |
| `AnlagenPVFlaechen` | String/JSON | `[]`; PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichter-ID. |
| `AnlagenBatterien` | String/JSON | `[]`; Batteriespeicher mit Kapazitaeten, Kopplung, Leistung, SOC sowie Lade- und Entladeenergiezaehlern. |
| `PrognoseSendeintervall` | Integer | `60` s; Intervall fuer den Upload aktueller Messwerte, zulaessig sind 60 bis 3600 Sekunden. |
| `PrognoseAnschluss` | String/JSON | Verdeckte Altproperty fuer bestehende Konfigurationen. |
| `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. | | `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. |
| `SDLAktiv` | Boolean | `false`; separat gemessene SDL-/Regelenergie in der Anlagenbilanz beruecksichtigen. Keine SDL-Steuerung. |
| `SDLLeistungVariableID` | Integer | `0`; bei aktiver SDL erforderliche numerische Istleistungsquelle. |
| `SDLLeistungsfaktor` | Float | `1`; Umrechnung nach W, positiv Laden/Bezug, negativ Entladen/Abgabe. Fuer kW `1000`, bei umgekehrtem Vorzeichen negativ. |
| `SDLSOCVariableID` | Integer | `0`; optionaler separater SDL-Ladezustand in Prozent, 0 bis 100. |
| `SDLEnergieflussAnzeigen` | Boolean | `false`; SDL mit optionalem SOC als eigenen Speicher im aktivierten Energiefluss anzeigen. |
| `SDLDiagrammeAnzeigen` | Boolean | `false`; SDL-Leistung, Lade-/Entladeenergie und optionalen SOC in aktivierten Diagrammen anzeigen. |
| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. | | `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. |
| `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. | | `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. |
| `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. | | `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. |
| `LoggingEin` | Boolean | `false`; laufende Meldungen im Debug-Fenster ausgeben. | | `LoggingEin` | Boolean | `false`; laufende Meldungen im Debug-Fenster ausgeben. |
| `EnergieaufzeichnungAktiv` | Boolean | `true`; managergefuehrte Leistungs- und Energieaufzeichnung aktivieren. |
| `MesswerteAnzeigen` | Boolean | `false`; einzelne Manager-Messwerte in der Objektstruktur anzeigen. |
| `EnergyPieAnzeigen` | Boolean | `false`; Energy Pie unter dem Manager anlegen oder entfernen. |
| `EnergiediagrammeAnzeigen` | Boolean | `false`; native Leistungs- und Energiediagramme anlegen oder entfernen. |
| `FunFactsAnzeigen` | Boolean | `false`; responsive Energy Facts anlegen oder entfernen. |
| `EnergieflussAnzeigen` | Boolean | `false`; native Energy Distribution anlegen oder entfernen. |
| `LeistungsaufzeichnungMinuten` | Auswahl | `1`; Verdichtung auf 1 Minute, 5 Minuten oder 1 Stunde. |
| `LeistungLoeschenMonate` | Integer | `12`; Leistungswerte nach zwoelf Monaten loeschen, `0` deaktiviert die Loeschung. |
| `EnergieaufzeichnungMinuten` | Auswahl | `5`; Verdichtung auf 1 Minute, 5 Minuten oder 1 Stunde. |
| `EnergieVerdichtenMonate` | Integer | `12`; Energiezaehler nach zwoelf Monaten auf Tageswerte verdichten. |
| `EnergieLoeschenMonate` | Integer | `0`; Energiezaehler nicht loeschen. |
Es gibt keine Sollwertquellenauswahl und keine zweite Prioritätseinstellung im Es gibt keine Sollwertquellenauswahl und keine zweite Prioritätseinstellung im
Manager. Im Modus `Aus` bleibt Solarladen aktiv, nur die Peak-Begrenzung entfällt. Manager. Im Modus `Aus` bleibt Solarladen aktiv, nur die Peak-Begrenzung entfällt.
Die Monatsgrenzen werden in den Grundeinstellungen über eine Schaltfläche Die Monatsgrenzen werden in den Grundeinstellungen über eine Schaltfläche
ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut
ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren. ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren.
Die Lizenzierung steht an erster Stelle des Formulars. Der Manager erzeugt dabei
einmalig eine UUIDv4 als stabile Installations-ID. Mit "Lizenz pruefen und binden" ## Energieaufzeichnung und Visualisierung
wird der eingegebene Code ueber
`POST https://license.enelix.ch/api/v1/licenses/activate` an diese Installation Sobald Netzleistung und mindestens eine PV-Istleistung in der Anlagentopologie
gebunden. Es werden weder Portal-Cookies noch ein CSRF-Token oder ein noch nicht konfiguriert sind, tastet der Manager die Quellen minuetlich ab. PV-Leistung ist
implementierter Lizenzendpunkt verwendet. 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.
## SDL / Regelenergie
`SDL aktiv` blendet die Quellen- und Anzeigeauswahl ein. Die SDL-Istleistung
beschreibt einen **separat gemessenen AC-Zweig am selben Netzanschlusspunkt**.
Sie darf weder in der normalen Batterieleistung noch in einer PV-Leistung
enthalten sein. Eine identische Variablen-ID fuer SDL und Netz/PV/Batterie wird
abgewiesen. Ueberschneidungen in extern gebildeten Summen muss die
Anlagenkonfiguration ausschliessen. Ein gemeinsamer Speicher mit nur einem
Gesamtleistungsmesswert kann damit nicht in EMS- und SDL-Anteile zerlegt werden.
Bei aktiver SDL lautet die Hauslast `max(0, PV + Netz - Batterie - SDL)`.
Die Korrektur geschieht vor der Begrenzung auf null und unabhaengig davon,
ob SDL angezeigt wird. Diese Hauslast wird an den Prognosedienst gesendet;
Netzleistung und der kapazitaetsgewichtete SOC der normalen Batterien bleiben
unveraendert. SDL-SOC ersetzt keinen Batterie-SOC und wird nicht als
planbare EMS-Speicherkapazitaet exportiert. Ungueltige SDL-Leistungswerte
unterbrechen die Messaufzeichnung und den jeweiligen Telemetrieversand,
statt als null in die Hauslast einzugehen. Der SDL-Status ist bei aktiver
SDL auch ohne eingeschaltete Diagnosevariablen sichtbar.
`SDLLeistungArchiv` zeichnet Watt auf, `SDLSOCArchiv` gueltige Prozentwerte.
`SDLLadenEnergie` und `SDLEntladenEnergie` integrieren die getrennten Richtungen
in kWh. Die Abtastung erfolgt wie bei den anderen Leistungen minuetlich, nicht
als abrechnungsgenaue SDL-Energiemessung. Auch bei vorhandenen physikalischen
Energiezaehlern wird die Hausenergie um die im selben Intervall integrierte
SDL-Ladung vermindert und um SDL-Entladung erhoeht. Bei Messluecken ueber
fuenf Minuten, Quellenwechseln oder Zaehlerresets wird keine unvollstaendige
Hausenergie nachberechnet; Historie und bereits erfasste Zaehler bleiben erhalten.
Die Anzeigeoptionen wirken beim Anwenden direkt auf die vorhandenen
Manager-Visualisierungen. Nur die eigenen SDL-Knoten und -Datensaetze werden
ergaenzt oder entfernt. Andere Knoten, Farben, Datensaetze und sonstige
Anpassungen bleiben bestehen. Im Energiefluss verwendet SDL dieselbe W/kW-
Einheit wie die anderen Manager-Knoten; ein ungueltiger SOC wird dort nicht
als aktueller Zusatzwert angezeigt. Ohne separaten SDL-Knoten enthaelt der
Manager-Sammelknoten Batteriespeicher die normale Batterie plus SDL, damit
die dargestellte Leistungsbilanz vollstaendig bleibt. Im Leistungsdiagramm
erscheint die signierte SDL-Istleistung, im Energiediagramm erscheinen Laden
und Entladen getrennt. SOC verwendet in beiden Diagrammen eine eigene rechte
Prozentachse. Die allgemeinen Schalter fuer Energiefluss, Diagramme und
Energieaufzeichnung bleiben erforderlich.
Migration: SDL bleibt nach dem Modulupdate standardmaessig aus. Bestehende
Installationen behalten damit ihre bisherige Bilanz. Erst Quellen und
Vorzeichen pruefen, SDL aktivieren und anwenden. Historische Hauslasten oder
bereits trainierte Prognosen werden nicht rueckwirkend korrigiert; neue
Prognoseeingaben sind ab Aktivierung bereinigt. Ausschalten entfernt nur
SDL-Anzeigen, nicht die aufgezeichnete Historie. Der alte `SDLAnschluss`
bleibt lesbar; externe SDL-Regelung und Stellbefehle werden nicht veraendert.
Native Darstellungsvertraege: [Energy Distribution](https://www.symcon.de/en/service/documentation/module-reference/energy/energy-distribution/)
und [Diagramme](https://www.symcon.de/en/service/documentation/basics/media/charts/).
## Anlagenweite Einspeisebegrenzung
Die Einspeisebegrenzung ist Bestandteil von Peak Shaving und arbeitet auf der
Messung am Netzanschlusspunkt. Fuer jeden regelbaren PV- oder Hybridwechselrichter
werden die Istleistungsvariable und eine bedienbare Begrenzungsvariable
konfiguriert. Der Stellwert kann als Prozent der AC-Nennleistung oder als
absolute Leistung in Watt ausgegeben werden.
Der Manager berechnet eine einzige Grenze fuer die Gesamtanlage. Beim Abregeln
verteilt er sie nach der tatsaechlichen Erzeugung, beim Freigeben nach der
AC-Nennleistung auf alle angebotenen Wechselrichter. Jede gemessene Abweichung
wird stufenlos nachgefuehrt; ein zusaetzliches Toleranzfenster wird nicht
verwendet. Beim Abschalten des Managers oder der Funktion werden zuvor gesetzte
Grenzen kontrolliert auf die jeweilige
Nennleistung zurueckgesetzt. Wechselrichter ohne beide Register werden nicht
geregelt; sobald eines der beiden Register gesetzt ist, muessen beide gueltig
sein und das Begrenzungsregister eine IP-Symcon-Aktion besitzen.
Der intelligente Netzfahrplan verwendet die PV-, Verbrauchs- und
Netzleistungsprognose sowie Tarife und Batteriespeicher. Liegt im
Prognosezeitraum mehr PV-Ertrag als zulaessige Einspeisung vor, wird dieses
Potenzial als flexibler Verbrauch beziehungsweise Speicherladung eingeplant.
Damit werden Verbraucher in ertragreiche Zeitfenster verschoben, bevor der
harte Anlagenregler die Wechselrichter reduziert. Ein fehlender oder
abgelaufener Fahrplan fuehrt automatisch zum konfigurierten
`SollwertSolarladen` zurueck; die harte Einspeisebegrenzung bleibt unabhaengig
davon aktiv.
## Anlagentopologie und Prognoseexport
Der Manager ist die fuehrende Quelle fuer die technischen Stammdaten. Die
Konfiguration trennt Wechselrichter, PV-Flaechen und Batteriespeicher. AC-Leistung
wird am Wechselrichter, DC-Leistung an der PV-Flaeche gepflegt. Individuelle
AC-Einspeise- und Bezugsgrenzen werden nicht erfasst, weil die Begrenzung
anlagenweit am Netzanschlusspunkt erfolgt. Modulanzahl und Modulleistung sind
optional und muessen gemeinsam gesetzt werden.
PV-Flaechen und Batterien referenzieren ihren Wechselrichter ueber dessen stabile
ID. Bei `hybrid` koennen beide denselben Wechselrichter verwenden. Der Export
`ENELIX_AnlagentopologieExportieren($InstanzID)` weist dann eine gemeinsame
AC-Grenze aus, damit PV und Batterie die Nennleistung nicht unabhaengig doppelt
beanspruchen.
Der JSON-Export enthaelt die Vertragsversion `1.0`, die Lizenz-Installations-ID,
die drei Komponentenlisten, gemeinsame AC-Nennleistungen und aggregierte Summen
fuer die schrittweise Prognoseanbindung. Interne Symcon-Variablen-IDs werden
nicht exportiert. Tarif- und Variantenparameter bleiben im Prognoseportal. Bei
aktivierter Prognose wird eine nichtleere Topologie beim Speichern automatisch ueber
einen separaten, widerrufbaren Installationszugang synchronisiert. Der
Lizenzserver liefert diesen Zugang nur auf ausdrueckliche Geraeteanforderung;
der Manager entfernt ihn vor dem Speichern aus der Lease und haelt ihn in einem
internen Attribut. Der Lizenzcode wird nicht als API-Token verwendet. Ein
Synchronisationsfehler erscheint im `Prognosestatus`, blockiert die lokale
EMS-Regelung aber nicht.
Der Forecast-Schalter zeigt direkt an, ob fuer die Manager-ID eine passende
Kombination `forecast_pv` plus `forecast_load` oder `grid_schedule` vorhanden ist. Nach der Aktivierung sendet der
Manager PV-Leistung, berechneten Hausverbrauch, Netzleistung und Batterie-SOC
mit UTC-Zeitstempel im eingestellten Sendeintervall. Dieses Intervall bestimmt
also, wie oft aktuelle Messwerte zum Prognosedienst hochgeladen werden; es ist
kein Regelintervall. Das Minimum von 60 Sekunden
passt zum Rate-Limit des Lizenzportals. Die Uebertragung nutzt denselben
widerrufbaren Installationszugang wie die Topologie; Lizenzcode und Geraet Token
werden weder als Telemetriefelder noch im Debug-Log ausgegeben. Bei HTTP 401
oder 403 verwirft der Manager den Geraetezugang und fordert ihn bei der naechsten
Lizenzpruefung neu an.
Die Entscheidung und ihre Alternativen sind in
[ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert.
## Lizenzierung
Der Manager arbeitet nur mit einer gueltigen Manager-Lizenz. Das Lizenzfeld steht
zuoberst im Konfigurationsformular. Ohne Freigabe bleibt die Instanz mit Status
`203` inaktiv und sendet keine Leistungsvorgaben.
### Voraussetzungen
- Der Auftrag im Enelix-Lizenzportal ist bezahlt und enthaelt eine aktive
Manager-Berechtigung.
- IP-Symcon erreicht `https://license.enelix.ch` ueber HTTPS (Port 443).
- Der Lizenzcode liegt im Format `ENX-XXXX-XXXX-XXXX-XXXX` vor.
### Lizenz aktivieren
1. Manager-Konfiguration in IP-Symcon oeffnen.
2. Lizenzcode im Bereich `Lizenzierung` eintragen.
3. `Lizenz pruefen und binden` ausloesen.
4. Die erfolgreiche Freigabe am angezeigten Lizenzstatus kontrollieren.
5. Die Manager-Konfiguration mit `Uebernehmen` beziehungsweise `OK` speichern,
damit der eingegebene Lizenzcode als Property erhalten bleibt.
Beim ersten Anlegen erzeugt die Manager-Instanz eine UUIDv4 als stabile
Installations-ID. Die Aktivierung sendet ausschliesslich `code` und
`installationId` per
`POST https://license.enelix.ch/api/v1/licenses/activate`. Sie benoetigt weder
Portal-Cookies noch einen CSRF-Token. Derselbe Code kann von derselben
Installation erneut abgerufen werden; die Bindung an eine andere Installation
wird vom Lizenzserver abgelehnt.
### Berechtigungen
| Berechtigung | Freigegebene Funktion |
| --- | --- |
| `manager_standard` | Manager-Grundregelung ohne Peak Shaving. |
| `manager_peak` | Bezugs- und Einspeisebegrenzung am Netzanschlusspunkt. |
| `forecast_pv` | PV-Ertragsprognose; interne Modellvarianten bleiben verborgen. |
| `forecast_load` | Verbrauchsprognose; interne Modellvarianten bleiben verborgen. |
| `grid_schedule` | Intelligenter Netzfahrplan inklusive PV- und Verbrauchsprognose sowie Vermeidung von Abregelung. |
Wird mit `manager_standard` ein Lastspitzenmodus aktiviert, bleibt der Manager
mit dem Hinweis `Peak Shaving ist nicht lizenziert` gesperrt. `manager_peak`
gilt zugleich als Berechtigung fuer die Grundregelung.
### Erneuerung und Offline-Betrieb
Die erfolgreiche Serverantwort wird als Lease in der Manager-Instanz gespeichert.
Der Manager erneuert sie ab `refreshAfter` automatisch ueber denselben
Aktivierungsendpunkt. Schlaegt eine Erneuerung fehl, wird fruehestens nach einer
Stunde erneut angefragt. Eine bereits bestaetigte Entwicklungsfreigabe bleibt
bis `offlineUntil` verwendbar. Der aktuelle Entwicklungsvertrag setzt diesen
Zeitpunkt ungefaehr 14 Tage nach Ausstellung. Danach sperrt der Manager die
Regelung, bis der Lizenzserver wieder eine gueltige Antwort liefert.
Die Installations-ID und die Lease liegen in internen Instanzattributen. Die
ID wird erst nach dem Laden bestehender Attribute initialisiert und bleibt bei
Modulupdates, Modul-Neuladen und einem normalen Neustart unveraendert. Bei einer
Migration muss trotzdem die vollstaendige Manager-Instanz mitsamt ihren
Attributen uebernommen werden. Eine neu erzeugte Instanz erhaelt eine andere
Installations-ID und kann einen bereits gebundenen Code nicht selbststaendig
uebertragen. Wurde die ID mit einer aelteren Manager-Version bereits ungewollt
geaendert, muss im Lizenzportal einmalig ein Ersatzcode erzeugt und an die nun
stabile ID gebunden werden.
### Status und Fehlerbehebung
| Anzeige / Serverstatus | Bedeutung und Massnahme |
| --- | --- |
| `Lizenzcode fehlt.` | Code eintragen, pruefen und die Konfiguration speichern. |
| `Lizenzcode ist ungueltig.` | Format und Zeichen des Codes kontrollieren. |
| HTTP `404` | Code unbekannt oder zugehoeriger Auftrag noch nicht bezahlt. |
| HTTP `409` | Code ist bereits an eine andere Installation gebunden. |
| HTTP `429` | Zu viele Aktivierungsversuche; vor dem naechsten Versuch warten. |
| `Lizenzserver nicht erreichbar` | DNS, Internetzugang, HTTPS und Systemzeit des Symcon-Systems pruefen. Eine bestehende Lease gilt nur bis `offlineUntil`. |
| `Offline-Freigabe ist abgelaufen.` | Verbindung zum Lizenzserver wiederherstellen und Lizenz erneut pruefen. |
| `Keine Prognoseberechtigung vorhanden` | Im Lizenzportal entweder PV-Ertragsprognose zusammen mit Verbrauchsprognose oder den Intelligenten Netzfahrplan ergaenzen. Anschliessend im Manager `Lizenz pruefen und binden` ausloesen, die Prognose aktivieren und die Konfiguration speichern. |
Fuer eine genauere Diagnose koennen die Diagnosevariablen eingeblendet werden.
`Lizenzstatus` zeigt dann den aktuellen Zustand. Mit aktiviertem Debug-Logging
werden Fehlermeldungen der Lizenzpruefung ausgegeben, niemals jedoch der
Lizenzcode selbst.
### Datenschutz und Entwicklungsstand
Der Lizenzcode wird als Manager-Property in der IP-Symcon-Konfiguration
gespeichert. Fuer den internen Abgleich mit der Lease verwendet der Manager
zusaetzlich nur einen SHA-256-Hash und schreibt den Code nicht ins Debug-Log.
Die aktuelle Serverantwort ist ein Entwicklungsvertrag mit
`development: true` und noch nicht kryptografisch signiert. Ein optionaler
Geraete-Public-Key sowie Challenge-, Heartbeat- oder separate
Entitlement-Endpunkte werden vom Manager derzeit bewusst nicht verwendet.
## Verteilalgorithmus ## Verteilalgorithmus
@@ -73,14 +362,43 @@ der aktiven Betriebsart und der aktuellen Leistung aller frisch gemeldeten
Verbraucher. Eine gültige Istleistung wird bevorzugt; fehlt sie, bleibt der im Verbraucher. Eine gültige Istleistung wird bevorzugt; fehlt sie, bleibt der im
Zustand bestätigte Sollwert konservativ reserviert. Zustand bestätigte Sollwert konservativ reserviert.
Nicht verfügbare, nicht änderbare oder aktuell angebotlose Verbraucher behalten Nicht verfügbare oder aktuell angebotlose Verbraucher behalten ihre Leistung
ihre Leistung und erhalten keine neue Vorgabe. Die übrigen Verbraucher werden und erhalten keine neue Vorgabe. Jeder verfügbare, synchronisierte Verbraucher
nach der gemeldeten PV- beziehungsweise Peak-Priorität und danach stabil nach mit einem nicht leeren Angebot erhält dagegen einen Sollwert aus diesem Angebot.
Instanz-ID sortiert. Von ihrem jeweils kleinsten erlaubten Leistungswert aus Das gilt auch bei `AenderungMoeglich=false`: Ein fixes Angebot wie `[11000]`
wird das Budget in dieser Reihenfolge aufgefüllt. Einzelwerte und ganzzahlige wird mit genau `11000 W` zugeteilt.
Bereiche werden direkt verarbeitet; Lücken werden nie durch unzulässige Werte
geschlossen. Eine verbleibende Abweichung wird im `Verbraucherstatus` Die Verbraucher werden nach der gemeldeten PV- beziehungsweise Peak-Priorität
dokumentiert. sortiert. Zuerst reserviert der Manager die richtungsneutralen erlaubten Werte
sowie die gemessene Leistung fester oder gesperrter Verbraucher.
Positives Restbudget wird innerhalb jeder Priorität schrittweise verteilt:
Zuerst kommt die kleinste nächste erreichbare **absolute Sollleistung**, nicht
die kleinste Erhöhung. Bei gleicher nächster Sollleistung entscheidet die exakt
gezählte bezogene Energie in Wh, danach stabil die Instanz-ID. Die bisherigen
2-kWh-Gruppen werden für diese Vergabe nicht mehr verwendet. Vom Budget wird nur
die Differenz zum bereits zugeteilten Sollwert abgezogen. Ein nicht finanzierbarer
Schritt wird übersprungen; andere passende Schritte und danach tiefere
Prioritäten können das verbleibende Budget nutzen.
Beispiel bei gleicher Priorität: A bietet `[0,100,200,400,800]` bei 10 kWh,
B bietet `[0,100,110,500,780,1500]` bei 12 kWh. Die Zuteilungsfolge bei
ausreichendem Budget ist `A100, B100, B110, A200, A400, B500, B780, A800, B1500`.
Die zugehörigen Gesamtbudgets sind 100, 200, 210, 310, 510, 900, 1180, 1580 und
2300 W. Energie entscheidet nur bei gleichen nächsten Leistungsstufen, nicht
mehr über die vollständige Versorgung eines Verbrauchers vor allen anderen.
Ganzzahlige Bereiche folgen derselben Reihenfolge wie einzelne Wattstufen,
werden aber über gemeinsame Leistungsniveaus effizient verarbeitet. Lücken
werden nie durch unzulässige Werte geschlossen. Bei negativem Restbudget bleibt
die bestehende Defizitregelung einschliesslich ihrer Energiegruppen unverändert.
Eine verbleibende Abweichung wird im `Verbraucherstatus` dokumentiert.
Für das Update sind keine neuen Properties oder eine Migration nötig. Bestehende
Energiezähler, Sperrzeiten und Freigaben bleiben erhalten. Nach dem Modulupdate
gilt die neue Vergabe beim nächsten Regellauf. Vor einem Update einer laufenden
Anlage Einstellungen sichern und die neuen Sollwerte kontrolliert prüfen;
die Git-Veröffentlichung allein aktualisiert keine laufende Installation.
Die Betriebsart wechselt unterhalb des Solar-Sollwerts zu `PV` und oberhalb Die Betriebsart wechselt unterhalb des Solar-Sollwerts zu `PV` und oberhalb
der wirksamen Lastspitzengrenze zu `Peak`. Zwischen den beiden Zielwerten der wirksamen Lastspitzengrenze zu `Peak`. Zwischen den beiden Zielwerten
+7 -6
View File
@@ -1,8 +1,9 @@
# EMS-Module und Modulentwürfe # EMS-Module und Modulentwürfe
> Manager, Warmwassererwaermer, Verbraucher 1-Stufig und Ladestation > Manager, Warmwassererwaermer, Pufferspeicher, Batterie, Verbraucher 1-Stufig,
> Stand-Alone sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren > Waermepumpe, Ladestation Stand-Alone, Easee Gateway und Ladestation Gateway
> Ordner enthalten Besprechungsgrundlagen. > sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren Ordner
> enthalten Besprechungsgrundlagen.
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb [EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
@@ -11,14 +12,14 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte.
| Modul | Rolle | | Modul | Rolle |
| --- | --- | | --- | --- |
| [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse (implementiert) | | [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse (implementiert) |
| [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers | | [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers (implementiert) |
| [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung (implementiert) | | [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung (implementiert) |
| [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve | | [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve |
| [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) | | [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) |
| [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready | | [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready |
| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung (implementiert) | | [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung (implementiert) |
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Ladestation am Easee Gateway | | [Ladestation Gateway](Ladestation-Gateway/README.md) | Eventbasierte Ladestation am Easee Gateway (implementiert) |
| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation | | [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation (implementiert) |
## Review-Regel ## Review-Regel
+76 -34
View File
@@ -1,47 +1,89 @@
# Wärmepumpe # Wärmepumpe
> Status: Diskussionsentwurf. > Status: Implementiert.
Die Kontaktwirkung ist als Sperrkontakte oder SG Ready konfigurierbar. Die Das Modul bindet eine Wärmepumpe über zwei schaltbare Boolean-Ausgänge in den
elektrische Leistung kann fehlen, berechnet oder gemessen werden. Enelix-Manager ein. Die Ausgänge können als getrennte Kontakte für Sperre und
Erhöhung oder als SG-Ready-Eingänge verwendet werden. Die Regelung arbeitet mit
dem Nachrichtenvertrag 4.0 und meldet für PV und Peak ein
zustandsabhängiges Leistungsangebot.
## Zusätzliche Variablen ## Verhalten
| Ident | Typ / Zugriff | Beschreibung | - Im PV-Betrieb kann eine freigegebene Wärmepumpe aus dem Normalzustand in die
Erhöhung geschaltet werden.
- Im Peak-Betrieb kann eine laufende Wärmepumpe gesperrt werden, sofern
Mindestlaufzeit und Sperrgrenzen dies erlauben.
- Schaltvorgänge erfolgen als Break-before-Make: beide Kontakte werden zuerst
deaktiviert, danach wird höchstens ein Kontakt aktiviert.
- Nach einem erfolglosen PV-Anlauf wird die Erhöhung aufgehoben und bis zum
Ablauf der Wiederholsperre nicht erneut angeboten.
- Die maximale Sperrzeit am Stück und innerhalb von 24 Stunden erzwingt eine
Erholungsphase im Normalbetrieb.
- Ohne gültige Manager-Vorgabe fällt die Steuerung in den sicheren
Normalzustand zurück.
## Konfiguration
| Property | Standard | Beschreibung |
| --- | ---: | --- |
| `Kontaktart` | `0` | `0`: Sperre/Erhöhung, `1`: SG Ready |
| `Kontakt1VariableID` | `0` | Schaltbarer Ausgang für Sperre beziehungsweise SG Ready 1 |
| `Kontakt2VariableID` | `0` | Schaltbarer Ausgang für Erhöhung beziehungsweise SG Ready 2 |
| `Kontakt1Invertiert` | `false` | Logik des ersten Ausgangs invertieren |
| `Kontakt2Invertiert` | `false` | Logik des zweiten Ausgangs invertieren |
| `Nennleistung` | `0 W` | Elektrische Nennleistung der Wärmepumpe |
| `Rueckmeldungsart` | `0` | `0`: gemessene Leistung, `1`: Boolean-Betriebsstatus |
| `IstleistungVariableID` | `0` | Leistungsmessung oder Betriebsrückmeldung |
| `Laufschwelle` | `100 W` | Ab dieser Leistung gilt die Wärmepumpe als laufend |
| `Anlaufwartezeit` | `30 s` | Wartezeit auf bestätigten Anlauf |
| `Wiederholsperre` | `300 s` | Pause nach einem erfolglosen Anlauf |
| `Mindestlaufzeit` | `1200 s` | Mindestlaufzeit nach bestätigtem Start |
| `Mindestsperrzeit` | `1200 s` | Mindestdauer einer begonnenen Sperre |
| `MaximaleSperrzeit` | `7200 s` | Maximale zusammenhängende Sperre; `0` deaktiviert |
| `MaximaleSperrzeit24h` | `21600 s` | Maximale Sperrsumme in 24 Stunden; `0` deaktiviert |
Hinzu kommen die gemeinsamen Verbraucher-Properties für Prioritäten,
Meldeintervall, Vorgabe-Timeout, Visualisierung und Logging.
## Variablen
| Ident | Typ | Beschreibung |
| --- | --- | --- | | --- | --- | --- |
| `Waermepumpenzustand` | Integer / Anzeige | `0` unbekannt, `1` aus, `2` normal, `3` erhöht, `4` gesperrt. | | `Waermepumpenzustand` | Integer | `1` normal/aus, `2` normal/laufend, `3` erhöht, `4` gesperrt |
| `SperreAktiv` | Boolean / Anzeige | Aktuelle Sperranforderung. | | `WaermepumpeLaeuft` | Boolean | Bestätigter Betriebsstatus |
| `ErhoehungAktiv` | Boolean / Anzeige | Aktuelle Erhöhungsanforderung. | | `SperreAktiv` | Boolean | Sperrkontakt ist aktiv |
| `SGReadyZustand` | Integer / Anzeige | Bei SG Ready Zustand 0–3. | | `ErhoehungAktiv` | Boolean | Erhöhungskontakt ist aktiv |
| `Laufzeit` | Integer / Anzeige | Kumulierte bestätigte Laufzeit in Sekunden. | | `SGReadyZustand` | Integer | Bitwert der beiden logischen Kontakte |
| `Laufzeit` | Integer | Kumulierte bestätigte Laufzeit in Sekunden |
## Zusätzliche Properties Optionale Diagnosevariablen zeigen unter anderem Ist- und Sollleistung,
Verfügbarkeit, Anlaufstatus, Wiederholsperre, Sperrerholung, verbleibende
Mindestzeiten und die Sperrsumme der letzten 24 Stunden.
| Ident | Typ | Standard / Beschreibung | ## Adaption aus Enelix 1
| --- | --- | --- |
| `Kontaktwirkung` | Auswahl | `Sperrkontakte`; alternativ `SG Ready`. |
| `Kontakt1VariableID` | Integer | `0`; Sperre oder SG-Ready-Eingang 1. |
| `Kontakt2VariableID` | Integer | `0`; Erhöhung oder SG-Ready-Eingang 2. |
| `Kontakt1Invertiert` | Boolean | `false`. |
| `Kontakt2Invertiert` | Boolean | `false`. |
| `Leistungsermittlung` | Auswahl | `Nicht vorhanden`, `Berechnet` oder `Gemessen`; entspricht Quelle 0/1/2. |
| `LeistungsmessungVariableID` | Integer | `0`; nur bei gemessener Leistung. |
| `Zustandskennlinie` | String/JSON | `[]`; bei berechneter Leistung je Zustand Planleistung und Kontaktbelegung. |
| `BetriebsrueckmeldungVariableID` | Integer | `0`; optionale echte Betriebsrückmeldung. |
| `Mindestlaufzeit` | Integer | `0*` s; anlagengerecht festlegen. |
| `Mindeststillstand` | Integer | `0*` s; anlagengerecht festlegen. |
| `MaxSperrzeit` | Integer | `0` s; `0` deaktiviert die EMS-Sperrfunktion. |
Zeit- und wetterabhängiges Sperren beziehungsweise Erhöhen aus dem Altmodul | Bereich | Behandlung in Enelix 2 |
entfällt. | --- | --- |
| Grundzustände Normal, Sperre und Erhöhung | angepasst übernommen |
| Zwei physische Kontakte | angepasst und mit Invertierung sowie Rücklesekontrolle umgesetzt |
| Leistungsangebot an den Manager | neu nach Nachrichtenvertrag 4.0 implementiert |
| Mindestlauf- und Sperrzeiten | neu als ereignisbasierte Schutzlogik implementiert |
| Wetter-, Wolken- und Sonnenaufgangslogik | nicht uebernommen |
| Warmwasser-Schwellwert im Wärmepumpenmodul | nicht uebernommen |
| Fester zyklischer Fünf-Sekunden-Regler | nicht uebernommen; Ereignisse und Einmal-Timer |
## Zustand ## Lizenzierung und Manager
`Waermepumpenzustand`, `SperreAktiv`, `ErhoehungAktiv` beziehungsweise Der Manager erkennt das Modul über die Modul-ID
`SGReadyZustand` als Status, `Laufzeit_s` als Istwert und `{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}`. Es verwendet das bestehende
`Kommunikationsfehler` als Störung. Lizenzkontingent `consumer_single`, das im Portal als einstufiger Verbraucher
mit Wärmepumpe als Beispiel angeboten wird.
## Offene Punkte ## Sicherheitshinweise
- Der Formularbutton „Wassererwärmer über Wärmepumpe betreiben“ bleibt ohne Die beiden Ausgänge müssen unterschiedliche, schaltbare Boolean-Variablen sein.
Regelwirkung, bis Anschluss und Verriegelung gemeinsam definiert sind. Die Rückmeldung ist verpflichtend: entweder eine numerische Leistungsmessung
oder ein Boolean-Betriebsstatus. Bei ungültiger Konfiguration, fehlgeschlagenem
Schalten oder widersprüchlicher Rücklesung setzt das Modul seinen Status auf
Fehler und meldet sich dem Manager als nicht verfügbar.
+99
View File
@@ -0,0 +1,99 @@
# V4: getrennte Last-/Batterie-/SDL-Bilanz (Kandidat, keine Stellfreigabe)
## Status 2026-10-02
`NetzfahrplanV4Bilanzierung` ist ein reiner Rechen-/Validierungsbaustein. Noch keine
Einbindung in die laufende Telemetrie, den Trainer oder den Kontrolltest. Er ruft
keine IPS-, Netzwerk-, Archiv- oder Stellfunktionen auf. Ergebnis ist immer
`controlEligible=false`, auch bei geschlossener mathematischer Bilanz.
34 isolierte PHP-Pruefungen wurden mit der gleichen SHA256-Datei auf iot-symcon01
bestanden. Das ist kein Nachweis fuer die reale Hardware-/Messgrenzenzuordnung
und kein vollstaendiger PHPUnit- oder Symcon-Kernel-Test.
## Gefundener Unterschied in Lihrenmoos
Installierter Manager `Manager/module.php::leseMessleistungen()` verwendet:
Haus = PV + Netz - Summe der konfigurierten Batterie-Istleistungen
Konfiguriert ist hier die virtuelle EV-Leistung 52020, nicht die gesamte physische
Batterieleistung. Daher bleibt der externe SDL-Anteil und eine allfaellige
Abweichung der virtuellen Aufteilung im an den Prognosedienst gesendeten Hauswert.
`sendePrognoseTelemetrie()` sendet genau diesen errechneten Wert. Die parallel
existierenden Hausvariablen 46714 und 40713 sind NICHT automatisch diese Quelle.
Der bisherige Gateway `Bat_EV_SDL_V4` liest die physische Batterieleistung aus
47725, 35724 und 36380 und invertiert diese Vorzeichen. EV und SDL sind daraus
abgeleitete, separat gefilterte virtuelle Konten, keine zwei unabhaengigen Zaehler.
Die Filter koennen alte Teilwerte halten. `UpdateActualPowerSplit` aktualisiert die
Ausgabevariablen trotzdem. Ein neuer Ausgabezeitstempel beweist deshalb nicht die
Frische aller darunter liegenden Originalmessungen.
## Kandidat fuer die korrekte Bilanz
Mit einheitlicher Messgrenze und Vorzeichen Batterie+ = Laden, Netz+ = Bezug:
Verbraucherlast = Netz + PV - gesamte physische Batterieleistung
Grundlast = Verbraucherlast - separat geplante flexible Verbraucher
Zuordnungsrest = physische Batterie - virtuell steuerbare Batterie - externe virtuelle Konten
Externer Netzeffekt = externe virtuelle Konten + Zuordnungsrest
Netz = Grundlast + flexible Last + steuerbare Batterie + externer Netzeffekt - PV
Der Zuordnungsrest wird nicht erneut als Hausverbrauch trainiert, sondern separat
angezeigt. Eine grosse Abweichung sperrt die Datenqualitaet. Gegenlaeufige virtuelle
Konten (z.B. EV +42 kW, SDL -40 kW, physisch +2 kW) bleiben korrekt getrennt.
Spaetere Ladestationen/Boiler sind ueber `flexible_load` erweiterbar. Solange sie
nicht separat geplant werden, verbleiben sie in der nicht separat steuerbaren Last.
Keine Messgroesse wird doppelt subtrahiert. Ein negatives Grundlastergebnis wird
nicht heimlich auf null begrenzt. Numerische Nullen sind echte Werte; fehlende,
nicht endliche oder umtypisierte Werte sind keine Nullen.
## Originalzeit und Datenqualitaet
Jede Quelle hat explizite Variable/Parent/Ident, Faktor, maximales Alter und optionale
Ursprungsabhaengigkeiten. Alle Quellen werden zweimal gelesen. Wertewechsel waehrend
der Aufnahme werden abgewiesen; Zeitstempel werden nie kuenstlich verjuengt.
Abhaengigkeiten sind zyklenfrei, doppelte Quellen werden verhindert.
Eine virtuelle Summenvariable erbt das aelteste Datum ihrer physischen Eingangsquellen.
Konfigurierbare Alters-/Synchronitaetspruefung ist erforderlich. Diagnoseberechnungen
mit ungeeigneten Daten bleiben `quality_hold`, nicht `base_load`-Trainingsfreigabe.
Die momentanen Beispielgrenzen (60 s Alter, 30 s Versatz) sind konservative
Diagnoseparameter, keine garantierten Geraeteeigenschaften.
Die konkrete Solar-PV-Herkunft sowie AC/DC-/Wirkungsgradgrenzen aller Hybridgeraete
sind noch zu verifizieren. Eine algebraisch geschlossene Gleichung beweist diese
physische Messgrenze NICHT. Die vorgeschlagene Quellkonfiguration ist nicht freigegeben.
## Migration in die Prognose (noch offen)
1. Originalmessquellen und Messgrenze dokumentieren und Qualitaet ueberwachen.
2. Korrigierte Grundlast und externe Kanaele als NEUE versionierte Zeitreihen erfassen.
3. Bestehende Historien nicht ueberschreiben oder als bereinigte Historie umbenennen.
Rekonstruktion nur bei ausreichenden zeitlich passenden Originaldaten.
4. Prognosemodell auf der korrigierten Basis trainieren/validieren.
5. Zukunfts-SDL separat liefern. Unbekannt ist nicht null; eine geschaetzte Annahme
muss explizit bezeichnet werden und darf nicht als veroeffentlichter SDL-Fahrplan gelten.
6. Erst nach diesen Pruefungen eine echte Bilanzierungsreferenz im Trial-Gate verwenden.
`mappingId` aus dieser Klasse ist ein Konfigurationsfingerabdruck, KEIN solcher Nachweis.
## Gateway-Ausfallverhalten (nicht getestet/freigegeben)
Quelltextpruefung des installierten Alt-Gateways ergab keinen Frischetest fuer
`Nennleistung_Soll_EV` im Weg `Update -> ApplySetpoints`. Der zwischengespeicherte
EV-Sollwert wird weiter verwendet, solange die Gateway-Schleife laeuft. Der Eingang
wird von `scripts/31800.ips.php` geschrieben; dieser enthaelt ebenfalls keine Lease.
Der vorhandene 30-s-Timeout des EMS-Batterieadapters ist ein Schutz innerhalb von
Symcon, nicht ein unabhaengiger Wechselrichter-Timeout bei Rechnerausfall.
`State=false` im Alt-Gateway beendet `Update` ohne dort Nullwerte zu schreiben.
Diesen Schalter nicht als nachgewiesenen Hardware-Notstopp verwenden.
Keine Kommunikationsunterbrechung, kein Abschalttest und keine neue Stellwertausgabe
wurden an der realen Anlage durchgefuehrt. Auch aus dem fehlenden Code kann NICHT
gefolgert werden, dass das Geraet selbst keinen Timeout hat. Exakte Typen/Firmware
und das wirksame geraeteseitige Verhalten muessen noch festgestellt werden.
Wichtig fuer spaetere Anpassung: Ein EV-Befehlsablauf darf nicht pauschal den
externen SDL-Auftrag loeschen. Ein Kernel-interner Timer ersetzt wiederum keinen
Schutz bei komplettem Kernel-/Rechner-/Kommunikationsausfall.
+131
View File
@@ -0,0 +1,131 @@
# V4 application data and forecast integration
## Implemented application path
`ManagerNetzfahrplanV4DatenTrait` records the configured raw power/SOC/counter sources
inside the existing Manager. This replaces the need for permanent standalone
observation categories once the native delivery path has been accepted. It never
issues device commands. New acquisition properties default disabled.
The private outbox at `data/enelix-v4/<installationId>` is append-only and delivery
is acknowledged by dataset and capture time. Unacknowledged data survives network
failures and 429 responses. The native payload budget is below the existing portal
1 MiB request-body limit. Existing authentication and V4 rate limiting are reused.
Server application source is now versioned under `services/netplan-v4`, rather than
existing only as an untracked server working tree. No live measurements, databases,
credentials, dependency binaries or settings exports are included in this directory.
The existing worker handles per-plant dataset ingestion, physical five-minute
rollup, model training, model storage and corrected-load substitution into the V4
optimizer. It continues to consume the existing PV forecasts and price/operation
inputs. The public device route can append measurements but cannot alter source
meaning, register a dataset, change live permissions or arm a controlled trial.
## Explicit settings and model meaning
- `forecastSource=legacy` preserves the existing forecast input by default.
- `forecastSource=corrected_profile` plus `measurementDataset` uses the trained
corrected physical load. Missing models are reported; legacy house values are
not substituted silently.
- `trainingCadence=daily|weekly` is now connected to real profile fitting in the
application worker, independently from the optimizer refresh frequency.
- Corrected load profiles are tagged `physical-profile-v1`: robust daily profile,
recent-day profile and weekday/weekend profile paired with PV families 3/13/23.
They are not claimed to be the previous load models unchanged. Economic automatic
selection still requires the separate, not yet finished cost-replay integration.
- Initial models are explicitly bootstrap models. Later candidates record causal
holdout errors; an overlapping validation window cannot certify a promotion.
- Current SDL request persistence is only a labelled shadow scenario. Unknown or
stale SDL is not silently zero, and the scenario is not a guaranteed future
schedule or a robust SDL-aware production control policy.
## Lihrenmoos package scope
Effective EV capacity 161.44 kWh and power 39 kW remain unchanged; SDL reserve is
already excluded. The prepared dataset `lihrenmoos-physical-v1` uses a configured
physical estimate with SolarEdge signed terminal power counted once. It requires
at least 24 equivalent usable hours before the first bootstrap profile; the
history window is 28 days. Coverage policy 95% with maximum 10-second unsupported
portion is an explicit modelling assumption. Gaps and source ages remain visible.
It is not an independent electrical metering-boundary proof.
Server entry point on the service host:
python3 /home/agent/services/netplan-v4-shadow/commissioning/deploy_application.py \
--plant e3a08f9e-af12-4695-99bd-8b51c0520021 --apply
Default without --apply checks source only. The explicit apply command rebuilds
and tests V4 in Python 3.11, tests portal routing, backs up the V4 database and
recreates only V4 and the portal. It configures the dataset but DOES NOT select it
for the existing plan automatically. No forecasts/tariff importer or controllers
are restarted by the server command. A previous-image rollback is prepared.
Then, inside Lihrenmoos Symcon:
require '/srv/agent/netplan-v4-application-build/install.php';
This is a data-only patch of the currently installed passive manager, with file
backup and library reload. It does NOT install the development control-trial
changes in Manager/Batterie. The first runtime invocation imports at most 48h of
the existing 23-channel observer as a durable backlog, preserving the original
files, then activates acquisition and delivery. If Symcon module registration is
not immediately available, the installer reports that a single repeat is needed.
The two temporary standalone samplers are not stopped automatically by this
initial package; disable them only after native batch acceptance and history
continuity are confirmed. This package creates no new root diagnostic category.
## Validation performed before deployment
219 Python tests passed in the isolated host QA runtime, including actual synthetic
measurement -> profile -> existing optimizer -> stored shadow plan. 16 Node proxy
checks passed. 21 PHP data-path checks and 5 installer scenarios passed with mocked
IPS/HTTP and temporary files on the test host. Full staged PHP syntax passed.
No new container or new manager data integration has yet been run in production.
Evidence: service `APPLICATION_PIPELINE_TEST_RESULTS.txt`,
`APPLICATION_PORTAL_TEST_RESULTS.txt`, `commissioning/application-source/RELEASE.json`;
test host `netplan-v4-application-build/PREPARATION.json` and `TEST_RESULTS.txt`.
## Remaining product scope (do not disguise as complete)
This integrates application data and load forecasting, not completed production
commissioning. Economic replay-based automatic family selection, full corrected
feedback/control integration, long-term outbox/server retention and real live/
failure acceptance remain open. Existing trial gates are unchanged. No sensor,
accounting or hardware evidence identifier is fabricated by data ingestion.
## Installer class-path correction (2026-10-03)
The original commissioning installer loaded `source/libs/NetzfahrplanV4Messaufnahme.php`
while importing observation history. A later Manager callback loads the installed
copy at `modules/Enelix-EMS/libs/NetzfahrplanV4Messaufnahme.php`. PHP's once-only file
inclusion does not deduplicate class declarations across those different files.
The dependent `NetzfahrplanV4Bilanzierung` class is subject to the same collision.
The tested installer-only fix is preserved in
`examples/V4ApplicationData/application-class-loading.patch` (SHA256
`6d53d74d4059bb1ae3a446350637a4f4a4a73cc2fd64d2fc2f0967bd23ec7173`).
When building a commissioning package, load both classes only from the installed
module tree, after validating their expected source hashes. Before any require,
check both already-loaded origins without autoload. Reject a foreign staged origin
with a controlled error; do not hide it by wrapping the class declaration in a guard.
Include `installed_capture_bootstrap.php` in the package checksum map. Do not change
the installed-module manifest or bless unrelated source changes.
The Lihrenmoos staging entrypoint has this fix applied, with backups of its original
installer and checksum map. No running modules, data files, timers, native callbacks,
server components or actuator settings were changed by the repair. Actual completion
still requires the user's fresh Symcon invocation of the corrected installer.
Validation: the original two-path failure was reproduced with the real PHP classes.
Thirteen additional isolated installer scenarios passed, including callback/reload
loading order, preloaded dependencies, history import, repeat/finished import,
foreign-class refusal, partial-import preservation, source drift, registration wait
and first installation. These are PHP CLI tests with mocked IPS in temporary trees,
not a completed test in the actual Symcon kernel.
Evidence on the test host:
`/srv/agent/netplan-v4-application-build/class-loading-fix/TEST_RESULTS.json` and
`FIX_RESULT.json`. The test harness and exact candidate source are retained there.
+96
View File
@@ -0,0 +1,96 @@
# V4: begrenzter Regeltest (Entwicklungskandidat, NICHT in Betrieb)
Stand 2026-10-02. Diese Erweiterung ist weder Produktionsfreigabe noch ein
Installations-/Startauftrag. Bestehende Schattenplaene und ihre `shadow_seen`
Bestaetigungen bleiben unveraendert. Ein Vorschauwert allein darf niemals
als Stellfreigabe interpretiert werden.
## Freigabekette
1. Serverseitige separate Allowlist `NETPLAN_V4_CONTROL_TRIAL_PLANTS` (standardmaessig
leer). Interner authentifizierter `planner/trial/arm`-Aufruf mit expliziter
Bestaetigung, neuer Sitzung, gepruefter Plan-ID/Revision, Batteriezuordnung,
Zeitfenster, Leistungsgrenzen und dokumentierter Geraete-Watchdog-Abnahme.
Die bestehenden oeffentlichen Portal-/Geraeterouten proxien diesen Aufruf NICHT.
2. `NetzfahrplanV4RegeltestErlaubt` lokal im Manager UND im Batteriemodul (jeweils
Standard false). Alte `NetzfahrplanAktiv`-Freigabe muss false bleiben.
3. Zusaetzlicher bewusster lokaler Start einer bestimmten Sitzung. Ein Reload,
Netzwiederkehr oder gespeicherter Haken startet keine Sitzung automatisch.
4. Der Manager prueft den empfangenen Schattenplan und die getrennte
`controlled_trial_authority` gemeinsam. Sie sind gebunden an Anlage, Batterie,
beide Instanz-IDs, Revision, manuelle Modellfamilie und stabilen Planungskontext.
5. Die Batterie nimmt nur einen separaten `controlled_trial_command` mit eigener
Sequenz und kurzer Gueltigkeit an. Normale Managerbefehle erneuern diese
Gueltigkeit nicht. `shadow_seen` bleibt eine Empfangs-, keine Ausfuehrungsmeldung.
Die Serverantwort bleibt eine Schattenplan-Antwort (`liveEnabled:false`). Eine
getrennte Testfreigabe ist unter `controlledTrial` und
`controlledTrialAuthorized` sichtbar. Der Schattenmodus allein ist bei kuenftig
bewusst gestartetem Test deshalb kein Nachweis fuer 'keine Batteriebewegung'.
Die Controller-Diagnosen benennen eine angenommene Sollvorgabe, nicht bereits
physisch gemessene Umsetzung oder Ersparnis.
## Bewusst begrenzter Pilotumfang
- Genau eine eindeutig zugeordnete Batterie je Test; Familie manuell festgelegt.
- 30 bis 1800 Sekunden Sitzung, maximal 5000 W Laden und 5000 W Entladen.
- Server-, Manager- und Batteriegrenze koennen strenger sein; nie grosszuegig
ersetzen oder still auf 39 kW hochsetzen.
- Gesamtsollleistung, NICHT Zusatzleistung zur bereits fliessenden Batterieleistung.
- Andere Verbraucher bleiben im alten Verteiler. Ihre erwartete Leistungsaenderung
wird einmal bei der Batteriekorrektur beruecksichtigt.
- Batterie wird nur einem Stellwertpfad zugeteilt, nicht gleichzeitig dem alten
und neuen Verteiler. Ist der Test ungueltig, stoppt er und es wird eine NEUE
normale Verteilung angefordert; kein alter gespeicherter Sollwert wird restauriert.
- Physische Grenzverletzung, die innerhalb der Pilotleistung nicht behebbar ist,
beendet den Test zugunsten der bestehenden lokalen Regelung.
## Ausfall- und Zeitverhalten
- Serverfreigabe maximal 90 Sekunden, lokal regelmaessig frisch abgerufen.
Serverseitiger Widerruf wirkt nicht magisch sofort: Cachefrist beachten.
- Einzelner Batteriebefehl maximal 10 Sekunden; Batterie kontrolliert im laufenden
Kernel mit 1-Sekunden-Timer zusaetzlich zu Messwert-Callbacks.
- Manager liefert kurze Befehle im Test mit zusaetzlichem 2-Sekunden-Takt.
- Monotone Laufzeit und Kalenderzeit werden getrennt geprueft. Eine rueckwaerts
springende Systemuhr darf eine Sitzung nicht verlaengern.
- Abbruch widerruft die Sitzung VOR dem Nullstellversuch. Schreibfehler werden als
`stop_failed`/nicht bestaetigter Stopp sichtbar, nicht als sicherer Zustand.
- Reserve, Hysterese, aktuelle Lade-/Entladeleistung, SOC und Verfuegbarkeit werden
auch im Batteriemodul erneut geprueft. Sicherheitsreduktionen werden nicht durch
die Umschaltsperre verzoegert; Richtungswechsel laufen ueber null.
- Laufzeitsitzungen liegen nur im Buffer, nicht als automatische Wiederanlauf-Freigabe.
Ein persistenter Marker sorgt bei Wiederinitialisierung fuer einen erneuten
Nullstellversuch, falls ein Testwert zuvor ausgegeben worden sein koennte.
WICHTIGE GRENZE: PHP-Timer und Software-Nullstellversuche funktionieren nur bei
laufendem Kernel und erreichbarem Wechselrichter. Sie sind KEIN physischer
Not-Aus und kein Beweis fuer Abschaltung bei Serverabsturz, Stromausfall oder
unterbrochener Stellwertverbindung. Dafuer ist ein separat nachgewiesener
Befehlsausfall-/Watchdogmechanismus am Geraet bzw. Gateway erforderlich.
Das Feld `NetzfahrplanV4GeraeteWatchdogNachweis` ist eine Referenz auf die gepruefte
Dokumentation/Abnahme, keine automatische Hardwarepruefung und kein Defaultwert.
## Noch offene Freigabepunkte (nicht mit Platzhaltern umgehen)
- Der laufende Lihrenmoos-Prognosestrom ist noch `house_total`. Fuer Testfreigabe
muss `base_load` mit nachgewiesener Quelle/SDL-Abgrenzung und
`accountingEvidenceId` aus dem geprueften Adapter vorliegen. Ein Labelwechsel
oder ein erfundener Nachweis reicht nicht; Quelle und Messbilanz pruefen.
- Geraete-/Gateway-Watchdog bei ausbleibenden Stellbefehlen nachweisen.
- Vollstaendiges Manager-/Batterie-Callbackverhalten im IP-Symcon-Kernel pruefen.
Die isolierten Tests verwenden echte neue Traits, aber simulierte IPS-Aufrufe,
Messwerte, Zeit und Register. Reale Modbus-/VGT-Stellreaktion steht aus.
- Zielcontainerpruefung des neuesten Serverstands und Feldabnahme.
- Keine durchgehende unbeaufsichtigte Produktion, kein automatischer Mehranlagenstart.
Modellvergleich, Trainingsanbindung, Mehranlagenabnahme und Betriebs-Release
bleiben weitere Aufgaben.
## Quelltests
`php tests/V4ControlTrial/checks.php` nur im separaten CLI-Testprozess ausfuehren,
niemals im laufenden Symcon-Skripteditor. Die Datei verweigert einen realen
Symcon-Kernel. Eine PHPUnit-Huelle bindet sie an die normale Testsuite an.
Der Server-Test verwendet ausschliesslich synthetische Daten/Temporaerdatenbanken.
Keine Testfunktion liest Zugangsdaten oder schreibt eine reale Stellvariable.
+108
View File
@@ -0,0 +1,108 @@
# V4 corrected physical feedback -> local bounded control
## Scope of this delivery
The shared physical feedback reader is now connected to the actual Manager preview,
the existing explicit bounded-trial command path, and an independent reread in the
Battery module immediately before hardware output. This is not a new observer.
The installer leaves both local trial permissions disabled and cannot arm a server
trial. Normal operation continues through the existing local controller.
Lihrenmoos binding: Manager 17004, Battery 44234, virtual asset
`anlage01-virtual-ev`. Effective 161.44 kWh / 39 kW unchanged. Source definitions
come from the existing versioned capture config and current saved topology, not
from new guessed device models. No old histories, sender cursors, virtual energy
accounts, SDL requests, archive policies or inverter polling are changed.
## Feedback and meaning
`NetzfahrplanV4Rueckmeldung` checks original value timestamps and object identities
in two read passes. Live max age is bounded to 60s and inter-source skew to 30s.
History interpolation/publication estimates are not accepted as live feedback.
The mapping fingerprint normalizes numbers and ordering, so a JSON property round
trip does not create a new identity. A mapping change cancels an active session.
Two adapters exist: sum of explicitly assigned physical meters, and a virtual
EV/SDL partition model. For the latter, gateway state and current requests are
read but unchanged zero commands are not mistaken for failed sensor heartbeats.
When SDL request is exactly zero, current physical battery power is used rather
than the filtered virtual EV display. A new EV request does not invent an immediate
physical response or force the measured sign toward the desired sign.
When SDL is nonzero, source timestamps must cover the latest request change and
physical tracking error must remain within the configured small tolerance. The
partition is explicitly estimated, not separately measured. Opposing EV/SDL flows
cannot be uniquely identified; they remain ineligible for trial control. Unknown
or stale physical sources never fall back to held filtered EV values.
`allowEstimatedForTrial` is false in the prepared Lihrenmoos config. Configuring
feedback or seeing a valid preview therefore does NOT accept the estimate for a
trial and does NOT satisfy accounting/device-watchdog evidence or other gates.
This adapter does not claim to resolve arbitrary simultaneous SDL activity.
## Actual control integration
The same coherent grid/battery snapshot is used in the Manager. Battery command
is a TOTAL power, not a delta added to the existing command. Planned changes in
other consumers are counted once and remain explicitly separate from measured grid.
A separate explicit server authority, both local consents, accounting evidence,
device-watchdog evidence, a deliberate session start and fresh plan remain required.
The existing trial is limited to 1800s / 5000W per direction; individual command
leases remain <=10s and cannot be renewed by replay or ordinary manager messages.
The local feedback mapping fingerprint and grid caps are bound to the session.
Immediately before writing, Battery rereads physical feedback and applies current
SOC, reserve, hysteresis, availability and change-lock constraints. Its resulting
command must still meet the bound grid limits; an unreachable target revokes the
trial rather than claiming that the planned grid value was achieved. It cannot
claim physical performance merely because a register call returned successfully.
Abort revokes the lease and attempts zero, and the Manager wrapper then runs at
most one fresh ordinary allocation, clearing cached pre-trial targets. It does
not recurse forever or revive an old plan. A failed stop stays explicitly failed.
The watchdog still requires a functioning kernel. Independent hardware failure
behavior is NOT certified by software tests.
Outside a V4 session the existing battery power path is unchanged. The compatible
BatterieRegler library includes the already developed optional reserve charging
limit; with its default (maximal charge) 13,824 old/new ordinary-offer test cases
match exactly. That comparison is not an on-device dynamics test.
## Installation
A single prepared Symcon script is the next runtime action:
require '/srv/agent/netplan-v4-feedback-stage/install.php';
It hash-checks nine changed files and all version-matched dependencies, backs up
existing source, installs dependencies before modules and reloads the ENELIX
library. Reload/ApplyChanges may execute existing initialization routines; this
is not promised to be a zero-effect library reload. It configures only the new
feedback mapping on Battery. No server redeployment is required. No shared classes
are included from staging, avoiding the earlier duplicate-class problem.
A delayed module-registration result requests one repeat. A valid installation
can still report an unavailable feedback sample; that is not reinterpreted as zero.
Any preexisting trial permission, unsaved change, or concurrent source change stops
the installer. Source write failure rolls back its own changed files; reload errors
are reported for review and do not automatically authorize anything.
## Validation and remaining acceptance
125 isolated PHP functional checks cover the reader, real receiver, actual trial
traits with a simulated register driver, the extracted actual Manager fallback
wrapper, and the real Battery message builder with optional diagnostic variables absent.
Source quality is stored internally; a last partition estimate is not relabelled as a
measured value just because a trial ends or a diagnostic variable is hidden. Four full module linkage checks and ten isolated installer scenarios also
pass. Full PHP syntax and dependency hashes are checked. Neither these fixtures nor
the ordinary-offer comparison run the real Symcon kernel or an inverter.
Evidence: test host `/srv/agent/netplan-v4-feedback-stage/PREPARATION.json`,
`TEST_RESULTS.txt`, `INSTALLER_TEST_RESULTS.json`, `ORDINARY_OFFER_TEST.json`.
Runtime installation, real feedback reception, and a separately authorized field
trial remain outstanding. This finishes the code connection for bounded control;
it is NOT an unrestricted continuous-production controller or a hardware
commissioning certificate. Corrected model selection and actual real-world savings
must be checked against their own data. Existing safety gates are not bypassed.
+67
View File
@@ -0,0 +1,67 @@
# Passive V4 data capture - user inventory and raw acquisition
Status 2026-10-02: source implementation, CLI/mocked tests and staged user installation.
Not part of the live manager, not a control or training release.
## Lihrenmoos user-reported inventory (not applied to running configuration)
- GoodWe 1/2: each 50 kW AC, each 156 kWh reported physical battery capacity.
- SolarEdge: 10 kW AC, 10 kWh reported physical battery capacity.
- EV allocation: 30 kW / 160 kWh total, understood as 10 kW EV per inverter.
- PV roof: approximately 20 kWp east and 20 kWp west. Tilt/individual strings unknown.
- Nominal battery sum: 322 kWh. 162 kWh nominal difference to EV allocation is NOT a
verified usable SDL capacity. AC ratings are not verified battery charge/discharge ratings.
- Exact types, firmware, common AC meter boundary and device watchdog remain unverified.
- Current saved manager topology still reports 161.44 kWh / 39 kW. Gateway currently
lists 130/130/8 kWh and 57/57/5 kW. These may use a different usable/nominal basis.
Do not rebase virtual SOC or change current controls based on these notes alone.
## Why collect before boundary approval
Record the actual original numerical channels now, with source metadata and timestamps.
The algebraic base-load candidate is useful for diagnosis, but NEVER a certified base-load
training sample until the physical AC/DC boundary and historical recipe are verified.
No dependence on hardware-watchdog approval for passive recording.
21 channels: PCC power, legacy displayed PCC power, PV for three inverters, three physical
battery powers, EV/SDL virtual powers and SOCs, three physical SOCs, dynamic EV limits,
T1/T2 import counters, EV/SDL requested setpoints (READ ONLY).
A cyclic script reads values already present in Symcon every 30 seconds. It does not issue
additional Modbus polls. Original VariableUpdated/VariableChanged are retained; these are
Symcon timestamps, not independent device-measurement timestamps. Two-pass reads detect
concurrent changes; missing/non-numeric/re-used channels remain explicit quality failures.
## Storage and interpretation
Daily append-only raw-YYYYMMDD.jsonl in the separate capture stage data directory, UTC
capture times plus original source timestamps, immutable mapping/inventory snapshots by
SHA256. Raw errors and quality_hold are recorded too; nothing is invented as zero.
The raw observations are a source for later documented 5-minute aggregation, not themselves
aligned 5-minute means. No interpolation, training publication or future SDL assumption.
A 512 MiB journal quota, 64 MiB daily limit, 256 KiB record cap and 100 MiB free-space
reserve bound recording. Limits fail visibly without deleting old data. File permissions
0640; no upload credentials, request bodies, complete instance configurations or secrets.
A separate status variable and STATUS.json show latest capture and failures. Process restart
retains files; an incomplete final JSONL row is preserved for manual review, not extended.
## Install / stop
Staged user entry point: /srv/agent/netplan-v4-data-capture-stage/install.php
Execute in the Symcon PHP editor (not a Linux shell):
require '/srv/agent/netplan-v4-data-capture-stage/install.php';
Installer checks exact source hashes and numeric source identities before creating a NEW
root category ENELIX_V4_PASSIVE_CAPTURE, a status string, collector script and stop script.
IPS_SetScriptTimer enables only this new script at 30 seconds. Repeating installation is
idempotent. Unrelated/edited scripts or object identity collisions are not overwritten.
Stop using the newly created script 'Nur diese Messaufnahme stoppen'; it disables only
this collection timer, retains collected files, and does not stop the manager or battery.
No module reload, existing archive change, existing timer/poller change, service restart,
forecast selection, battery reservation or actuator command is included.
Current tests: 36 core/file tests + 19 mocked native lifecycle tests with PHP CLI on the
test host. First real capture and future timer execution require the user activation.
These tests do not certify hardware mappings or a Symcon-kernel run.
+90
View File
@@ -0,0 +1,90 @@
# Historical publication timing (2026-10-03)
## Implemented scope
`services/netplan-v4/netplan_v4/history_timing.py` implements optional, bounded
retrospective estimates between consecutive identical source values at DIFFERENT
original publication timestamps. It is used by the existing measurement pipeline,
model training and forecast source substitution. It is NOT a real-time estimator,
a measurement certificate, an actuator authority or a repair of device communications.
No source `maxAgeSeconds` is changed. When a regular hold expires, only its tail
until the next qualifying original publication may be estimated. Strict processing
remains the default. The following forbid the estimate: unequal endpoints,
nonfinite/missing observations, conflicting same-timestamp values, collector gaps,
invalid source intervals or a span beyond the explicit source-specific bound.
There is no extrapolation past the last observation and no fabricated zero.
Each window reports `publicationEstimatedSeconds`, per-source estimated portions,
uncovered-source seconds and `availableNotBefore`. Training/validation must not use
a confirmation or receipt before it was actually available. Backfilled windows
cannot establish a supposedly causal holdout at a date before their availability.
All physical windows remain configured estimates; even full support is not proof
that the physical waveform stayed constant between the two endpoint readings.
## Evidence and model assumptions
Read-only source configuration projection on the test server, snapshot
2026-10-03T07:47:24Z: GoodWe 1 instance 19742 (ModBus Device) has Poller 60000 ms;
GoodWe 2 instance 57658 has Poller 5000 ms; SolarEdge scale instance 48996
(ModBus Address) has Poller 20000 ms. No polling setting was changed.
Projection program: /srv/agent/netplan-v4-application-build/inspect_source_update_policy.py.
Official Symcon documentation accessed on 2026-10-03:
https://www.symcon.de/de/service/dokumentation/modulreferenz/geraete/modbus-rtu-tcp/vorlagen/
It documents value publication for ModBus Device on changes or when the variable is
older than 60 seconds, including ordinary and virtual addresses. VariableUpdated
is therefore not automatically the last successful device poll. The documentation
is not a proof that a particular installed device link was healthy.
Explicit Lihrenmoos HISTORICAL estimate bounds:
- GoodWe 1 PV/physical storage: equal original publications at most 130 s apart
(60 s publication suppression + configured 60 s poll + 10 s scheduling allowance).
- GoodWe 2 PV/physical storage: at most 75 s (60 + 5 + 10).
- SolarEdge scale: equal scale publications at most 90 s apart. This is a modelling
bound based on the configured 20 s polling and observed sparse scale publication,
not a manufacturer guarantee and not proof of the same internal implementation
as ModBus Device. Raw AC power remains under the original strict limit.
The 10 s margins are explicit scheduling assumptions, NOT measured timing guarantees.
Gaps of many minutes (e.g. the previously observed GoodWe gaps above 600 s) are NOT
bridged. Nonzero changes are never interpolated by this policy.
## Versioned dataset without restarting collection
`lihrenmoos-physical-published-v2` references immutable observations in
`lihrenmoos-physical-v1`. No observations are copied, deleted, relabelled or resent.
Registration requires the same installation and identical original mapping,
formula, coverage and training configuration. Cycles/reference chains and device
uploads directly into a derived dataset are rejected. Original ingestion continues
unchanged in the Manager. The 24 usable-hour minimum stays in force.
Models/windows are stored separately for v1/v2 and record their timing policy. An
existing selected model/family is not automatically switched by this release.
The current SDL request still uses the strict current-state requirements; no
historical bridge grants a future SDL schedule or a live battery permission.
## Tests and release
254 isolated Python tests passed (219 previous + 35 new including simulated
installation/recovery). Includes the actual service pipeline from referenced
observations to model and optimizer using synthetic records. No new target-container
or field-history evaluation has been run with this release yet; do not infer an
improved real coverage percentage or production acceptance from unit tests.
Evidence: /home/agent/services/qa/history-timing-20261003/ALL_TEST_RESULTS.txt.
Prepared command on enelix-services (root required for Docker):
python3 /home/agent/services/netplan-v4-shadow/commissioning/deploy_history_timing.py \
--plant e3a08f9e-af12-4695-99bd-8b51c0520021 --apply
Default without --apply validates staged source only. The apply path backs up source
files, builds and runs the exact target tests, backs up the V4 SQLite database, and
recreates ONLY the V4 service. A failed test restores source without a service
restart; failed post-deploy validation attempts the previous image and preserves
additive data. Concurrently modified files are not silently overwritten.
No portal/forecast/tariff/Symcon restart, no new sensor requests, no change to
161.44 kWh / 39 kW, SOC accounting, old raw samplers or actuator permissions.
The new dataset is registered but not selected for the active forecast. A root
execution and successful report are still required to install it. Previous release
manifests remain historical records; do not bypass them to reapply an older build.
+52
View File
@@ -0,0 +1,52 @@
# Numeric mapping identity defect - 2026-10-03
## Confirmed from installed code and read-only evidence
The pending batch contains 120 observations: zero duplicate timestamps, zero
backwards pairs, but 75 identity failures. The initial 45 observations in this
batch are imported observer records; later native captures use the saved manager
configuration. Evidence: test-host `PENDING_DELIVERY_REVIEW.json`.
The configured mapping fingerprint is
`517d1907631c7f152096e21759bb3452cdd0b4b1b73f91f1c7cd3bd62d8aaa8b`.
Reproducing native configuration loading generates
`3a1a9cf42aae49f55385d2350da74cae780ae12f170e4976849591e48a393994`.
The sole difference in the compared configurations is numeric representation of
`accounting.splitToleranceW`: prepared float 100.0 versus saved integer 100.
Both inventory fingerprints and all measurement definitions match.
`NetzfahrplanV4Bilanzierung::konfigurieren` validated this field using a function
which returns a float, but discarded the normalized return value. Other numeric
factors were already assigned from that validator.
## Tested source correction
Assign the returned float to `splitToleranceW`. Equal numeric settings now retain
the original fingerprint through a plain JSON property round trip. Actual numeric
changes still produce distinct fingerprints, and boolean/string values are still
rejected. Input configurations are not mutated.
42 isolated PHP checks passed on the test host: 34 accounting regressions plus
8 numeric identity regressions. A separate check with the actual prepared capture
configuration reproduces the configured fingerprint after the property round trip.
Evidence: `/srv/agent/netplan-v4-application-build/NUMERIC_MAPPING_TEST_RESULTS.json`
and `NUMERIC_MAPPING_TEST_RESULTS.txt`. Library SHA256:
`ffda2a03dbb226a4405a2ad66704c080a9dc475ce64282ad3f0d7d60034ab199`.
## Not deployed / recovery still incomplete
No installed library, original outbox, cursor, server validator, settings, dataset
registration or actuator permission was changed in this turn. Future-capture
normalization alone does not recover already stored native observations. The
server correctly rejects an entire batch containing a mismatched mapping.
An untested proposal to send distinct immutable dataset versions in separate
batches was removed from the development source and preserved only as a draft at
`/home/agent/services/qa/numeric-mapping-20261003/UNTESTED_SENDER_DRAFT.patch`.
Metadata preparation files are not an installation or permission to ingest into
an unregistered dataset. Tool writes for the complete server recovery package were
blocked; no alternative deployment or identity-check bypass was performed.
Next recovery must preserve original records and provenance, retain exact
per-dataset identity validation, acknowledge only accepted data and include tests
for the mixed 45/75 batch, retries, partial ACKs and no cursor/data loss. Do not
skip the rejected block, rewrite hashes in raw journals or re-run old installers.
+48
View File
@@ -0,0 +1,48 @@
# V4 plan reception and local preview (observation only)
This module does not execute a battery command. The existing regulator and its
setpoints remain unchanged. `NetzfahrplanAktiv` must remain false.
The optional `NetzfahrplanV4EmpfangAktiv` property defaults to false. When enabled
with the existing shadow sender, one bounded GET is made before the next native
operation POST, avoiding reading the plan immediately after queuing its replan.
A successful inspection acknowledges `shadow_seen` once per plan/step. This is
not `applied`. An HTTP failure clears the local preview; it does not change the
old control mode or its connection credentials.
The V4 service must first support `receiverProtocolVersion=1`, installation IDs
in envelope and plan, per-input event references, and the stable controlContext
used at calculation time. Old or cross-plant responses are deliberately refused.
The client checks fresh/lastRun/pending status, exact configuration revision,
model-family registry, known price coverage, contiguous half-open five-minute
intervals, asset mapping and power balance. Numerical feasibility checks are
not evidence of economic performance or permission to control devices.
A cache watchdog refreshes the diagnostic display every 10 seconds, without any
network requests. Cached response older than 90 seconds, plan older than 900
seconds, an expired step, invalid local measurement or policy change invalidates
the preview. Missing targets are JSON null and '-' in the display, never zero.
Restart/ApplyChanges clears the cache. Existing control functions are not edited.
The preview is currently limited to ONE battery. Multiple batteries are still
accepted by the server planner; local allocation is explicitly unsupported in
this stage. Preview feedback uses actual grid and battery power, local capacity,
available power, minimum/maximum SOC, discharge hysteresis, network-charging
permission and import/export/month limits. It exposes any remaining grid-limit
violation instead of pretending the battery can resolve it. It is a momentary
calculation only, NOT the commissioned real-time regulator. PV curtailment and
external SDL/base-load separation must still be validated before any live test.
Two read-only variables below the manager expose a HTML summary and detailed
JSON. No EnableAction, consumer RequestAction, SendData or actor property writes
are used. The existing form.json and web portal assets are not modified.
Offline checks:
php tests/V4Receiver/protocol_checks.php
php tests/V4Receiver/receiver_checks.php
The PHPUnit wrapper runs these in separate subprocesses, so fake IPS and curl
functions cannot contaminate other module tests. The mocked transport uses no
real credentials or connections. Full Symcon kernel testing and installation
are separate commissioning steps; these unit tests are not field acceptance.
+88
View File
@@ -0,0 +1,88 @@
# Separate physical load / EV-SDL observation (NO dispatch or training permission)
## Implemented 2026-10-02
- `NetzfahrplanV4Messsicht` recalculates independent physical-load, physical-storage,
unfiltered modelled allocation and SolarEdge-origin views from explicitly identified
raw snapshots. Quality failures in held virtual accounts do not veto otherwise
consistent physical inputs. Missing/stale physical inputs still invalidate their
respective view. No automatic relaxation of age/skew limits.
- EV/SDL model: delta = physical - requestedEV - requestedSDL; distribute delta in
proportion to the absolute requested powers. The SDL component is obtained from
physical minus modelled EV to preserve numerical closure. This is an accounting
convention, NOT independently measured EV and SDL and NOT a verified response.
- Command changes more recent than the oldest physical feedback are flagged. Unknown
change timestamps, tracking mismatch, aged/missing origins and idle unallocated
power remain explicit. There is no held-value filter and no forced convergence of
displayed power to the requested value. All proposals keep `canDispatch=false`.
- Actual legacy filter outputs are displayed solely for comparison. They are not
reused as inputs of physical-load or unfiltered partition calculations. No changes
to the legacy filter, physical driver, EV/SDL setpoints, SOC integration or accounts.
## New read-only origin finding
Selected event 38921 writes legacy PV variable 20335 using:
a = 10 ** GetValue(41853) * GetValue(37975) + GetValue(21447)
if (a < 0) a = 0
SetValue(20335, a)
Arithmetic statements were inspected without exposing arbitrary scripts/credentials.
Verified saved configuration: variable 37975 parent 35514, register 40083; scale
41853 parent 48996, register 40084; battery 21447 parent 30789. An independently
updated 20335 is therefore not an independent PV reading and cannot certify all
input timestamps. A second constrained arithmetic read confirmed the condition
`if ($a < 0)`; this clamp applies to the legacy calculated PV value, not to the
new signed terminal-power observation. Neither proves the full AC/DC boundary.
The staged observer additionally records the ORIGINAL mantissa and exponent, not
only their periodically rewritten derived PV result. The alternative candidate
uses the scaled terminal power ONCE for this inverter (removing its derived-PV
and battery terms). GoodWe AC/DC boundaries and full-site physical boundary are
still unverified. This is not a definitive corrected house meter or live forecast.
## Standalone installation / operational scope
Stage on test host: `/srv/agent/netplan-v4-separated-observer-stage`.
User invocation inside Symcon only:
require '/srv/agent/netplan-v4-separated-observer-stage/install.php';
Creates only a NEW root category, three textual diagnostic variables, observe and
stop scripts and a NEW 30-second timer. Reads 23 existing numeric variables twice;
no Modbus polling requests, network calls, archive settings, existing module or
control timer updates. Existing 21-channel raw collector continues untouched.
New daily journals contain the selected numeric raw snapshot and derived views.
A new immutable mapping/inventory reference binds the corrected user EV allocation
161.44 kWh / 39 kW. Original collector metadata and its correction addendum remain
unchanged. No rescaling or resetting of virtual SOC/energy accounts.
Derived journal and LATEST/STATUS reports are 0644 WITHIN mode-0700 private stage
and data directories, allowing the authorized agent to inspect these selected
new outputs without repeated manual exports. No permissions on original journals,
Symcon directories or unrelated data are changed. Quotas reuse 64 MiB/day, 512 MiB
in total and 100 MiB disk reserve; exceeding them stops recording visibly and does
not delete old data. No network transfer or cloud trainer is activated.
## Validation and limitations
40 pure view checks + 32 native-mock checks passed on PHP 8.3 CLI at test host.
Regression: 36 raw-capture + 19 native-capture + 34 accounting checks passed with
new additive `raw_power`/`scale` units. Total 161 isolated checks. Full relevant PHP
source lint passed. Not a full PHPUnit run, Symcon kernel trial or hardware test.
Tests cover opposite EV/SDL requests, held output, non-unique partition labelling,
negative terminal power, scale/sentinel validity, old dependency timestamps, changed
commands, missing source, no duplicate native objects, unchanged existing signals,
append preservation, source drift, private output restrictions and stop behavior.
The observer is PREPARED, not started by the assistant. It does not replace the
installed receiver or its existing preview calculation. The preview/driver must
only be switched after measurement-boundary and feedback policy acceptance. Model
training and five-minute reconstruction must consume the new versioned snapshots
separately; there is no training eligibility or zero future-SDL assumption here.
Device-side command-loss protection and exact model/firmware identification remain
open. Official SolarEdge PDF fetch was unavailable (403/error); no device-specific
register suitability or watchdog behavior was inferred from a third-party source.
+77
View File
@@ -0,0 +1,77 @@
# V4 Unified RC1 - actual scope and release boundary
This release consolidates application data recovery, native delivery status, economic
family comparison, and optional verified archival. It is NOT a production actuation
release. No claim that all original product requirements or hardware acceptance are
complete. Sources are in the existing develop branch, not feature branches.
## Delivered code
* Audited recovery for the exact 100.0/100 splitToleranceW serialization defect.
The internal operator endpoint validates the two complete JSON representations,
their exact SHA256, installation, inventory, and every field. Only that numeric
representation may differ. Device-supplied arbitrary aliases are never accepted.
Existing rows/measurements are not rewritten. The original received mapping and
proof reference are recorded with the observation. A mixed 45/75 block and retries
are regression tested. Adoption requires an explicit deployment flag.
* Native normalization prevents recurrence. The sender preserves a separate last
successful receipt even during scheduled/backoff states, reports safe numeric HTTP
status (no response or credential text), and continues trying to deliver a durable
prefix if the current capture cannot be appended. No cursor reset or skipped block.
* Economic comparison is actually connected, not an empty score list. At the start
of a local calendar day the worker freezes comparable plans for families 3/13/23
with the then-known forecasts, prices and common battery/limit state. Completed
days are evaluated against subsequently acquired physical residual data. Initial
energy, losses, common terminal energy valuation and 15-minute demand charge are
included; monthly maxima are charged once over a comparison period, not per day.
GUI lookback/minimum days/coverage/switch margin are applied by the selector.
* v1 economic scope: one grid-charge-enabled battery, frozen day-ahead plans, with
local grid-target tracking simulated during the day. It is NOT a full receding-
horizon replay. Historical external SDL uses the then-recorded SDL request and is
clearly marked as an estimate, not independently measured delivery. Missing
actuals, unmatched contexts and breaches cannot become zero cost or a successful
comparison. Broader device topologies stay collecting; no fake equivalence.
* Lossless archive implementation and tests are present for acknowledged old native
outbox days and old server observations/plans. Archives are verified before plain
rows/files are retired. Active plans, acknowledged plans and unacknowledged data
are excluded. Archiving is OFF by default; enable only with an explicit storage
policy (native NetzfahrplanV4ArchivAktiv; server NETPLAN_V4_ARCHIVE_ENABLED=1).
Compressed archives are retained: this is not an external backup or infinite disk.
## Deployment is not data/model/actuator acceptance
Server entry: commissioning/deploy_unified_release.py. Default checks sources only.
--apply builds and tests a separate candidate context with no network/production
mounts before replacing the V4 service. It preserves the running plant allowlist,
backs up SQLite consistently, and uses a pinned built image. Only the V4 planner JS
asset is updated; unrelated GUI work, portal runtime, tariffs, forecasting, Symcon
and actuators are untouched. Rollback restores previous source/image; additive audit
rows remain. Database schema changes do not overwrite old observations.
--approve-numeric-mapping-compatibility explicitly authorizes the demonstrated pair
of equivalent configuration representations. Without it, strict original validation
remains and backlog containing the legacy serialization is still rejected.
Lihrenmoos native stage: /srv/agent/netplan-v4-unified-release/install.php.
This patches three data-only libraries, reloads the existing library, compares all
pre-existing properties and lets the existing data timer resume. No raw replay,
force-skip or actuator callback from the installer. Archiving stays disabled by default.
## Remaining product work - do not label as complete
1. The full production dispatcher is not installed. Earlier controlled-trial code
still has bounded pilot grants; continuous production authority and complete
corrected physical/virtual feedback integration are NOT delivered by this RC.
2. The actual host/container/kernel and field acceptance of this release are not
replaced by CLI mocks. Real source validity and independent command-loss behavior
are not asserted by a measurement algebra or an override.
3. The replay limitation above must be accepted as a beta feature, or extended to
continuous rolling reoptimization and additional asset types before advertising
the original full automatic-selection requirement.
4. Native multi-plant commissioning, external backups, retained archive lifecycle,
model quality on field data and publication authentication remain operational
release criteria. The tests do not demonstrate actual cost savings.
Do not re-run obsolete piecemeal installers after this release, bypass their source
checks, turn the previous grid schedule on, or turn 'productionReady' true manually.
The retained EV capacity is 161.44 kWh / 39 kW; SDL reserve is already outside EV.
+111
View File
@@ -0,0 +1,111 @@
# Prognosebedienung im bestehenden Manager
Stand: 06.10.2026. Gepruefter Integrationsstand zur von Daniel freigegebenen
Veroeffentlichung auf develop und beta. Kein Deployment und keine neue Stellfreigabe.
## Umfang
Der separate Formularbereich `V4 Planempfang / Prognosemanager` entfaellt.
Empfang, Planpruefung, Lizenzportal-Verweis und explizites Ein-/Ausschalten
stehen im bestehenden Bereich `Prognose / Forecast`.
Der neue Formularaufruf `FormNetzfahrplanSchalten` delegiert an den vorhandenen
`V4ManagerAktivtestSchalten`-Pfad. Er prueft beim Start nochmals gespeicherte
Prognosekonfiguration, Netzfahrplanlizenz, Managerstatus und bestehende lokale
Testfreigaben. Ein Formularaufruf allein erteilt keine Berechtigung. Ausschalten
bleibt auch nach Lizenzverlust moeglich. Der bestehende Stopp beinhaltet eine
frisch berechnete normale Regelung nach Widerruf der V4-Sitzung; er ist kein
anlagenweiter Not-Aus und veraendert keine unabhaengigen SDL-Auftraege.
Die Anzeige nennt weiterhin Testbetrieb und das bestehende Testende. Es gibt
keine neue Dauerbetriebsfreigabe, keinen Reset der 48-Stunden-Frist und keine
Aenderung an Watchdog-Ausnahmen, Messwertgrenzen oder Geraeteschutzpruefungen.
## Kompatibilitaet
- Keine Properties, Attribute, Objekt-IDs, Timer, Datenpfade oder Backend-APIs werden migriert.
- Prognosemodelle, Optimierer, Planempfang, ACKs, Messaufnahme und Versand bleiben unveraendert.
- Die vorhandene Portalpruefung fuer `grid_schedule` bleibt massgeblich. Keine Portal- oder Lizenzbestellung wird geaendert.
- `NetzfahrplanAktiv` bedeutet weiterhin den alten Regler. Bei vorhandener V4-Konfiguration wird dessen ausgeschalteter Schalter ausgeblendet. Ein eingeschalteter Altregler bleibt zum Ausschalten sichtbar.
- Nicht auf V4 eingerichtete Installationen behalten den bisherigen Schalter. Diese Aenderung provisioniert keine neue V4-Installation.
- `PrognoseAktiv` wird nicht zum automatischen V4-Startsignal. Bestehende Aufnahme-/Sender-Properties und die bisherige Laufzeitvariable bleiben erhalten.
## Dateien
- `Manager/form.json`: stabiler Anker fuer den bestehenden Prognosebereich.
- `Manager/module.php`: Formularintegration und gepruefter Bedienaufruf.
- `libs/ManagerPrognoseFormular.php`: reine Formularzusammenstellung ohne I/O.
- `libs/ManagerNetzfahrplanV4EmpfangTrait.php`: nur bisherigen Formulargenerator entfernt; Empfangscode unveraendert.
- `tests/PrognoseFormular/`: Formular- und echte RequestAction-Pruefungen mit simulierten IPS-/Stellaufrufen.
- `tests/V4Receiver/receiver_checks.php`: Strukturpruefung an die zusammengefuehrte Oberflaeche angepasst.
## Tests
Isoliert im PHP-8.3.6-CLI auf einer Quellkopie, nicht im Symcon-Kernel:
| Suite | Erfolgreiche Pruefungen |
|---|---:|
| PrognoseFormular/checks.php | 59 |
| PrognoseFormular/manager_checks.php | 26 |
| V4Receiver/receiver_checks.php | 32 |
| V4ControlTrial/checks.php | 96 |
| V4Feedback/receiver_checks.php | 8 |
| V4Feedback/device_read_checks.php | 9 |
| V4ControlTrial/register_checks.php | 1 |
| Gesamt | 231 |
Zusaetzliche Gesamttests des zusammengefuehrten Stands am 06.10.2026:
- PHP 8.3.6 / PHPUnit 9.6.36: 357 Tests, 1758 Assertions erfolgreich.
- Syntaxpruefung: alle 150 PHP-Dateien erfolgreich.
- Backend: 310 Python-Tests erfolgreich mit den vorhandenen QA-Abhaengigkeiten.
- Portal: 16 Node-Tests erfolgreich.
- Konfliktmarker- und Patch-Pruefung erfolgreich.
Die Pruefungen liefen in isolierten Quellkopien. Die PHP-Laufzeit wurde nur im
Agent-Arbeitsordner entpackt, ohne Systeminstallation. Kein Symcon-Kernel-/GUI-
oder Hardwaretest; der vorhandene Docker-Zugang war nicht nutzbar.
Eine vorbestehende, nicht committe Aenderung an measurement_pipeline.py wurde
bewusst nicht uebernommen: Sie erzeugte fuer jede konfigurierte Messgrenze ein
accountingEvidenceId trotz measurementBoundaryVerified=false und verletzte den
bestehenden Backendtest. Die Datei bleibt im Integrationsstand bytegleich zum
bisherigen versionierten V4-Stand. Die fremde Arbeitskopie und das laufende
Backend wurden nicht veraendert. Eine spaetere Freigabe dieser Messgrenze braucht
einen eigenstaendigen fachlichen Nachweis, keine Anpassung des Tests an das Label.
## Offener Abschluss
Der Auftrag ist mit dieser Bedienintegration noch nicht vollstaendig umgesetzt:
- Die dokumentierte Batterietimerblockade und die fehlende Anlagenabnahme werden hier nicht behoben.
- Ein regulaerer, lizenzierter Dauerbetrieb anstelle des begrenzten Testpfads ist nicht implementiert.
- Die separaten Laufzeitvariablen und Diagnoseordner werden noch nicht entfernt oder ausgeblendet.
- In gespeicherten Settings vom 06.10.2026, 13:06:07 UTC sind nur die Kategorien 21196 (`ENELIX_V4_SEPARATED_OBSERVATION`) und 57590 (`ENELIX_V4_PASSIVE_CAPTURE`) eindeutig als V4-Diagnosebereiche erkannt. Darin liegen weiterhin Beobachtungs-/Aufnahmeskripte. Keine pauschale Loeschung nach Namen.
- Der allgemeine `Testordner` 10249 enthaelt auch Abrechnung, Schnittstellen und weitere nicht zu dieser Integration gehoerende Objekte. Er bleibt unangetastet.
- Vor einem Rollout aktuelle Moduldateien sichern, mit dem getesteten Stand vergleichen und Abhaengigkeiten pruefen. Keine pauschalen Modulupdates, Reloads oder Dienstneustarts aus diesem Dokument ableiten.
- Die Divergenz wurde in einem separaten develop-Checkout auf ubuntu zusammengefuehrt: Server-89-Historie bis ce525a1 und origin/develop bis 5437f3f. Die Originalarbeitsverzeichnisse mit fremden Aenderungen bleiben erhalten. Daniel hat Merge, Commit und Push auf develop und beta ausdruecklich freigegeben; main bleibt unveraendert.
## Uebergabe und Ruecksetzung
Die fruehere Einzelpatch-Uebergabe ist durch den zusammengefuehrten Quellstand
ersetzt. Die Ausgangshistorie und die zugehoerigen lokalen V4-Quellen sind unter
/srv/agent/prognose-integration-20261006 gesichert. Der gepruefte Kandidat
enthaelt keine temporaren Live-Hooks, Zugangsdaten oder fremden uncommitteten
Ladestationsaenderungen. Ruecksetzungen nur additiv nach Driftpruefung; keine
fremden Aenderungen verwerfen. Fuer einen spaeteren Runtime-Rollout sind eine
eigene Sicherung, Abhaengigkeitspruefung und Anlagenabnahme erforderlich.
## Vorschlag PR-Text
**Titel:** Manager: Prognosebedienung im bestehenden Forecast-Bereich buendeln
**Aenderung:** Separaten V4-Formularbereich entfernen, bestehende Funktionen
unter Prognose / Forecast anbieten und explizite Bedienaktionen zusaetzlich
gegen Lizenz-, Konfigurations- und Freigabestatus pruefen.
**Unveraendert:** Prognose-/Optimierungsbackend, Empfangsprotokoll, Anlagenlimits,
befristete Testfreigaben, Datenhistorie und SDL. Keine automatische Aktivierung.
**Pruefung:** 357 PHPUnit-Tests, 310 Backend-Tests, 16 Portal-Tests sowie die
oben genannten isolierten Bedien-/V4-Checks; kein Hardware- oder Symcon-GUI-Abnahmetest.
+59
View File
@@ -0,0 +1,59 @@
# Erste Schritte mit Enelix
Vom ersten Überblick zur eingerichteten Anlage: Hier siehst du die Reihenfolge und woran du erkennst, dass du zum nächsten Schritt gehen kannst. Die verlinkten Anleitungen erklären die einzelnen Einstellungen.
**Für Betreiber:** Beginne mit [Enelix verstehen](index.md). Lass die technische Einrichtung fachkundig durchführen und dir anschließend die Bedienung deiner eigenen Anlage zeigen. **Für Installateure:** Arbeite die folgenden Schritte in dieser Reihenfolge ab. Beginne mit einem einzigen Verbraucher.
## 1. Die Anlage vorbereiten
Du brauchst IP-Symcon ab Version 8.0, Zugang zur Verwaltungskonsole und die tatsächlichen Mess- und Geräteanschlüsse deiner Anlage. Besonders wichtig ist die **Netzleistung am Netzanschlusspunkt**: Sie zeigt, ob insgesamt Strom bezogen oder eingespeist wird. Die PV-Produktion allein ersetzt diese Messung nicht.
Halte fest, welches Gerät zuerst geregelt werden soll und welche Regelung es bisher steuert. Sichere die bestehende Konfiguration. Zwei Regler dürfen nicht unkoordiniert dieselben Ausgänge bedienen.
**Ergebnis:** Du kannst die relevanten Messwerte in IP-Symcon ansehen, kennst die zulässigen Gerätegrenzen und hast einen Rücksetzweg. Weiter mit [Voraussetzungen und Installation](installation.md).
## 2. Die Bibliotheken installieren
Füge **Enelix EMS** über die Modulverwaltung von IP-Symcon hinzu. Installiere **Enelix Utils** zusätzlich, wenn du beispielsweise dessen Diagramme oder Geräteanbindungen benötigst. Verwende die Repository-Adressen und den freigegebenen Kanal aus der [Installationsanleitung](installation.md#2-bibliotheken-hinzufügen).
Eine installierte Bibliothek stellt zunächst die Modulvorlagen bereit. Sie richtet noch keinen vollständigen Manager und keine Verbindung zu deinen Geräten ein.
**Ergebnis:** Die benötigten Bibliotheken sind ohne Warnung vorhanden und ihre Module stehen zum Anlegen von Instanzen zur Verfügung.
## 3. Den Manager einrichten
Lege eine **Manager-Instanz** an. Lass ihre Regelung zunächst ausgeschaltet. Binde die passende Lizenz, wähle die Netzleistungsmessung und prüfe Einheit und Vorzeichen. Stelle nur Grenzen ein, die für diese Anlage freigegeben sind.
In der normierten Messung bedeutet **positiv: Netzbezug**, **negativ: Einspeisung**. Ein falsch zugeordnetes Vorzeichen kann zu falschen Regelentscheidungen führen.
**Ergebnis:** Der Manager hat einen gültigen Lizenzstatus und erkennt eine aktuelle, plausible Netzleistung. Die [Manager-Anleitung](manager.md) führt durch Lizenzierung, Messquelle und Betriebsgrenzen.
## 4. Einen Verbraucher hinzufügen
Lege die passende Verbraucherinstanz an, zum Beispiel einen Warmwassererwärmer oder eine Ladestation. Richte dort Geräteverbindung, Messwerte, Leistungsgrenzen und Schutzwerte ein. Anschließend wählst du diese Instanz **im Manager** aus.
Mit der Priorität bestimmst du die Reihenfolge bei der Verteilung. Kleinere Zahlen bedeuten höhere Priorität. Auch ein hoch priorisiertes Gerät erhält nur eine Vorgabe, die zu seinem gemeldeten Zustand und den eingestellten Grenzen passt.
**Ergebnis:** Gerät und Manager sind bewusst zugeordnet. Die Rückmeldungen sind plausibel; die Regelung bleibt bis zur Funktionsprüfung ausgeschaltet. Die [Verbraucheranleitung](verbraucher.md) erklärt die Unterschiede der Gerätetypen.
![Grundeinstellungen des Managers mit Netzleistung und manueller Verbraucherauswahl](images/symcon-manager.png "Hier kommt alles zusammen: oben die Netzleistungsmessung, unten die ausgewählten Verbraucher. Echte Aufnahme der Testanlage vom 6. Oktober 2026, IP-Symcon 8.0. Angezeigte Werte sind Testeinstellungen, keine Empfehlung für deine Anlage. Zum Vergrössern auf das Bild klicken.")
## 5. Unter Aufsicht prüfen
Prüfe zuerst Lizenzstatus, Messwerte und Störmeldungen. Teste anschließend mit der fachkundigen Person eine kleine, zulässige Vorgabe und vergleiche sie mit dem tatsächlichen Verhalten am Gerät. Prüfe auch Abschaltung und den Umgang mit fehlenden Messwerten. Erst danach kommen weitere Verbraucher hinzu.
**Ergebnis:** Es ist nachvollziehbar, welches Gerät wann reagiert und wie die Anlage in einen sicheren Zustand zurückkehrt. Eine Anzeige in der Konsole allein ist noch kein Nachweis einer funktionierenden Geräteansteuerung.
## 6. Die Anlage im Alltag nutzen
Lass dir in deiner Visualisierung die Netzleistung, die wichtigsten Verbraucherzustände und mögliche Störmeldungen zeigen. Im Manager zeigt **Betriebsart**, ob die Regelung inaktiv, im PV- oder im Peak-Betrieb ist. Die Freigaben am einzelnen Gerät bleiben ebenfalls wichtig.
Wenn ein Gerät nicht wie erwartet arbeitet, ändere nicht wahllos Prioritäten oder Schutzwerte. Prüfe zuerst Freigabe, Verbindung, Messwerte und Störtext. Die Abschnitte [Im Alltag](manager.md#im-alltag) und [FAQ](faq.md) helfen dabei.
**Ergebnis:** Du weisst, wo du den Anlagenzustand abliest, welche Bedienhandlungen vorgesehen sind und wen du bei einer Störung kontaktierst.
## Optional: Auswertungen und weitere Funktionen
Mit [Enelix Utils](utils.md) kannst du passende Zusatzfunktionen einrichten, etwa ein Energiediagramm oder einen Verbrauchskostenreport. Richte erst die benötigten Messquellen und Archivdaten ein; ein installiertes Auswertungsmodul hat nicht automatisch alle Daten deiner Anlage.
Welche Lizenzen du für deinen Ausbau benötigst und was sie kosten, siehst du in der [aktuellen Preisliste](preise.md).
+73
View File
@@ -0,0 +1,73 @@
# Häufige Fragen
## Brauche ich für die Dokumentation ein Konto?
Nein. Anleitungen, Modulreferenzen, FAQ und Preisliste sind öffentlich. Ein Konto wird für die persönlichen Funktionen des Lizenzportals benötigt.
## Welche IP-Symcon-Version wird benötigt?
Enelix EMS und Enelix Utils setzen IP-Symcon ab Version 8.0 voraus. Maßgeblich ist der Kernel der Installation, nicht nur die Version der Verwaltungskonsole.
## Muss ich EMS und Utils gemeinsam installieren?
Nein. Utils ist eine eigenständige Bibliothek. Installiere sie zusätzlich, wenn du ihre Module oder entsprechende Manager-Visualisierungen benötigst. Die Funktions- und Lizenzvoraussetzungen des gewählten Moduls gelten weiterhin.
## Welchen Updatekanal soll ich wählen?
Verwende den für deine Anlage freigegebenen Kanal: `main` entspricht Stable, `beta` Beta und `develop` Testing. Vergleiche den installierten Stand mit dem oben angegebenen Dokumentationskanal. Testsoftware benötigt eine kontrollierte Inbetriebnahme.
## Warum bleibt der Manager inaktiv?
Prüfe `Aktiv`, Lizenzstatus und Netzleistungsmessung. Fehlende, veraltete oder falsch normierte Messwerte verhindern korrekte Regelung. Kontrolliere anschließend Verbraucherzuordnung und Störtext. Die [Manager-Anleitung](manager.md) beschreibt die Reihenfolge.
## Wo ordne ich einen Verbraucher zu?
Im Manager. Verbraucher haben keine eigene Manager-ID-Property. Die automatische Suche zeigt Kandidaten; ausgewählt und aktiviert werden sie gezielt.
## Warum wird ein Verbraucher trotz Überschuss nicht eingeschaltet?
Mögliche Ursachen sind lokale Deaktivierung, fehlende Zuordnung, fehlende Lizenzmenge, Gerätefehler, ein zu kleines Budget, Temperaturbedingungen oder Mindestzeiten. Prüfe auch das angebotene Leistungsraster und den aktuellen Betriebszustand. Bei einer Ladestation gehören Fahrzeugstatus, Ladefreigabe und Solarladen dazu.
## Was bedeuten positive und negative Leistungen?
Am Netzanschlusspunkt bedeutet positiv Netzbezug und negativ Einspeisung. Bei Batterien bedeutet positiv Laden und negativ Entladen. Die Messfaktoren müssen die Gerätewerte auf diese Konvention und Watt normieren.
## Wie funktionieren Prioritäten?
Kleinere Zahlen haben Vorrang. PV- und Peak-Priorität werden pro Verbraucher eingestellt. Mindestleistungen und technische Sperren bleiben verbindlich. Der aktuelle schrittweise Verteilalgorithmus ist in der [Manager-Referenz](../module/Manager/README.md) beschrieben.
## Schaltet der Manager-Aus-Schalter die gesamte Anlage sicher ab?
Nein. Er ersetzt keine elektrische Freischaltung oder unabhängige Schutztechnik. Verbraucher können lokale Betriebs- und Schutzfunktionen besitzen. Für Arbeiten an der Anlage gilt das dafür vorgesehene Sicherheitsverfahren.
## Kann ich einen Lizenzcode für mehrere Installationen verwenden?
Die Aktivierung bindet den Code an eine Installations-ID. Eine andere Installation wird abgewiesen. Sichere bei einer Migration die vollständige Manager-Instanz einschließlich ihrer Attribute; kläre einen erforderlichen Gerätewechsel über das Portal beziehungsweise den Betreiber.
## Was passiert ohne Internetverbindung?
Eine bereits bestätigte Manager-Freigabe kann im dokumentierten Entwicklungsstand bis `offlineUntil` genutzt werden, ungefähr 14 Tage nach Ausstellung. Danach wird die Regelung bis zu einer gültigen Serverantwort gesperrt. Verlasse dich auf den tatsächlich angezeigten Lizenzstatus; Cloud-Geräte und Prognosen haben zusätzliche Netzwerkabhängigkeiten.
## Was unterscheidet Lizenz und Ersteinrichtung?
Die direkte Lizenzbestellung enthält die gewählten Lizenzen. Beim Systemkonfigurator kommen die noch nicht bezahlten Einrichtungskosten hinzu. Die [Preisliste](preise.md) zeigt beide Positionen getrennt. Prüfe vor Abschluss die vollständige Bestellung.
## Sind Prognoselizenzen automatisch verlängerte Abonnements?
Der aktuelle Portalstand verwendet datierte Jahresberechtigungen und manuelle Verlängerung, keine automatische Stripe-Abonnementverlängerung. Die Preisseite zeigt die im Katalog hinterlegte Laufzeit. Der intelligente Netzfahrplan benötigt den Manager mit Peak Shaving.
## Sind die Preise aktuell und inklusive MwSt.?
Die [Preisliste](preise.md) liest den aktuellen Backend-Katalog. Sie zeigt Netto- und berechnete Bruttopreise einschließlich des aktuell gültigen, im Backend konfigurierten MwSt.-Satzes. Bei einem Abruffehler werden keine Ersatzpreise angezeigt.
## Aktualisiert ein Git-Update auch meine Anlage?
Nein. Dokumentationsabgleich und Modulupdate sind getrennt. IP-Symcon-Module werden über die Modulverwaltung aktualisiert, mit Sicherung und Nachkontrolle.
## Warum zeigt das Energiediagramm keine Werte?
Prüfe die drei Pflichtzähler, deren Archivierung, die Einheit beziehungsweise den Faktor und den gewählten Zeitraum. Das Modul benötigt Energiezähler, nicht nur momentane Leistungsmessungen.
## Welche Daten sollte ich bei einem Fehler bereithalten?
Notiere Modulname, Version beziehungsweise Kanal, Instanzstatus, genaue Fehlermeldung, Zeitpunkt und die letzten Änderungen. Ergänze relevante Messwerte mit Einheiten. Entferne Lizenzcodes, Kontodaten, Passwörter und Tokens aus Screenshots und Protokollen.
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+63
View File
@@ -0,0 +1,63 @@
# Enelix verstehen
**Enelix ist ein Energiemanagementsystem, kurz EMS.** Es hilft dabei, den Strom einer Anlage gezielt zu nutzen: zum Beispiel Solarstrom für Warmwasser oder das Elektroauto einzusetzen und den Bezug aus dem Stromnetz zu begrenzen.
Enelix läuft in **IP-Symcon** und besteht aus zwei Bibliotheken: **Enelix EMS** enthält Manager und Verbrauchermodule; **Enelix Utils** ergänzt Geräteanbindungen und Auswertungen. Du richtest einen **Manager** ein und wählst aus, welche **Verbraucher** er steuern darf. Über IP-Symcon erhält er die Messwerte und Geräteverbindungen. Die laufende Regelung übernimmt anschließend das System innerhalb der eingestellten Grenzen.
Du musst dafür nicht programmieren. Die technische Einrichtung und die Prüfung der Geräteansteuerung gehören jedoch in fachkundige Hände. Diese Einführung erklärt dir erst das Prinzip und danach den Weg zur eigenen Anlage.
## Ein Beispiel aus dem Alltag
Mittags erzeugt deine Solaranlage mehr Strom, als das Haus gerade braucht. Statt den gesamten Überschuss einzuspeisen, kann Enelix ihn einem eingerichteten Verbraucher anbieten, etwa dem Warmwassererwärmer oder einer Ladestation.
**Vereinfachtes Beispiel:** Die Solaranlage liefert 6 kW, im Haus werden 2 kW gebraucht. Damit bleiben zunächst 4 kW Überschuss. Der Manager berücksichtigt, welche Geräte gerade Energie benötigen, welche Leistung sie annehmen können und welche Priorität eingestellt ist. Er verteilt daraus passende Vorgaben. Zieht eine Wolke auf oder steigt der Hausverbrauch, passt er die Verteilung an.
Nicht jedes Gerät kann beliebig wenig Leistung aufnehmen oder sofort abschalten. Deshalb berücksichtigt die Einrichtung auch Leistungsstufen, Mindestlaufzeiten, Temperaturen und weitere Gerätegrenzen. Die Zahlen im Beispiel erklären nur das Prinzip und sind keine Einstellvorgaben.
## So setzt sich Enelix zusammen
| Baustein | Einfach erklärt |
| --- | --- |
| **IP-Symcon** | Die Softwarebasis deiner Anlage. Hier liegen Messwerte, Geräteanbindungen und die Enelix-Instanzen. |
| **Enelix EMS** | Die Bibliothek für das Energiemanagement. Sie enthält den Manager und die passenden Module für Verbraucher und Batterien. |
| **Manager** | Die zentrale Regelung. Er beobachtet die Netzleistung und verteilt Vorgaben an die Geräte, die du ihm zugeordnet hast. |
| **Verbraucher** | Ein angebundenes Gerät, etwa Warmwassererwärmer, Wärmepumpe oder Ladestation. Sein Modul kennt die gerätespezifischen Grenzen und setzt die Vorgaben um. |
| **Enelix Utils** | Eine zweite Bibliothek mit Zusatzfunktionen, zum Beispiel Energiediagramm, Shelly-Anbindung und Verbrauchskostenreport. Sie kann auch unabhängig vom EMS genutzt werden. |
| **Lizenzportal** | Hier verwaltest du die benötigten Lizenzen. Die Regelung deiner Geräte läuft in IP-Symcon, nicht auf dieser Dokumentationsseite. |
Die beiden Bibliotheken sind also zwei zusammenpassende Werkzeugkästen, keine zwei getrennten EMS. Für die Regelung beginnst du mit **Enelix EMS**. **Enelix Utils** kommt dazu, wenn du dessen Zusatzmodule oder die entsprechenden Darstellungen nutzen möchtest.
![Ein Manager und mehrere Verbraucher im Objektbaum der Enelix-Demoanlage](images/symcon-objektbaum.png "So sieht das in IP-Symcon aus: Der markierte Manager und die Verbraucher sind eigene Instanzen. Echte Aufnahme der Testanlage vom 6. Oktober 2026, IP-Symcon 8.0. Namen und Gerätegrössen sind Beispiele. Zum Vergrössern auf das Bild klicken.")
## Was macht der Manager genau?
1. **Messen:** Er liest, ob deine Anlage gerade Strom aus dem Netz bezieht oder einspeist.
2. **Bedarf berücksichtigen:** Die ausgewählten Verbraucher melden ihren Zustand und ihre möglichen Leistungen.
3. **Verteilen:** Der Manager berücksichtigt Betriebsart, Prioritäten und eingestellte Grenzen.
4. **Umsetzen:** Das jeweilige Verbrauchermodul übersetzt die Vorgabe in die passende Geräteansteuerung.
Beim **Solarladen beziehungsweise PV-Betrieb** steht die Nutzung des Solarüberschusses im Vordergrund. **Peak Shaving** bedeutet, hohe Bezugsspitzen gegenüber einer eingestellten Grenze zu begrenzen. Diese Funktion benötigt die entsprechende Lizenz und eine passende Einrichtung. Ein Batteriemodul kann zusätzlich Laden und Entladen in die Regelung einbinden.
Enelix erkennt und steuert nicht automatisch jedes Gerät im Gebäude. Messquellen, Geräteverbindungen und die Zuordnung zum Manager müssen bewusst eingerichtet und geprüft werden.
## Welche Begriffe brauche ich zum Start?
- Eine **Bibliothek** ist ein installierbares Paket, hier Enelix EMS oder Enelix Utils.
- Ein **Modul** ist die Vorlage für eine Funktion, zum Beispiel „Manager“ oder „Wassererwärmer“.
- Eine **Instanz** ist die konkret eingerichtete Verwendung eines Moduls in deiner Anlage. Zwei Ladestationen erhalten zum Beispiel je eine eigene Instanz.
- Eine **Variable** enthält einen Wert oder einen bedienbaren Zustand in IP-Symcon, etwa Netzleistung, Temperatur oder eine Freigabe.
- Die **Visualisierung** ist die Oberfläche, auf der du deine Anlage im Alltag ansehen und bedienen kannst. Die technische Einrichtung erfolgt in der Verwaltungskonsole.
## Wie geht es weiter?
**Du möchtest die erste Anlage einrichten?** Folge den [Ersten Schritten](erste-schritte.md). Sie führen von den Voraussetzungen bis zur kontrollierten Inbetriebnahme und verlinken jeweils die passende Anleitung.
**Deine Anlage ist bereits eingerichtet?** Unter [Manager bedienen](manager.md#im-alltag) findest du die wichtigsten Zustände und die ersten Prüfungen bei Problemen. Die [häufigen Fragen](faq.md) helfen bei typischen Unklarheiten.
**Du suchst einzelne Einstellungen?** Die [Verbraucheranleitung](verbraucher.md), die [Utils-Anleitung](utils.md) und die Modulreferenzen in der Navigation gehen ins Detail. Die [Preisseite](preise.md) zeigt die aktuellen Preise aus dem Lizenzportal.
## Stand und Sicherheit
Enelix befindet sich im kontrollierten Testbetrieb. Die Software ersetzt keine elektrischen Schutzfunktionen und keine fachkundige Inbetriebnahme. Ein ausgeschalteter Manager ist kein elektrischer Not-Aus.
Diese Dokumentation folgt dem unten angegebenen Git-Kanal. Prüfe, ob deine installierte Version dazu passt. Ein Update der Dokumentation aktualisiert deine laufende IP-Symcon-Anlage nicht.
+53
View File
@@ -0,0 +1,53 @@
# Enelix installieren
## 1. Voraussetzungen prüfen
- IP-Symcon ab Version 8.0 und Zugriff auf die Verwaltung.
- Netzwerkzugriff von IP-Symcon auf die Enelix-Git-Repositories; erforderliche Repository-Berechtigungen müssen vorhanden sein.
- Für lizenzierte Funktionen: ein Portal-Konto und eine passende Lizenz. Das Symcon-System muss den Lizenzserver per HTTPS erreichen können.
- Funktionsfähige Geräteanbindungen mit bekannten Mess- und Stellvariablen. IP-Adressen, Zugangsdaten und elektrische Grenzen stammen aus deiner Anlage.
Sichere vor Änderungen die IP-Symcon-Konfiguration einschließlich Instanzen und Attributen. Halte fest, welche bestehende Regelung aktiv ist und wie du sie kontrolliert wiederherstellen kannst. Zwei Regler dürfen nicht unkoordiniert dieselben Ausgänge steuern.
## 2. Bibliotheken hinzufügen
Öffne in der IP-Symcon-Verwaltung den Bereich **Module** und füge die benötigten Repository-URLs hinzu:
| Bibliothek | Repository |
| --- | --- |
| Enelix EMS | `https://git.belevo.ch/ENELIX/Enelix-EMS.git` |
| Enelix Utils | `https://git.belevo.ch/ENELIX/Enelix-Utils.git` |
Wähle einen für deine Umgebung freigegebenen Kanal. Installiere Utils, wenn du dessen Zusatzmodule oder die entsprechenden Manager-Visualisierungen nutzen möchtest.
| Branch | Kanal | Einordnung |
| --- | --- | --- |
| `main` | Stable | stabiler Freigabekanal |
| `beta` | Beta | freigegebene Feldtests |
| `develop` | Testing | Entwicklung und Integrationstests |
Die Kanalbezeichnung allein ist keine Anlagenfreigabe. Beachte zusätzlich den Status der jeweiligen Version. Die öffentliche Dokumentation kann einen neueren Stand zeigen als deine Installation.
Zugangsdaten gehören in die vorgesehene Laufzeitkonfiguration, nicht in Repository-URLs, Skripte oder Screenshots. Kopiere die Modulordner nicht manuell: Sonst kann die Modulverwaltung URL, Branch und Aktualisierungen nicht zuverlässig verwalten.
## 3. Installation kontrollieren
Prüfe, dass die Bibliothek ohne Warnsymbol angezeigt wird und URL sowie Branch sichtbar sind. Lege zunächst nur die benötigten Instanzen an. Für das EMS beginnst du mit dem [Manager](manager.md), danach folgen die [Verbraucher](verbraucher.md).
Lass Manager und Verbraucher während der Grundeinrichtung deaktiviert. Prüfe zuerst Messwerte, Einheiten, Vorzeichen und die Zuordnung der Stellvariablen. Eine gültige Konfiguration ersetzt keinen physischen Funktionstest.
## 4. Lizenz und Einrichtung
Im [Lizenzportal](/) kannst du ein Konto anlegen und die erforderlichen Lizenzen wählen. Die [Preisliste](preise.md) unterscheidet Lizenzkosten und Ersteinrichtung. Eine direkte Lizenzbestellung und eine Bestellung über den Systemkonfigurator sind unterschiedliche Abläufe.
Bei Verwendung des Systemkonfigurators prüfst du die erzeugte Konfiguration vor der Ausführung in IP-Symcon. Führe ein Einrichtungspaket nicht ungeprüft auf einer bestehenden Anlage aus. Bewahre Sicherung und Rücksetzweg auf.
## 5. Aktualisieren
1. Änderungsumfang und passenden Kanal prüfen.
2. Konfiguration, Instanzen und Attribute sichern; bei Regelungsänderungen einen sicheren Anlagenzustand herstellen.
3. Updates über die IP-Symcon-Modulverwaltung beziehen.
4. Instanzstatus, Lizenzbindung, Messquellen und Verbraucherzuordnung kontrollieren.
5. Änderungen an Ausgängen unter Aufsicht prüfen, bevor der normale Betrieb wieder freigegeben wird.
Eine neue Manager-Instanz erhält eine neue Installations-ID. Ein Update einer bestehenden Instanz ist deshalb nicht gleichbedeutend mit Löschen und Neuanlegen.
+53
View File
@@ -0,0 +1,53 @@
# Manager einrichten und bedienen
Der Manager liest die Netzleistung und verteilt Leistung im PV- oder Peak-Betrieb. Er steuert ausschließlich die ausgewählten Verbraucher.
## 1. Instanz und Lizenz vorbereiten
Lege eine Manager-Instanz aus Enelix EMS an. Die Variable `Aktiv` bleibt zunächst ausgeschaltet. Öffne die Konfiguration, trage den Aktivierungscode aus dem Lizenzportal im Bereich **Lizenzierung** ein und wähle **Lizenz prüfen und binden**. Kontrolliere den Lizenzstatus und speichere die Konfiguration anschließend mit **Übernehmen** oder **OK**.
Ohne gültige Manager-Berechtigung bleibt die Instanz mit Status `203` gesperrt. Ein Code ist an die Installations-ID gebunden; verwende ihn nicht zum Einrichten einer zweiten Anlage.
## 2. Netzleistung einstellen
Wähle die Messvariable am Netzanschlusspunkt über `NetzleistungVariableID`. Die Normierung erfolgt mit `Netzleistungsfaktor`:
- positive Leistung bedeutet Netzbezug;
- negative Leistung bedeutet Einspeisung;
- die normierte Einheit ist Watt, nicht Kilowatt.
Prüfe das Vorzeichen bei einem bekannten Betriebszustand. `MesswertMaxAlter` begrenzt das zulässige Alter der Quelle. Eine unveränderte Messung kann weiterhin aktuell sein; entscheidend ist die echte Aktualisierung der Quelle. Fehlende oder veraltete Netzleistung verhindert neue Regelvorgaben.
## 3. Betriebsgrenzen festlegen
`SollwertSolarladen` legt die gewünschte Netzleistung im PV-Betrieb fest. Beginne mit der für deine Anlage freigegebenen Einstellung.
Für Peak Shaving benötigt der Manager die entsprechende Lizenz. `Lastspitzenmodus` kennt **Aus**, **Konstant** und **Monatlich**. Bei konstantem Modus wird `Lastspitzengrenze` in Watt verwendet; im monatlichen Modus werden zwölf Monatswerte gepflegt. **Aus** deaktiviert nur die Peak-Begrenzung, nicht das Solarladen.
Eine Einspeisebegrenzung benötigt geeignete Mess- und Stellregister der Wechselrichter. Übernimm keine beispielhaften Grenzen als Anlagenwerte. Die vollständigen Einstellungen stehen in der [Manager-Referenz](../module/Manager/README.md).
## 4. Verbraucher auswählen
Nutze die automatische Suche oder die manuelle Zuordnung. Gefundene Instanzen müssen gezielt ausgewählt und für die Zuordnung aktiviert werden. Die Verbindung wird im Manager gepflegt; im Verbraucher gibt es keine Manager-ID-Property.
Prioritäten stellst du am jeweiligen Verbraucher ein. Kleinere Zahlen bedeuten höhere Priorität. Die aktuelle Verteilregel und ihre Beispiele stehen in der [Manager-Referenz](../module/Manager/README.md).
![Manager-Konfiguration mit Messquelle, Betriebsgrenzen und ausgewählten Verbrauchern](images/symcon-manager.png "Ausschnitt der Grundeinstellungen in IP-Symcon 8.0, aufgenommen in der Testanlage am 6. Oktober 2026. Die Liste unten zeigt die manuell zugeordneten Verbraucher. Die dargestellten Werte sind keine Einstellvorgaben. Zum Vergrössern auf das Bild klicken.")
## 5. Anlage und Visualisierung ergänzen
Unter Anlagentopologie werden Wechselrichter, PV-Flächen und Batterien mit ihren Messquellen erfasst. Verwende eindeutige Kennungen; PV-Flächen und Batterien müssen auf einen vorhandenen Wechselrichter verweisen.
Für die Energieaufzeichnung werden Netz- und PV-Leistung benötigt. Vollständige Energiezähler verbessern die Bilanzierung; ohne vollständigen Zählersatz integriert der Manager Leistungswerte. Energiezähler und Leistungswerte sind nicht austauschbar.
Die Optionen für Energy Pie, Diagramme, Energy Facts und Energiefluss verwalten die zugehörigen Visualisierungen. Prüfe bestehende angepasste Darstellungen vor Änderungen. Prognosefunktionen sind optional und benötigen passende Berechtigungen sowie eine gültige Topologie.
## 6. Kontrolliert in Betrieb nehmen
Prüfe Lizenzstatus, Netzleistung, Verbraucherstatus und Störtext bei ausgeschalteter Regelung. Nimm zunächst einen Verbraucher unter Aufsicht in Betrieb und vergleiche Sollwert mit physischem Verhalten. Prüfe außerdem Abschaltung und Verhalten bei fehlenden Messwerten, bevor weitere Geräte hinzukommen.
## Im Alltag
`Aktiv` schaltet die Manager-Regelung ein oder aus. `Betriebsart` zeigt **Inaktiv**, **PV** oder **Peak**. Verbraucher können eigene Mindestzeiten, Temperaturanforderungen und lokale Schutzfunktionen haben. Ein deaktivierter Manager ist deshalb kein universeller elektrischer Not-Aus. Verwende für Arbeiten an der Anlage die vorgesehenen technischen Sicherheitsmaßnahmen.
Bei Problemen kontrollierst du zuerst `Lizenzstatus`, `NetzleistungGueltig`, `Sammelstoerung`, `Stoertext` und anschließend die betroffene Verbraucherinstanz. Diagnosevariablen und Debug-Logging können gezielt eingeblendet werden; teile keine Lizenzcodes oder Zugangsdaten.
+17
View File
@@ -0,0 +1,17 @@
# Preise
Alle im öffentlichen Backend-Katalog geführten Produkte, mit Lizenzpreis, Laufzeit und Ersteinrichtung. Die Beträge werden direkt aus dem Lizenzportal geladen.
## So liest du die Preisliste
Die Lizenzpreise gelten pro aufgeführter Einheit. Einrichtungskosten sind einmalige separate Positionen und werden nicht automatisch zum Lizenzpreis addiert. Bei der direkten Lizenzbestellung wird keine Ersteinrichtung berechnet; der Systemkonfigurator berücksichtigt die noch nicht bezahlte Einrichtung.
Jahresberechtigungen werden mit ihrer Laufzeit ausgewiesen. Für den intelligenten Netzfahrplan ist der Manager mit Peak Shaving erforderlich. Der Verbrauchskostenreport benötigt eine Grundlizenz und die passenden Zählerkontingente.
Die Bruttowerte sind aus den Einzelpreisen und dem aktuellen Backend-MwSt.-Satz berechnet. Der Checkout berechnet die MwSt. auf den Bestellgesamtbetrag; bei mehreren Positionen können Rundungsdifferenzen gegenüber der Summe einzelner Bruttowerte entstehen. Maßgeblich ist die Bestellung vor dem Abschluss.
## Weitere Kosten und Verfügbarkeit
Die Liste umfasst den Enelix-Produktkatalog, keine externen Kosten für IP-Symcon, Hardware, Installationsarbeiten oder Drittanbieter. Die Anzeige eines Katalogprodukts bestätigt keine technische Freigabe für jede Anlage. Prüfe Gerätekompatibilität und den angebotenen Bestellumfang im Portal.
Das Portal ist derzeit als Entwicklungsumgebung gekennzeichnet; Zahlungen erfolgen im Stripe-Sandbox-Modus. Eine produktive Zahlungsfreigabe wird durch diese Preisliste nicht erteilt.
+37
View File
@@ -0,0 +1,37 @@
# Enelix Utils nutzen
Die Utils-Bibliothek enthält eigenständige Zusatzmodule. Installiere sie über die IP-Symcon-Modulverwaltung und lege nur die benötigten Instanzen an. Ein EMS-Manager ist für die Bibliothek nicht erforderlich; einzelne Module besitzen eigene Voraussetzungen und gegebenenfalls eigene Lizenzanforderungen.
## Energiediagramm
Wähle die archivierten, fortlaufenden Zähler für Produktion, Einspeisung und Netzbezug. Eine direkte Verbrauchsquelle ist optional. Kontrolliere `Zaehlerfaktor` und die Einheit kWh. Leistungsvariablen in Watt sind keine Energiezähler.
Wähle einen Zeitraum mit vorhandenen Archivdaten und prüfe Energiebilanz, Eigenverbrauch und Autarkie. Fehlende Archivdaten werden als Diagnose angezeigt. [Energiediagramm-Referenz](../../../Enelix-Utils/docs/module/Energiediagramm/README.md).
## Shelly Modul
Richte zuerst den nativen MQTT-Datenfluss in IP-Symcon und MQTT am Shelly-Gerät ein. Das Modul verarbeitet Shelly-NG-Meldungen der Generationen 2, 3 und 4; alte Gen1-Topics sind nicht Teil dieser Anbindung.
Wähle die gewünschten Datenpunktgruppen. Aktiviere einen Topic-Filter nur, wenn er benötigt wird, und trage dann den tatsächlichen Präfix ein. Prüfe Online-Status und Messwerte, bevor Schaltausgänge getestet werden. Broker- und Gerätezugangsdaten werden nicht im Modul verwaltet. [Shelly-Referenz](../../../Enelix-Utils/docs/module/Shelly-Modul/README.md).
## Verbrauchskostenreport
Dieses Modul benötigt seine Grundlizenz sowie passende Kontingente für Strom- und Nebenzähler. Ordne Benutzer und Zähler zu, pflege Tarifzeiträume und Einheiten und prüfe den gewählten Abrechnungszeitraum.
Die Aktion `CreateReport` erstellt das PDF-Medium `ReportPDF`. Für einen aktivierten QR-Zahlteil sind vollständige strukturierte Empfängerangaben erforderlich. Steuer- und Abrechnungseinstellungen müssen fachlich geprüft werden; ein Code-Standardwert ist keine steuerliche Festlegung. [Verbrauchskostenreport-Referenz](../../../Enelix-Utils/docs/module/Verbrauchskostenreport/README.md).
## CC100 Hardware
Das Modul bindet die dokumentierten Ein- und Ausgänge der CC100-Hardware ein. Prüfe Kanalzuordnung, Signalanpassung und physische Verdrahtung anhand der [CC100-Referenz](../../../Enelix-Utils/docs/module/CC100-Hardware/README.md). Teste Ausgänge erst nach Prüfung der angeschlossenen Verbraucher und des sicheren Anlagenzustands.
## Virtuelle Batterie
Trage die physischen Batterien mit Kapazitäten, Leistungsgrenzen, Ladezustands- und Leistungsquellen ein. Die virtuelle Batterie ist die einzige Instanz, die deren physische Sollwerte schreibt. Ein EMS-Batteriemodul nutzt die Eigenverbrauchs-Proxyregister; eine VGT-Anbindung nutzt die SDL-Variablen.
Prüfe Reserven, Zeitüberschreitungen und Messwertalter. Teste Eigenverbrauch und SDL zuerst getrennt, anschließend gemeinsam. SDL hat bei der Verteilung Vorrang. [Referenz zur virtuellen Batterie](../../../Enelix-Utils/docs/module/Virtuelle-Batterie/README.md).
## VGT-Schnittstelle
Ordne den MQTT-Parent zu und übernimm den vereinbarten Topic-Suffix. Wähle Geräteart, Messquellen und bedienbare Zielvariable. Im Batteriemodus wird an die virtuelle Batterie angebunden, nicht direkt an physische Register.
Prüfe zuerst die Rückmeldungen. Steueraufträge und Wiederherstellungsfunktionen dürfen erst im freigegebenen Testumfang erprobt werden. [VGT-Referenz](../../../Enelix-Utils/docs/module/VGT-Schnittstelle/README.md).
+55
View File
@@ -0,0 +1,55 @@
# Verbraucher einrichten
Ein Verbraucher übersetzt ein Leistungsangebot und die Manager-Vorgabe in die konkrete Geräteansteuerung. Die Einrichtung erfolgt zuerst am Gerät und anschließend im Manager.
## Gemeinsamer Ablauf
1. Passendes Modul als Instanz anlegen und `Aktiv` zunächst ausgeschaltet lassen.
2. Messquellen, Geräteanschluss und bedienbare Stellvariablen eintragen. Ein angezeigter Variablenwert allein beweist keine erfolgreiche Geräteansteuerung.
3. Leistungsgrenzen, Mindestzeiten und Schutzwerte aus den tatsächlichen Gerätedaten übernehmen.
4. `PrioritaetPV` und `PrioritaetPeak` festlegen. Kleinere Werte bedeuten höhere Priorität.
5. Konfiguration speichern und Instanzstatus sowie Messwerte prüfen.
6. Den Verbraucher im Manager auswählen. Keine Manager-ID im Verbraucher eintragen.
7. Unter Aufsicht Freigabe, kleine Sollwerte, Rückmeldung und Abschaltung prüfen.
`Meldeintervall` bestimmt die Rückmeldungen. `VorgabeTimeout` begrenzt die Gültigkeit einer nicht erneuerten Vorgabe. Mindestlaufzeiten und Schaltsperren können schnelle Änderungen verhindern; ändere sie nicht nur, um eine Reaktion zu erzwingen.
## Einstufiger Verbraucher
Trage die elektrische `Nennleistung` und eine Boolean-Schaltvariable mit funktionierender Aktion unter `SchaltkontaktVariableID` ein. Eine optionale `RueckmeldungVariableID` bestätigt den physischen Schaltzustand. Mindest-Ein-/Aus-Zeiten und Tagesmindestlaufzeit müssen zum Gerät passen.
Bei fälliger Tagesmindestlaufzeit kann das Gerät auch ohne PV-Überschuss Leistung anfordern. Prüfe das gewünschte Peak-Verhalten ausdrücklich. [Vollständige Referenz](../module/Verbraucher-1-Stufig/README.md).
## Warmwassererwärmer
Wähle den Temperaturfühler und konfiguriere jede positive Leistungsstufe mit eigener Boolean-Schaltvariable. Der Zustand `0 W` wird automatisch ergänzt. Stufen müssen elektrisch zulässig und eindeutig sein. Prüfe Mindesttemperatur, Hysterese, Zeitplan und Legionellenfunktion fachkundig; die Software ersetzt keine unabhängigen Temperatur- und Überhitzungsschutzfunktionen. [Vollständige Referenz](../module/Wassererwaermer/README.md).
## Pufferspeicher
Hinterlege Puffertemperatur, Außentemperatur, Heizkurve und Schaltkontakte der Leistungsstufen. Eine optionale Solltemperaturquelle der Wärmepumpe kann eingebunden werden. Kontrolliere die resultierende Einschaltschwelle und Hysterese. Im dokumentierten Stand bietet der Pufferspeicher im Peak-Betrieb nur `0 W` an. [Vollständige Referenz](../module/Pufferspeicher/README.md).
## Wärmepumpe
Wähle Sperre/Erhöhung oder SG Ready, ordne die beiden Kontakte zu und kontrolliere ihre tatsächliche Logik. Hinterlege Nennleistung und die verpflichtende Leistungs- oder Boolean-Betriebsrückmeldung. Prüfe Herstellerfreigabe, Kontaktbelegung und Mindestzeiten. [Vollständige Referenz](../module/Waermepumpe/README.md).
## Ladestation direkt anbinden
Für unterstützte go-e-Varianten und smart-me Pico wird **Ladestation Stand-Alone** verwendet. Wähle den richtigen Typ. Bei go-e gehören IP-Adresse oder Hostname ohne Protokoll, Pfad oder Parameter in die Geräteadresse. Pico benötigt seine vorgesehenen Geräte- und Kontodaten.
Prüfe Stromgrenzen, Fahrzeug-/Phasenerkennung und Sperrzeiten. `Aktiv`, `Ladefreigabe` und `Solarladen` haben unterschiedliche Aufgaben. Kontrolliere sie zusammen mit dem Fahrzeugstatus, wenn keine Ladeleistung angeboten wird. [Vollständige Referenz](../module/Ladestation-Stand-Alone/README.md).
## Easee über Gateway anbinden
Richte zuerst ein **Easee Gateway** für das Konto ein. Lege je Ladestation eine Instanz **Ladestation Gateway** mit passender Seriennummer an und verbinde sie mit dem Gateway. Zugangsdaten bleiben im Gateway. Prüfe Verbindung, Fahrzeugstatus und Phasenmeldung, bevor du die Station im Manager aktivierst.
[Gateway-Referenz](../module/Easee-Gateway/README.md) und [Ladestations-Referenz](../module/Ladestation-Gateway/README.md).
## Batterie
Wähle den passenden Batterieadapter. Hinterlege dynamische Lade-/Entladegrenzen, Ladezustand, Netzleistung, Istleistung und die erforderlichen bedienbaren Register. Positive Batterieleistung bedeutet Laden, negative Entladen. Prüfe besonders Watt gegenüber Kilowatt und die Herstellerabbildung.
Setze Ladezustandsgrenzen und Reserve gemäß Anlagenfreigabe. Bei einer virtuellen Batterie dürfen physische Register nicht gleichzeitig von mehreren Reglern beschrieben werden. Prüfe zunächst ohne aktive Enelix-Regelung alle Vorzeichen und Messwerte. [Vollständige Referenz](../module/Batterie/README.md).
## Diagnose
Prüfe nacheinander lokale Freigabe, Managerzuordnung, Lizenzumfang, Geräteverbindung, aktuelle Messwerte und Störtext. Ein Verbraucher kann verfügbar sein und trotzdem wegen einer Mindestzeit nur seine aktuelle Leistung anbieten. Details zu `Verfuegbar`, `AenderungMoeglich` und `SollwertGueltig` stehen in der [gemeinsamen Schnittstelle](../Schnittstelle.md).
@@ -0,0 +1,66 @@
# V4: separate Wirkenergie-Bezugsquelle (Entwicklung, nicht live)
## Lihrenmoos - Befund und geplante Zuordnung
User hat die erfolgreiche Aktivierung der T2-Zaehlerarchivierung fuer 26620
im Archiv 16207 bestaetigt. Der exakte Beginn einer lueckenlosen Messhistorie
ist daraus NICHT nachgewiesen; keine historische Null setzen.
Die vorbereitete separate V4-Konfiguration fuer diese Anlage lautet:
```json
[
{"VariableID":59607,"ElternID":11490,"Ident":"Energy_0","FaktorZuKWh":1.0,"Messgroesse":"WirkenergieBezug"},
{"VariableID":26620,"ElternID":11490,"Ident":"Energy_1","FaktorZuKWh":1.0,"Messgroesse":"WirkenergieBezug"}
]
```
Property: NetzfahrplanV4BezugszaehlerQuellen. Diese Datei setzt KEINE Property.
IDs duerfen nicht fuer andere Anlagen kopiert werden. Zuordnung ist durch
Variablenstruktur, Standardtelegramm und Leistungs-/Energiedifferenz stark
plausibilisiert; Abgleich mit Rohtelegramm/Zaehleranzeige steht noch aus.
53476, dessen gespeicherte Historie, die alte Manager-Property
NetzbezugEnergieVariableID und alle vorhandenen Spiegelereignisse bleiben
unveraendert. V4 liest seine eigenen Quellen, erzeugt keine Spruenge in alten
Summenzaehlern und kann keine Blindenergie durch einen Faktor korrigieren.
## Vertrag
Jede Quelle hat eine ausdrueckliche Messgroesse und Umrechnung nach kWh.
Variable, Elterninstanz und Ident werden bei jedem Lesen geprueft. Ein
fehlender, veralteter, ungueltiger oder zeitlich unpassender Teilwert verwirft
die Summe. Ein aktueller echter Nullwert ist gueltig. Zwei aufeinanderfolgende
Lesevorgaenge reduzieren das Risiko einer gemischten Momentaufnahme; sie sind
kein atomarer Zugriff auf das physische Zaehlertelegramm.
Die Summe wird unter bezugszaehler in der lokalen V4-Diagnose ausgegeben, mit
den Beobachtungszeitpunkten jedes Teilregisters. VariableUpdated ist ein
Symcon-Aktualisierungszeitpunkt, kein Beweis einer exakten Abrechnungsgrenze.
Die Diagnose hat deshalb billingEvidence=false und historyComplete=false.
Eine falsche Messgroessen-Deklaration kann ohne Rohtelegramm nicht automatisch
erkannt werden; Installationspruefung bleibt erforderlich.
Ein aus Quell-IDs, Eltern-ID, Idents und Umrechnungsfaktoren gebildeter
stabiler SHA256-Schluessel bindet die spaeteren Messnachweise an genau diese
Quelle. Die Reihenfolge von T1/T2 aendert den Schluessel nicht. Quell-/Faktor-
wechsel dagegen invalidieren alte Nachweise. symcon:53476 wird nicht mehr
als V4-Messnachweis akzeptiert. Der Schluessel beweist Zuordnung, nicht den
Wahrheitsgehalt eines gelieferten Monatspeaks.
## Noch offen
Der Erzeuger fuer exakte Viertelstundenenergie und den vollstaendigen
Monatspeak ist weiterhin nicht implementiert. Keine Ableitung eines bereits
bezahlten Monatspeaks aus einer Managergrenze oder unvollstaendiger Historie.
Auch 15 Minuten neuer Aufzeichnung beweisen nicht den bisherigen Monatspeak.
Die vorbereitete native Summe ist noch nicht im laufenden Symcon installiert.
V4 bleibt ohne belastbare Nachweise in awaiting_inputs.
## Nachweis dieses Schritts
44 synthetische Offline-Szenarien mit PHP CLI 8.4.23 erfolgreich:
20 Quellen-/Summenpruefungen, 24 native Betriebsdatenkonvertierungen.
Die vom Server exportierten Betriebsdaten-, Trait- und Fixture-Dateien wurden
vor dem Test per SHA256 verglichen. Keine Symcon-Laufzeitpruefung und keine
neue Zielcontainer-/vollstaendige PHPUnit-Pruefung in diesem Schritt.
+96
View File
@@ -0,0 +1,96 @@
# V4: native Betriebsdaten, ausschliesslich Schattenbetrieb
Stand 2026-10-01: Der optionale Sender und die Diagnose sind implementiert.
Kein V4-Plan wird damit uebernommen, kein Aktor geschrieben, kein V1-Sollwert
geaendert. Der Sender ist nach Installation standardmaessig AUS.
## Konfiguration im Manager
- NetzfahrplanV4SchattenAktiv: bool, Standard false. Eigener 60-s-Timer,
unabhaengig vom bestehenden Prognose-Timer. Fehler betreffen nur den Sendestatus.
- NetzfahrplanV4NetzladenErlaubt: bool, Standard false. Betrifft ausschliesslich
die im Schattenplan angenommene Berechtigung, keine reale Netzladefreigabe.
- NetzfahrplanV4BatterieOptionen: JSON-Objekt je Topologie-ID. Optional
SOCKapazitaet_kWh und MaxSOC_Prozent (sonst 100). Bei unterschiedlicher Nenn-
und Nutzkapazitaet ist SOCKapazitaet_kWh zwingend explizit anzugeben.
- NetzfahrplanV4BezugszaehlerQuellen: JSON-Liste, Standard []. Eigene, explizite
Wirkenergie-Bezugsquellen fuer V4; KEIN Rueckfall auf den Altzaehler.
Details und Lihrenmoos-Kandidat: Netzfahrplan-V4-Bezugszaehler.md.
- NetzfahrplanV4MessnachweisVariableID: JSON-Stringvariable, Standard 0.
Ein vollstaendiger historischer/registerbasierter Messnachweis-Produzent
ist NOCH NICHT integriert. Fehlende Nachweise bleiben fehlend.
Die Quelle muss der gemeinsame Netzanschluss sein. Topologiebatterien werden
nur einer eindeutigen aktiven Batterieinstanz mit derselben SOC- UND
Leistungsmessquelle zugeordnet. Externe SDL-Batterien werden nicht automatisch
hinzugefuegt. SDL-/Grundlastbereinigung bleibt eine separate, noch offene Aufgabe.
BMS-Grenzen werden mit Topologieleistungen begrenzt. Normale Betriebsreserve
und technisches Minimum werden nicht verwechselt: die hoehere Grenze gilt.
Der erweiterte Sender uebermittelt Entladesperre und Wiederfreigabe-SOC separat
von der maximalen Entladeleistung. Der neue Python-Rechenkern darf die Sperre
im Zukunftsplan erst nach vorherigem Laden bis zur Wiederfreigabe aufheben.
SOC unterhalb der Betriebsreserve, aber oberhalb des technischen Minimums,
bleibt unveraendert erhalten: Es ist Wiederaufladung statt erfundener Energie
vorgesehen. Unterhalb des technischen Minimums wird kein normaler Plan erzeugt.
Zusaetzlich wird meterObservation mit frischen Empfangswerten der Netzleistung
und T1/T2-Summe uebermittelt. Dessen laufende Viertelstundenintegration ist eine
explizite Planungsschaetzung, KEIN verifizierter Abrechnungsnachweis. Verwendung
von Schaetzungen muss im V4-Dienst ausdruecklich aktiviert werden. Historische
Peakannahmen werden getrennt von verifizierten Maxima gespeichert.
Diese Protokollerweiterung benoetigt zuerst den passenden Backendstand. Der
bisherige laufende V4-Container und die Testanlage sind noch nicht aktualisiert.
Offline-Szenarien und Quelltextpruefungen ersetzen keinen Symcon-Laufzeittest.
## Diagnose ohne Netzwerk oder Stellbefehl
Im Symcon-Skript nach Installation des neuen Modulstands:
```php
<?php
print_r(json_decode(ENELIX_GetNetzfahrplanV4Diagnose(17004), true));
echo ENELIX_GetNetzfahrplanV4Sendestatus(17004);
```
Die Diagnose enthaelt KEINE Tokens oder sonstigen Zugangsdaten. Sie ist auch
bei ausgeschaltetem Sender verfuegbar. Die Instanz-ID ist fuer Lihrenmoos aus
der bestehenden Konfiguration bekannt, nicht fuer andere Anlagen zu kopieren.
Nicht ungeprueft deployen: Host-CLI- und Zielsystemtests getrennt nachweisen.
## Messnachweis-Vertrag (Beispiel, KEINE realen Messwerte)
```json
{
"version": 1,
"meterId": "symcon-active-import:<SHA256-der-konfigurierten-Quellen>",
"measuredAt": "2026-10-01T12:05:00Z",
"measuredPeaks": {
"2026-10": {"kw": 18.4, "source": "meter_month_register"}
},
"quarterPast": {
"start": "2026-10-01T12:00:00Z",
"measuredSeconds": 300,
"importKwh": 0.5
}
}
```
Zulaessige Herkunft: meter_month_register, verified_month_history oder
verified_new_month. Der Nachweis-Produzent muss deren Wahrheit garantieren.
Managergrenzen sind NIE ein gemessener Monatspeak. Der Monatswert muss den
vollstaendigen bisherigen Abrechnungsmonat abdecken. Fuer quarterPast muessen
Zaehlerzeitpunkt, abgedeckte Sekunden und Entscheidungszeitpunkt exakt passen;
veraltete Energie wird nicht hochgerechnet oder als aktuelle Messung ausgegeben.
Die kumulative kWh-Variable allein erfuellt diesen Vertrag noch nicht.
Die V4-API meldet bei fehlendem Monatspeak/quarterPast weiterhin awaiting_inputs.
Ein HTTP stored bestaetigt nur den Eingang der Telemetrie, nicht einen gueltigen
Fahrplan und schon gar nicht dessen Ausfuehrung. Backend und Portal-Bruecke
muessen separat aktiviert werden. Keine neue Modellwahl oder Aktorfreigabe hier.
## Tests
24 offline Konvertierungsszenarien und 20 Quellen-/Summen-Szenarien sowie PHPUnit-Strukturpruefungen. Werte sind
synthetisch; keine reale HTTP-Anbindung oder Symcon-Laufzeit damit behauptet.
+11 -9
View File
@@ -42,7 +42,9 @@ werden, wenn sie vor dem Lauf nicht existierten.
- `single`: genau die als Auswahl übergebenen Module - `single`: genau die als Auswahl übergebenen Module
- `affected`: die durch geänderte Pfade ermittelten Module - `affected`: die durch geänderte Pfade ermittelten Module
Verfügbare Module: `LadestationStandAlone`, `Manager`, `VerbraucherEinStufig`, `Warmwassererwaermer`. Verfuegbare Module: `Batterie`, `EaseeGateway`, `LadestationGateway`,
`LadestationStandAlone`, `Manager`, `Pufferspeicher`, `VerbraucherEinStufig`,
`Waermepumpe` und `Warmwassererwaermer`.
Der Manager-Test enthält Manager ohne Verbraucher, jeden Verbrauchertyp einzeln und alle aktuell implementierten Verbrauchertypen gemeinsam. Der Manager-Test enthält Manager ohne Verbraucher, jeden Verbrauchertyp einzeln und alle aktuell implementierten Verbrauchertypen gemeinsam.
@@ -83,15 +85,15 @@ Zusätzlich schreibt der Runner die Zusammenfassung in das IP-Symcon-Log.
## CI-Regeln ## CI-Regeln
Bei jedem Push laufen sämtliche PHPUnit-Tests. Auf `develop` werden zusätzlich Bei jedem Push laufen sämtliche PHPUnit-Tests und die PHP-Syntaxprüfung. Die
die von den geänderten Pfaden betroffenen Symcon-Modultests ausgeführt. Bei Symcon-Modultests werden bis zur Verfügbarkeit eines geschützten Runners auf
gemeinsamen Framework- oder Vertragsänderungen läuft die vollständige Suite. der isolierten IP-Symcon-8.0-Instanz des Agent-Servers ausgeführt. Vor jeder
Für `beta` ist immer `all` vorgeschrieben. Übernahme nach `beta` ist ein erfolgreicher Lauf im Modus `all` verbindlich.
Der erzeugte JSON- und JUnit-Bericht gehört zum Freigabenachweis.
Die Symcon-Jobs benötigen einen geschützten Runner mit dem Label `symcon-8`, Eine spätere CI-Automatisierung benötigt einen geschützten Runner mit dem Label
lokalem Zugriff auf eine isolierte IP-Symcon-8.x-Instanz und den installierten `symcon-8`, lokalem Zugriff auf die isolierte IP-Symcon-Instanz und den exakt
Repository-Stand. Ohne diesen Runner ist die Richtlinie dokumentiert, aber zu prüfenden Repository-Stand.
nicht technisch durchsetzbar.
## Checkliste für neue Module ## Checkliste für neue Module
+60 -17
View File
@@ -2,13 +2,22 @@
Die Demo erzeugt idempotent eine vollständige, spielbare EMS-Anlage in IP-Symcon 8.x: Die Demo erzeugt idempotent eine vollständige, spielbare EMS-Anlage in IP-Symcon 8.x:
- Enelix Manager mit manueller Zuordnung, - Enelix Manager mit manueller Zuordnung von fünf steuerbaren Teilnehmern,
- zwei mehrstufige Boiler (1,2/2,4 kW und 1,5/3,0 kW), - zwei mehrstufige Boiler (1,2/2,4 kW und 1,5/3,0 kW),
- einen einstufigen Entfeuchter (0,9 kW), - einen einstufigen Entfeuchter (0,9 kW),
- simulierte PV-Erzeugung, Grundlast, Netzleistung und Boilertemperaturen, - einen zweistufigen Pufferspeicher (1,8/3,6 kW) mit Heizkurve,
- native Symcon-Instanz `Energy Distribution` für den Energiefluss, - einen bidirektionalen Batteriespeicher (10 kWh, +/-3,5 kW),
- eigene Kachelansichten mit allen Laufzeitparametern je Boiler, - eine technische Anlagentopologie mit Hybridwechselrichter, PV-Fläche und
- kombinierten Verlauf für PV-Leistung, Hausverbrauch und Netzleistung, Batteriespeicher für die Prognoseanbindung,
- simulierte Istleistungs- und Leistungsbegrenzungsregister des Wechselrichters,
- eine aktive anlagenweite Einspeisebegrenzung auf 3.000 W mit 100 W Toleranz,
- Prognosetelemetrie aus PV, Hausverbrauch, Netzleistung und Batterie-SOC im
60-Sekunden-Intervall,
- simulierte PV-Erzeugung, variable Bewölkung, Tageslastgang, Zusatzlast,
Außentemperatur, Wärmeverluste, Netzleistung und Speicherzustände,
- native Symcon-Instanz `Energy Distribution` für acht Energieflussknoten,
- eigene Kachelansichten mit Laufzeitparametern für Boiler, Puffer und Batterie,
- kombinierten Verlauf für PV-Leistung, Hausverbrauch, EMS-Leistung und Netzleistung,
- kombinierten Verlauf der kumulierten PV-, Haus-, Bezugs- und Einspeiseenergie, - kombinierten Verlauf der kumulierten PV-, Haus-, Bezugs- und Einspeiseenergie,
- Archivierung der Leistungs-, Energie- und Temperaturwerte. - Archivierung der Leistungs-, Energie- und Temperaturwerte.
@@ -27,26 +36,60 @@ print_r($result);
Ein weiterer Lauf aktualisiert dieselbe Anlage anhand stabiler Idents und erzeugt keine Duplikate. Ein weiterer Lauf aktualisiert dieselbe Anlage anhand stabiler Idents und erzeugt keine Duplikate.
Bestehende, nicht zur Demo gehörende Objekte werden nicht verändert oder gelöscht. Bestehende, nicht zur Demo gehörende Objekte werden nicht verändert oder gelöscht.
Der Bootstrap hinterlegt absichtlich keinen Lizenzcode und überschreibt eine
bereits konfigurierte Manager-Lizenz nicht. Bei einer neuen Anlage wird die
Struktur vollständig erstellt; der Manager bleibt bis zur regulären
Lizenzaktivierung im Konfigurationsformular auf Status 203. Solange der Manager
nicht freigegeben ist, bleiben seine Zuordnung und die Verbraucher deaktiviert,
damit keine Lizenzfehler protokolliert werden. Nach der Aktivierung den
Bootstrap erneut ausführen; er ordnet dann alle fünf Teilnehmer zu und startet
die Regelung.
Die Demo übernimmt die einstellbare PV-Spitzenleistung als AC- und
DC-Anlagenleistung. Für die feste Beispieltopologie gelten 30 Grad Neigung und
Südausrichtung. Der 10-kWh-Speicher nutzt den gemeinsamen Hybridwechselrichter;
seine dynamischen Lade- und Entladegrenzen werden beim Bootstrap in die
technischen Stammdaten übernommen. Bei aktiver Manager-Lizenz uebertraegt der
Manager zusaetzlich PV-Leistung, Hausverbrauch, Netzleistung und Batterie-SOC
alle 60 Sekunden an den Enelix-Prognosedienst. Die Demo verwendet ein absolutes
Wechselrichter-Leistungsregister in Watt. Der Manager begrenzt die tatsächliche
PV-Produktion so, dass die Gesamtanlage höchstens 3.000 W einspeist; innerhalb
einer Toleranz von 100 W bleibt der zuletzt gesetzte Stellwert bestehen. Der
intelligente Netzfahrplan bleibt deaktiviert, solange dessen Backendfassung nur
als ausstehende Version bereitliegt.
## Bedienung ## Bedienung
In der Kachelvisualisierung öffnet die Startkategorie `Enelix 2 Demoanlage`. In der Kachelvisualisierung öffnet die Startkategorie `Enelix 2 Demoanlage`.
Unter `Simulation` lassen sich Tageslauf, Tageszeit, Geschwindigkeit, Unter `Simulation` lassen sich Tageslauf, Tageszeit, Geschwindigkeit,
PV-Spitzenleistung, Bewölkung und Haus-Grundlast verändern. Der Manager verteilt dynamische Profile, PV-Spitzenleistung, mittlere Bewölkung, Haus-Grundlast und
den verfügbaren PV-Überschuss nach Priorität auf beide Boiler und den Entfeuchter. eine ungeregelte Zusatzlast verändern. Bei aktivierten dynamischen Profilen
entstehen reproduzierbare Morgen- und Abendspitzen, ziehende Bewölkung und ein
Tagesgang der Außentemperatur. Der Manager verteilt den verfügbaren
PV-Überschuss nach Priorität auf beide Boiler, Entfeuchter, Pufferspeicher und
Batterie. Bei hoher PV-Erzeugung ist die Abregelung an den technischen Variablen
`PV-Wechselrichter Istleistung` und `PV-Wechselrichter Leistungsgrenze` sowie an
der auf etwa 3.000 W begrenzten Netzeinspeisung sichtbar.
`Übersicht` zeigt die native Symcon-Energieverteilung. Die Kacheln `Boiler 1` `Übersicht` zeigt die native Symcon-Energieverteilung. Die Kacheln `Boiler 1`,
und `Boiler 2` öffnen direkt sämtliche Bedien-, Status- und Diagnosevariablen, `Boiler 2`, `Pufferspeicher` und `Batteriespeicher` öffnen direkt die jeweiligen
ohne zuerst das Instanz-Konfigurationsformular zu zeigen. Unter `Leistung und Bedien-, Status- und Diagnosevariablen, ohne zuerst das
Energie` liegen die beiden kombinierten Zeitdiagramme und die Energiezähler. Instanz-Konfigurationsformular zu zeigen. Positive Batterieleistung bedeutet
Die Energiesummen verwenden die beschleunigte Simulationszeit. Laden, negative Leistung Entladen. Unter `Leistung und Energie` liegen die
beiden kombinierten Zeitdiagramme und die Energiezähler. Die Energiesummen,
Temperaturmodelle und der Ladezustand verwenden die beschleunigte
Simulationszeit.
## Validierung ## Validierung
- Alle vier Enelix-Instanzen müssen Status 102 besitzen. - Die fünf Verbraucher müssen Status 102 besitzen; der Manager nach Lizenzaktivierung ebenfalls (vorher erwarteter Status 203).
- Der Manager muss drei Verbraucher melden. - Der Manager muss fünf Teilnehmer aus vier unterschiedlichen Modultypen melden.
- Bei ausreichendem PV-Überschuss müssen Verbraucher schalten. - Bei ausreichendem PV-Überschuss müssen thermische Verbraucher schalten und die Batterie laden.
- Netzleistung, PV-Leistung, Hausverbrauch, Verbraucherleistungen, Energiesummen und Temperaturen werden archiviert. - Bei höherem verbleibendem PV-Überschuss muss das Wechselrichterregister die PV-Leistung so begrenzen, dass ungefähr 3.000 W ins Netz eingespeist werden.
- Die native Energieverteilung muss sechs Knoten enthalten. - Wechselrichter-Istleistung und Leistungsgrenze müssen in der technischen Kategorie vorhanden und im Manager zugeordnet sein.
- Puffertemperatur, Außentemperatur und Batterie-Ladezustand müssen sich über den Tageslauf verändern.
- Netzleistung, PV-Leistung, Hausverbrauch, EMS-Leistung, Einzelverbräuche, Speicherleistung, Energiesummen und Temperaturen werden archiviert.
- Die native Energieverteilung muss acht Knoten enthalten.
- Beide Diagrammkacheln müssen mindestens drei beschriftete Zeitreihen anzeigen. - Beide Diagrammkacheln müssen mindestens drei beschriftete Zeitreihen anzeigen.
- Ein erneuter Installationslauf muss dieselbe Root-ID zurückgeben. - Ein erneuter Installationslauf muss dieselbe Root-ID zurückgeben.
+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_MANAGER_MODULE = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
const ENELIX_DEMO_BOILER_MODULE = '{B7C54AF4-AD7D-4FE4-B75D-203693906251}'; const ENELIX_DEMO_BOILER_MODULE = '{B7C54AF4-AD7D-4FE4-B75D-203693906251}';
const ENELIX_DEMO_SINGLE_MODULE = '{15879A4E-D0C2-4495-83DE-46E1E462591E}'; const ENELIX_DEMO_SINGLE_MODULE = '{15879A4E-D0C2-4495-83DE-46E1E462591E}';
const ENELIX_DEMO_BUFFER_MODULE = '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}';
const ENELIX_DEMO_BATTERY_MODULE = '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}';
const ENELIX_DEMO_DUMMY_MODULE = '{485D0419-BE97-4548-AA9C-C083EB82E61E}'; const ENELIX_DEMO_DUMMY_MODULE = '{485D0419-BE97-4548-AA9C-C083EB82E61E}';
const ENELIX_DEMO_TILE_MODULE = '{B5B875BB-9B76-45FD-4E67-2607E45B3AC4}'; const ENELIX_DEMO_TILE_MODULE = '{B5B875BB-9B76-45FD-4E67-2607E45B3AC4}';
const ENELIX_DEMO_ARCHIVE_MODULE = '{43192F0B-135B-4CE7-A0A7-1475603F3060}'; const ENELIX_DEMO_ARCHIVE_MODULE = '{43192F0B-135B-4CE7-A0A7-1475603F3060}';
@@ -28,15 +30,17 @@ function enelixDemoInstall(): array
enelixDemoEnsureProfiles(); enelixDemoEnsureProfiles();
$legacyFlow = enelixDemoEnsureVariable($dashboard, 'Energiefluss', 'Energiefluss (Legacy)', 3, '~HTMLBox', '', 90); $legacyFlow = enelixDemoEnsureVariable($dashboard, 'Energiefluss', 'Energiefluss (Legacy)', 3, '~HTMLBox', '', 900);
IPS_SetHidden($legacyFlow, true); IPS_SetHidden($legacyFlow, true);
$pvDisplay = enelixDemoEnsureVariable($dashboard, 'PVLeistung', 'PV-Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 20); $pvDisplay = enelixDemoEnsureVariable($dashboard, 'PVLeistung', 'PV-Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 20);
$houseDisplay = enelixDemoEnsureVariable($dashboard, 'Hausverbrauch', 'Hausverbrauch gesamt', 2, 'ENELIX.Demo.PowerPositive', 0.0, 30); $houseDisplay = enelixDemoEnsureVariable($dashboard, 'Hausverbrauch', 'Hausverbrauch gesamt', 2, 'ENELIX.Demo.PowerPositive', 0.0, 30);
$loadDisplay = enelixDemoEnsureVariable($dashboard, 'Verbraucherleistung', 'Geregelte Verbraucher', 2, 'ENELIX.Demo.PowerPositive', 0.0, 40); $loadDisplay = enelixDemoEnsureVariable($dashboard, 'Verbraucherleistung', 'EMS-Leistung inkl. Speicher', 2, 'ENELIX.Demo.Power', 0.0, 40);
$grid = enelixDemoEnsureVariable($dashboard, 'Netzleistung', 'Netzleistung', 2, 'ENELIX.Demo.Power', 0.0, 50); $grid = enelixDemoEnsureVariable($dashboard, 'Netzleistung', 'Netzleistung', 2, 'ENELIX.Demo.Power', 0.0, 50);
$boiler1Power = enelixDemoEnsureVariable($dashboard, 'Boiler1Leistung', 'Boiler 1 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 60); $boiler1Power = enelixDemoEnsureVariable($dashboard, 'Boiler1Leistung', 'Boiler 1 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 60);
$boiler2Power = enelixDemoEnsureVariable($dashboard, 'Boiler2Leistung', 'Boiler 2 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 70); $boiler2Power = enelixDemoEnsureVariable($dashboard, 'Boiler2Leistung', 'Boiler 2 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 70);
$dryerPower = enelixDemoEnsureVariable($dashboard, 'EntfeuchterLeistung', 'Entfeuchter Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80); $dryerPower = enelixDemoEnsureVariable($dashboard, 'EntfeuchterLeistung', 'Entfeuchter Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80);
$bufferPower = enelixDemoEnsureVariable($dashboard, 'PufferLeistung', 'Pufferspeicher Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 90);
$batteryPower = enelixDemoEnsureVariable($dashboard, 'BatterieLeistung', 'Batterieleistung', 2, 'ENELIX.Demo.Power', 0.0, 100);
$powerChart = enelixDemoEnsureVariable($history, 'Leistungsdiagramm', 'Leistungsverlauf (Legacy)', 3, '~HTMLBox', '', 90); $powerChart = enelixDemoEnsureVariable($history, 'Leistungsdiagramm', 'Leistungsverlauf (Legacy)', 3, '~HTMLBox', '', 90);
$energyChart = enelixDemoEnsureVariable($history, 'Energiediagramm', 'Kumulierte Energien (Legacy)', 3, '~HTMLBox', '', 100); $energyChart = enelixDemoEnsureVariable($history, 'Energiediagramm', 'Kumulierte Energien (Legacy)', 3, '~HTMLBox', '', 100);
@@ -50,9 +54,11 @@ function enelixDemoInstall(): array
$auto = enelixDemoEnsureVariable($simulation, 'Automatik', 'Tageslauf automatisch', 0, '~Switch', true, 10); $auto = enelixDemoEnsureVariable($simulation, 'Automatik', 'Tageslauf automatisch', 0, '~Switch', true, 10);
$hour = enelixDemoEnsureVariable($simulation, 'Tageszeit', 'Simulierte Tageszeit', 2, 'ENELIX.Demo.Hour', 8.0, 20); $hour = enelixDemoEnsureVariable($simulation, 'Tageszeit', 'Simulierte Tageszeit', 2, 'ENELIX.Demo.Hour', 8.0, 20);
$speed = enelixDemoEnsureVariable($simulation, 'Geschwindigkeit', 'Minuten je Schritt', 1, 'ENELIX.Demo.Speed', 10, 30); $speed = enelixDemoEnsureVariable($simulation, 'Geschwindigkeit', 'Minuten je Schritt', 1, 'ENELIX.Demo.Speed', 10, 30);
$peak = enelixDemoEnsureVariable($simulation, 'PVSpitze', 'PV-Spitzenleistung', 2, 'ENELIX.Demo.PowerPositive', 7000.0, 40); $dynamic = enelixDemoEnsureVariable($simulation, 'DynamischeProfile', 'Dynamische Tagesprofile', 0, '~Switch', true, 40);
$cloud = enelixDemoEnsureVariable($simulation, 'Bewoelkung', 'Bewölkung', 1, 'ENELIX.Demo.Percent', 20, 50); $peak = enelixDemoEnsureVariable($simulation, 'PVSpitze', 'PV-Spitzenleistung', 2, 'ENELIX.Demo.PowerPositive', 9000.0, 50);
$base = enelixDemoEnsureVariable($simulation, 'Grundlast', 'Haus-Grundlast', 2, 'ENELIX.Demo.PowerPositive', 900.0, 60); $cloud = enelixDemoEnsureVariable($simulation, 'Bewoelkung', 'Mittlere Bewölkung', 1, 'ENELIX.Demo.Percent', 20, 60);
$base = enelixDemoEnsureVariable($simulation, 'Grundlast', 'Mittlere Haus-Grundlast', 2, 'ENELIX.Demo.PowerPositive', 900.0, 70);
$extraLoad = enelixDemoEnsureVariable($simulation, 'Zusatzlast', 'Ungeregelte Zusatzlast', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80);
$actorScript = enelixDemoEnsureScript( $actorScript = enelixDemoEnsureScript(
$technical, $technical,
@@ -61,6 +67,13 @@ function enelixDemoInstall(): array
"<?php\nSetValue((int) \$_IPS['VARIABLE'], (bool) \$_IPS['VALUE']);\n", "<?php\nSetValue((int) \$_IPS['VARIABLE'], (bool) \$_IPS['VALUE']);\n",
10 10
); );
$registerScript = enelixDemoEnsureScript(
$technical,
'RegisterAktion',
'Simulierte Register',
"<?php\nSetValue((int) \$_IPS['VARIABLE'], \$_IPS['VALUE']);\n",
15
);
$tickScript = enelixDemoEnsureScript( $tickScript = enelixDemoEnsureScript(
$technical, $technical,
'SimulationTick', 'SimulationTick',
@@ -76,7 +89,7 @@ function enelixDemoInstall(): array
30 30
); );
foreach ([$auto, $hour, $speed, $peak, $cloud, $base] as $variableId) { foreach ([$auto, $hour, $speed, $dynamic, $peak, $cloud, $base, $extraLoad] as $variableId) {
IPS_SetVariableCustomAction($variableId, $controlScript); IPS_SetVariableCustomAction($variableId, $controlScript);
} }
@@ -84,36 +97,59 @@ function enelixDemoInstall(): array
$temp1 = enelixDemoEnsureVariable($technical, 'Boiler1Temperatur', 'Boiler 1 Temperatur', 2, '~Temperature', 49.0, 100); $temp1 = enelixDemoEnsureVariable($technical, 'Boiler1Temperatur', 'Boiler 1 Temperatur', 2, '~Temperature', 49.0, 100);
$temp2 = enelixDemoEnsureVariable($technical, 'Boiler2Temperatur', 'Boiler 2 Temperatur', 2, '~Temperature', 47.0, 110); $temp2 = enelixDemoEnsureVariable($technical, 'Boiler2Temperatur', 'Boiler 2 Temperatur', 2, '~Temperature', 47.0, 110);
$boiler11 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe1', 'Boiler 1 Stufe 1', 0, '~Switch', false, 120); $bufferTemp = enelixDemoEnsureVariable($technical, 'PufferTemperatur', 'Puffertemperatur', 2, '~Temperature', 36.0, 120);
$boiler12 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe2', 'Boiler 1 Stufe 2', 0, '~Switch', false, 130); $outsideTemp = enelixDemoEnsureVariable($technical, 'AussenTemperatur', 'Aussentemperatur', 2, '~Temperature', 6.0, 130);
$boiler21 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe1', 'Boiler 2 Stufe 1', 0, '~Switch', false, 140); $boiler11 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe1', 'Boiler 1 Stufe 1', 0, '~Switch', false, 200);
$boiler22 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe2', 'Boiler 2 Stufe 2', 0, '~Switch', false, 150); $boiler12 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe2', 'Boiler 1 Stufe 2', 0, '~Switch', false, 210);
$dryContact = enelixDemoEnsureVariable($technical, 'EntfeuchterKontakt', 'Entfeuchter Kontakt', 0, '~Switch', false, 160); $boiler21 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe1', 'Boiler 2 Stufe 1', 0, '~Switch', false, 220);
$chartUpdated = enelixDemoEnsureVariable($technical, 'LetztesDiagrammUpdate', 'Letztes Diagramm-Update', 1, '', 0, 170); $boiler22 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe2', 'Boiler 2 Stufe 2', 0, '~Switch', false, 230);
$dryContact = enelixDemoEnsureVariable($technical, 'EntfeuchterKontakt', 'Entfeuchter Kontakt', 0, '~Switch', false, 240);
$buffer1 = enelixDemoEnsureVariable($technical, 'PufferStufe1', 'Puffer Stufe 1', 0, '~Switch', false, 250);
$buffer2 = enelixDemoEnsureVariable($technical, 'PufferStufe2', 'Puffer Stufe 2', 0, '~Switch', false, 260);
$batteryMaxCharge = enelixDemoEnsureVariable($technical, 'BatterieMaxLaden', 'Batterie max. laden', 2, 'ENELIX.Demo.PowerPositive', 3500.0, 300);
$batteryMaxDischarge = enelixDemoEnsureVariable($technical, 'BatterieMaxEntladen', 'Batterie max. entladen', 2, 'ENELIX.Demo.PowerPositive', 3500.0, 310);
$batterySoc = enelixDemoEnsureVariable($technical, 'BatterieLadezustand', 'Batterie Ladezustand', 2, 'ENELIX.Demo.PercentFloat', 52.0, 320);
$batteryActual = enelixDemoEnsureVariable($technical, 'BatterieIstleistung', 'Batterie Istleistung', 2, 'ENELIX.Demo.Power', 0.0, 330);
$batteryManagement = enelixDemoEnsureVariable($technical, 'BatterieManagement', 'Batterie Managementregister', 1, '', 0, 340);
$batteryMode = enelixDemoEnsureVariable($technical, 'BatterieModus', 'Batterie Modusregister', 1, '', 0, 350);
$batteryRegisterPower = enelixDemoEnsureVariable($technical, 'BatterieRegisterLeistung', 'Batterie Leistungsregister', 2, 'ENELIX.Demo.PowerPositive', 0.0, 360);
$inverterActual = enelixDemoEnsureVariable($technical, 'WechselrichterIstleistung', 'PV-Wechselrichter Istleistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 370);
$inverterLimit = enelixDemoEnsureVariable($technical, 'WechselrichterLeistungsgrenze', 'PV-Wechselrichter Leistungsgrenze', 2, 'ENELIX.Demo.PowerPositive', max(0.0, (float) GetValue($peak)), 380);
$chartUpdated = enelixDemoEnsureVariable($technical, 'LetztesDiagrammUpdate', 'Letztes Diagramm-Update', 1, '', 0, 390);
IPS_SetHidden($chartUpdated, true); IPS_SetHidden($chartUpdated, true);
$flowPv = enelixDemoEnsureVariable($technical, 'EnergieflussPVkW', 'PV-Leistung', 2, '~Power', 0.0, 300); $flowPv = enelixDemoEnsureVariable($technical, 'EnergieflussPVkW', 'PV-Leistung', 2, '~Power', 0.0, 500);
$flowGrid = enelixDemoEnsureVariable($technical, 'EnergieflussNetzkW', 'Netzleistung', 2, '~Power', 0.0, 310); $flowGrid = enelixDemoEnsureVariable($technical, 'EnergieflussNetzkW', 'Netzleistung', 2, '~Power', 0.0, 510);
$flowBase = enelixDemoEnsureVariable($technical, 'EnergieflussGrundlastkW', 'Haus-Grundlast', 2, '~Power', 0.0, 320); $flowBase = enelixDemoEnsureVariable($technical, 'EnergieflussGrundlastkW', 'Haus-Grundlast', 2, '~Power', 0.0, 520);
$flowBoiler1 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler1kW', 'Boiler 1', 2, '~Power', 0.0, 330); $flowBoiler1 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler1kW', 'Boiler 1', 2, '~Power', 0.0, 530);
$flowBoiler2 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler2kW', 'Boiler 2', 2, '~Power', 0.0, 340); $flowBoiler2 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler2kW', 'Boiler 2', 2, '~Power', 0.0, 540);
$flowDryer = enelixDemoEnsureVariable($technical, 'EnergieflussEntfeuchterkW', 'Entfeuchter', 2, '~Power', 0.0, 350); $flowDryer = enelixDemoEnsureVariable($technical, 'EnergieflussEntfeuchterkW', 'Entfeuchter', 2, '~Power', 0.0, 550);
$flowHouse = enelixDemoEnsureVariable($technical, 'DiagrammHausverbrauchkW', 'Hausverbrauch gesamt', 2, '~Power', 0.0, 360); $flowBuffer = enelixDemoEnsureVariable($technical, 'EnergieflussPufferkW', 'Pufferspeicher', 2, '~Power', 0.0, 560);
$flowLoads = enelixDemoEnsureVariable($technical, 'DiagrammVerbraucherkW', 'Geregelte Verbraucher', 2, '~Power', 0.0, 370); $flowBattery = enelixDemoEnsureVariable($technical, 'EnergieflussBatteriekW', 'Batteriespeicher', 2, '~Power', 0.0, 570);
$flowHouse = enelixDemoEnsureVariable($technical, 'DiagrammHausverbrauchkW', 'Hausverbrauch gesamt', 2, '~Power', 0.0, 580);
$flowLoads = enelixDemoEnsureVariable($technical, 'DiagrammVerbraucherkW', 'EMS-Leistung inkl. Speicher', 2, '~Power', 0.0, 590);
foreach ([ foreach ([
$flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2,
$flowBoiler2, $flowDryer, $flowHouse, $flowLoads, $flowDryer, $flowBuffer, $flowBattery, $flowHouse, $flowLoads,
] as $flowVariableId) { ] as $flowVariableId) {
IPS_SetHidden($flowVariableId, true); IPS_SetHidden($flowVariableId, true);
} }
foreach ([$boiler11, $boiler12, $boiler21, $boiler22, $dryContact] as $actorId) { foreach ([$boiler11, $boiler12, $boiler21, $boiler22, $dryContact, $buffer1, $buffer2] as $actorId) {
IPS_SetVariableCustomAction($actorId, $actorScript); IPS_SetVariableCustomAction($actorId, $actorScript);
} }
foreach ([$batteryManagement, $batteryMode, $batteryRegisterPower, $inverterLimit] as $registerId) {
IPS_SetVariableCustomAction($registerId, $registerScript);
}
foreach ([$temp1, $temp2, $bufferTemp, $outsideTemp, $batteryMaxCharge, $batteryMaxDischarge, $batterySoc, $batteryActual, $inverterActual] as $sensorId) {
SetValue($sensorId, GetValue($sensorId));
}
$boiler1 = enelixDemoEnsureInstance($ems, 'Boiler1', 'Boiler 1 · 300 Liter', ENELIX_DEMO_BOILER_MODULE, 20); $boiler1 = enelixDemoEnsureInstance($ems, 'Boiler1', 'Boiler 1 · 300 Liter', ENELIX_DEMO_BOILER_MODULE, 20);
$boiler2 = enelixDemoEnsureInstance($ems, 'Boiler2', 'Boiler 2 · 500 Liter', ENELIX_DEMO_BOILER_MODULE, 30); $boiler2 = enelixDemoEnsureInstance($ems, 'Boiler2', 'Boiler 2 · 500 Liter', ENELIX_DEMO_BOILER_MODULE, 30);
$dryer = enelixDemoEnsureInstance($ems, 'Entfeuchter', 'Entfeuchter · 1-stufig', ENELIX_DEMO_SINGLE_MODULE, 40); $dryer = enelixDemoEnsureInstance($ems, 'Entfeuchter', 'Entfeuchter · 1-stufig', ENELIX_DEMO_SINGLE_MODULE, 40);
$buffer = enelixDemoEnsureInstance($ems, 'Pufferspeicher', 'Pufferspeicher · Heizkurve', ENELIX_DEMO_BUFFER_MODULE, 50);
$battery = enelixDemoEnsureInstance($ems, 'Batterie', 'Batteriespeicher · 10 kWh', ENELIX_DEMO_BATTERY_MODULE, 60);
enelixDemoConfigure($boiler1, [ enelixDemoConfigure($boiler1, [
'Boilerfuehler_PT1' => $temp1, 'Boilerfuehler_PT1' => $temp1,
@@ -157,6 +193,52 @@ function enelixDemoInstall(): array
'Mindestausschaltdauer' => 2, 'Mindestausschaltdauer' => 2,
'Mindestlaufzeit' => 0, 'Mindestlaufzeit' => 0,
'PrioritaetPV' => 2, 'PrioritaetPV' => 2,
'PrioritaetPeak' => 3,
'Meldeintervall' => 5,
'VorgabeTimeout' => 30,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
]);
enelixDemoConfigure($buffer, [
'LeistungsStufen' => json_encode([
['Stufe' => 1, 'Leistung' => 1800, 'Schaltkontakt_Stufe' => $buffer1],
['Stufe' => 2, 'Leistung' => 3600, 'Schaltkontakt_Stufe' => $buffer2],
], JSON_THROW_ON_ERROR),
'PufferfuehlerVariableID' => $bufferTemp,
'AussentemperaturVariableID' => $outsideTemp,
'FusspunktVorlauftemperatur' => 34.0,
'HeizkurvenSteigung' => 0.8,
'HeizkurveMinimaltemperatur' => 28.0,
'HeizkurveMaximaltemperatur' => 55.0,
'Hysterese' => 4.0,
'MindesttemperaturModus' => 0,
'LastwechselSperrzeit' => 2,
'TemperaturMaxAlter' => 30,
'PuffertemperaturGlaetten' => false,
'PrioritaetPV' => 3,
'PrioritaetPeak' => 4,
'Meldeintervall' => 5,
'VorgabeTimeout' => 30,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
]);
enelixDemoConfigure($battery, [
'Batterietyp' => 2,
'Batteriemanagement' => 2,
'MaxLadeleistungVariableID' => $batteryMaxCharge,
'MaxEntladeleistungVariableID' => $batteryMaxDischarge,
'LadezustandVariableID' => $batterySoc,
'NetzleistungVariableID' => $grid,
'IstleistungVariableID' => $batteryActual,
'ManagementRegisterVariableID' => $batteryManagement,
'ModusRegisterVariableID' => $batteryMode,
'LeistungsRegisterVariableID' => $batteryRegisterPower,
'ReserveLadezustand' => 20.0,
'MindestLadezustand' => 10.0,
'LadezustandHysterese' => 3.0,
'MesswertMaxAlter' => 30,
'Aenderungssperre' => 0,
'PrioritaetPV' => 4,
'PrioritaetPeak' => 0, 'PrioritaetPeak' => 0,
'Meldeintervall' => 5, 'Meldeintervall' => 5,
'VorgabeTimeout' => 30, 'VorgabeTimeout' => 30,
@@ -178,6 +260,8 @@ function enelixDemoInstall(): array
enelixDemoEnergyNode($flowBoiler1, 'Boiler 1', 'consumer', $temp1), enelixDemoEnergyNode($flowBoiler1, 'Boiler 1', 'consumer', $temp1),
enelixDemoEnergyNode($flowBoiler2, 'Boiler 2', 'consumer', $temp2), enelixDemoEnergyNode($flowBoiler2, 'Boiler 2', 'consumer', $temp2),
enelixDemoEnergyNode($flowDryer, 'Entfeuchter', 'consumer'), enelixDemoEnergyNode($flowDryer, 'Entfeuchter', 'consumer'),
enelixDemoEnergyNode($flowBuffer, 'Pufferspeicher', 'consumer', $bufferTemp),
enelixDemoEnergyNode($flowBattery, 'Batteriespeicher', 'battery', $batterySoc),
]); ]);
IPS_SetHidden($energyDistribution, false); IPS_SetHidden($energyDistribution, false);
$oldEnergyDistribution = enelixDemoFind($dashboard, 'Energieverteilung'); $oldEnergyDistribution = enelixDemoFind($dashboard, 'Energieverteilung');
@@ -188,34 +272,105 @@ function enelixDemoInstall(): array
foreach ([$boiler1, $boiler2] as $boilerId) { foreach ([$boiler1, $boiler2] as $boilerId) {
IPS_RequestAction($boilerId, 'Mindesttemperatur', 42.0); IPS_RequestAction($boilerId, 'Mindesttemperatur', 42.0);
IPS_RequestAction($boilerId, 'Maximaltemperatur', 62.0); IPS_RequestAction($boilerId, 'Maximaltemperatur', 62.0);
IPS_RequestAction($boilerId, 'Aktiv', true);
} }
IPS_RequestAction($dryer, 'Aktiv', true);
$manager = enelixDemoEnsureInstance($ems, 'Manager', 'Enelix Manager', ENELIX_DEMO_MANAGER_MODULE, 10); $manager = enelixDemoEnsureInstance($ems, 'Manager', 'Enelix Manager', ENELIX_DEMO_MANAGER_MODULE, 10);
$assignment = array_map( $assignment = array_map(
static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true], static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true],
[$boiler1, $boiler2, $dryer] [$boiler1, $boiler2, $dryer, $buffer, $battery]
); );
enelixDemoConfigure($manager, [ $pvNennleistungKw = max(0.1, (float) GetValue($peak) / 1000.0);
$wechselrichterKonfiguration = [[
'ID' => 'demo-hybrid-1',
'Name' => 'Demo-Hybridwechselrichter',
'Typ' => 'hybrid',
'ACNennleistung_kW' => $pvNennleistungKw,
'IstleistungVariableID' => $inverterActual,
'Istleistungsfaktor' => 1.0,
'BegrenzungVariableID' => $inverterLimit,
'Begrenzungsart' => 'watt',
]];
$pvKonfiguration = [[
'ID' => 'demo-pv-1',
'Name' => 'Demo-PV-Anlage',
'DCLeistung_kWp' => $pvNennleistungKw,
'Neigung_Grad' => 30.0,
'Azimut_Grad' => 0.0,
'WechselrichterID' => 'demo-hybrid-1',
'MPPT' => 'A',
'Modulanzahl' => 0,
'Modulleistung_Wp' => 0.0,
]];
$batterieKonfiguration = [[
'ID' => 'demo-battery-1',
'Name' => 'Batteriespeicher 10 kWh',
'Nennkapazitaet_kWh' => 10.0,
'Nutzkapazitaet_kWh' => 10.0,
'MaxLadeleistung_kW' => max(0.1, (float) GetValue($batteryMaxCharge) / 1000.0),
'MaxEntladeleistung_kW' => max(
0.1,
(float) GetValue($batteryMaxDischarge) / 1000.0
),
'WechselrichterID' => 'demo-hybrid-1',
'Kopplung' => 'hybrid',
'LeistungVariableID' => $batteryActual,
'Leistungsfaktor' => 1.0,
'SOCVariableID' => $batterySoc,
]];
$managerProperties = [
'NetzleistungVariableID' => $grid, 'NetzleistungVariableID' => $grid,
'Netzleistungsfaktor' => 1.0, 'Netzleistungsfaktor' => 1.0,
'MesswerteAnzeigen' => true,
'EnergyPieAnzeigen' => true,
'EnergiediagrammeAnzeigen' => true,
'FunFactsAnzeigen' => true,
'EnergieflussAnzeigen' => true,
'PrognoseSendeintervall' => 60,
'MesswertMaxAlter' => 10, 'MesswertMaxAlter' => 10,
'AutomatischeSuche' => false, 'AutomatischeSuche' => false,
'SuchbereichID' => $root, 'SuchbereichID' => $root,
'VerbraucherZuordnung' => json_encode($assignment, JSON_THROW_ON_ERROR), 'VerbraucherZuordnung' => '[]',
'KeepAlive' => 5, 'KeepAlive' => 5,
'VerbraucherTimeout' => 15, 'VerbraucherTimeout' => 15,
'SollwertSolarladen' => 0.0, 'SollwertSolarladen' => 0.0,
'Umschaltdifferenz' => 0.0, 'Umschaltdifferenz' => 0.0,
'EinspeisebegrenzungAktiv' => true,
'Einspeisegrenze' => 3000.0,
'PrognoseAktiv' => false,
'NetzfahrplanAktiv' => false,
'AnlagenWechselrichter' => json_encode(
$wechselrichterKonfiguration,
JSON_THROW_ON_ERROR
),
'AnlagenPVFlaechen' => json_encode($pvKonfiguration, JSON_THROW_ON_ERROR),
'AnlagenBatterien' => json_encode(
$batterieKonfiguration,
JSON_THROW_ON_ERROR
),
'DiagnosevariablenAnzeigen' => true, 'DiagnosevariablenAnzeigen' => true,
'LoggingEin' => false, 'LoggingEin' => false,
]); ];
enelixDemoConfigure($manager, $managerProperties, [102, 203]);
foreach ([$boiler1, $boiler2, $dryer] as $consumerId) { $managerReady = (int) IPS_GetInstance($manager)['InstanceStatus'] === 102;
IPS_RequestAction($consumerId, 'Melden', true); 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( $boiler1List = enelixDemoEnsureInstance(
$dashboard, $dashboard,
@@ -231,14 +386,32 @@ function enelixDemoInstall(): array
ENELIX_DEMO_DUMMY_MODULE, ENELIX_DEMO_DUMMY_MODULE,
120 120
); );
enelixDemoConfigure($boiler1List, []); $bufferList = enelixDemoEnsureInstance(
enelixDemoConfigure($boiler2List, []); $dashboard,
'PufferListe',
'Pufferspeicher',
ENELIX_DEMO_DUMMY_MODULE,
130
);
$batteryList = enelixDemoEnsureInstance(
$dashboard,
'BatterieListe',
'Batteriespeicher',
ENELIX_DEMO_DUMMY_MODULE,
140
);
foreach ([$boiler1List, $boiler2List, $bufferList, $batteryList] as $listId) {
enelixDemoConfigure($listId, []);
}
enelixDemoLinkInstanceVariables($boiler1List, $boiler1, $temp1); enelixDemoLinkInstanceVariables($boiler1List, $boiler1, $temp1);
enelixDemoLinkInstanceVariables($boiler2List, $boiler2, $temp2); enelixDemoLinkInstanceVariables($boiler2List, $boiler2, $temp2);
enelixDemoLinkInstanceVariables($bufferList, $buffer, $bufferTemp, 'Puffertemperatur', 'SensorPuffertemperatur');
enelixDemoLinkInstanceVariables($batteryList, $battery, $batterySoc, 'Ladezustand', 'SensorLadezustand');
IPS_SetHidden($boiler1View, true); IPS_SetHidden($boiler1View, true);
IPS_SetHidden($boiler2View, true); IPS_SetHidden($boiler2View, true);
IPS_SetHidden($boiler1, true); foreach ([$boiler1, $boiler2, $buffer, $battery] as $hiddenInstanceId) {
IPS_SetHidden($boiler2, true); IPS_SetHidden($hiddenInstanceId, true);
}
foreach (['Boiler1Link', 'Boiler2Link'] as $oldLinkIdent) { foreach (['Boiler1Link', 'Boiler2Link'] as $oldLinkIdent) {
$oldLink = enelixDemoFind($dashboard, $oldLinkIdent); $oldLink = enelixDemoFind($dashboard, $oldLinkIdent);
@@ -248,22 +421,25 @@ function enelixDemoInstall(): array
} }
enelixDemoEnsureLink($dashboard, 'ManagerLink', 'Manager', $manager, 100); enelixDemoEnsureLink($dashboard, 'ManagerLink', 'Manager', $manager, 100);
enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 130); enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 150);
enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 70); enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 100);
enelixDemoEnsureLink($simulation, 'Boiler2TemperaturLink', 'Boiler 2 Temperatur', $temp2, 80); enelixDemoEnsureLink($simulation, 'Boiler2TemperaturLink', 'Boiler 2 Temperatur', $temp2, 110);
enelixDemoEnsureLink($simulation, 'PufferTemperaturLink', 'Puffertemperatur', $bufferTemp, 120);
enelixDemoEnsureLink($simulation, 'AussenTemperaturLink', 'Aussentemperatur', $outsideTemp, 130);
enelixDemoEnsureLink($simulation, 'BatterieLadezustandLink', 'Batterie Ladezustand', $batterySoc, 140);
enelixDemoConfigureArchive([ enelixDemoConfigureArchive([
$pvDisplay, $houseDisplay, $loadDisplay, $grid, $pvDisplay, $houseDisplay, $loadDisplay, $grid,
$boiler1Power, $boiler2Power, $dryerPower, $boiler1Power, $boiler2Power, $dryerPower, $bufferPower, $batteryPower,
$pvEnergy, $houseEnergy, $gridImportEnergy, $gridExportEnergy, $pvEnergy, $houseEnergy, $gridImportEnergy, $gridExportEnergy,
$temp1, $temp2, $temp1, $temp2, $bufferTemp, $outsideTemp, $batterySoc, $batteryActual,
$flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2, $flowDryer, $flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2, $flowDryer,
$flowHouse, $flowLoads, $flowBuffer, $flowBattery, $flowHouse, $flowLoads,
]); ]);
enelixDemoEnsureChart($history, 'LeistungsdiagrammNativ', 'Leistungsverlauf', [ enelixDemoEnsureChart($history, 'LeistungsdiagrammNativ', 'Leistungsverlauf', [
enelixDemoChartDataset($flowPv, 'PV', '#f4c542', '~Power'), enelixDemoChartDataset($flowPv, 'PV', '#f4c542', '~Power'),
enelixDemoChartDataset($flowHouse, 'Hausverbrauch', '#2bb673', '~Power'), enelixDemoChartDataset($flowHouse, 'Hausverbrauch', '#2bb673', '~Power'),
enelixDemoChartDataset($flowLoads, 'Geregelte Verbraucher', '#2786ac', '~Power'), enelixDemoChartDataset($flowLoads, 'EMS-Leistung inkl. Speicher', '#2786ac', '~Power'),
enelixDemoChartDataset($flowGrid, 'Netzleistung', '#e95d5d', '~Power'), enelixDemoChartDataset($flowGrid, 'Netzleistung', '#e95d5d', '~Power'),
], 10); ], 10);
enelixDemoEnsureChart($history, 'EnergiediagrammNativ', 'Kumulierte Energien', [ enelixDemoEnsureChart($history, 'EnergiediagrammNativ', 'Kumulierte Energien', [
@@ -279,7 +455,7 @@ function enelixDemoInstall(): array
'rootId' => $root, 'rootId' => $root,
'visualizationId' => enelixDemoFirstInstance(ENELIX_DEMO_TILE_MODULE), 'visualizationId' => enelixDemoFirstInstance(ENELIX_DEMO_TILE_MODULE),
'managerId' => $manager, 'managerId' => $manager,
'consumerIds' => [$boiler1, $boiler2, $dryer], 'consumerIds' => $consumerIds,
'tickScriptId' => $tickScript, 'tickScriptId' => $tickScript,
]; ];
} }
@@ -301,13 +477,24 @@ function enelixDemoTick(string $source = 'Manual'): void
SetValue($hourId, $hour); SetValue($hourId, $hour);
} }
$dynamic = (bool) GetValue(enelixDemoRequiredId($simulation, 'DynamischeProfile'));
$environment = enelixDemoEnvironment(
$hour,
(float) GetValue(enelixDemoRequiredId($simulation, 'Grundlast')),
(int) GetValue(enelixDemoRequiredId($simulation, 'Bewoelkung')),
$dynamic
);
$base = $environment['base']
+ (float) GetValue(enelixDemoRequiredId($simulation, 'Zusatzlast'));
$peak = (float) GetValue(enelixDemoRequiredId($simulation, 'PVSpitze')); $peak = (float) GetValue(enelixDemoRequiredId($simulation, 'PVSpitze'));
$cloud = (int) GetValue(enelixDemoRequiredId($simulation, 'Bewoelkung'));
$base = (float) GetValue(enelixDemoRequiredId($simulation, 'Grundlast'));
$sun = ($hour >= 6.0 && $hour <= 18.0) $sun = ($hour >= 6.0 && $hour <= 18.0)
? max(0.0, sin(M_PI * ($hour - 6.0) / 12.0)) ? max(0.0, sin(M_PI * ($hour - 6.0) / 12.0))
: 0.0; : 0.0;
$pv = $peak * $sun * (1.0 - (min(100, max(0, $cloud)) * 0.0075)); $pvAvailable = $peak * $sun * (1.0 - ($environment['cloud'] * 0.0085));
$inverterActual = enelixDemoRequiredId($technical, 'WechselrichterIstleistung');
$inverterLimit = enelixDemoRequiredId($technical, 'WechselrichterLeistungsgrenze');
$pv = enelixDemoLimitPvPower($pvAvailable, (float) GetValue($inverterLimit));
SetValue($inverterActual, round($pv, 1));
$contacts = [ $contacts = [
'boiler11' => enelixDemoRequiredId($technical, 'Boiler1Stufe1'), 'boiler11' => enelixDemoRequiredId($technical, 'Boiler1Stufe1'),
@@ -315,15 +502,46 @@ function enelixDemoTick(string $source = 'Manual'): void
'boiler21' => enelixDemoRequiredId($technical, 'Boiler2Stufe1'), 'boiler21' => enelixDemoRequiredId($technical, 'Boiler2Stufe1'),
'boiler22' => enelixDemoRequiredId($technical, 'Boiler2Stufe2'), 'boiler22' => enelixDemoRequiredId($technical, 'Boiler2Stufe2'),
'dryer' => enelixDemoRequiredId($technical, 'EntfeuchterKontakt'), 'dryer' => enelixDemoRequiredId($technical, 'EntfeuchterKontakt'),
'buffer1' => enelixDemoRequiredId($technical, 'PufferStufe1'),
'buffer2' => enelixDemoRequiredId($technical, 'PufferStufe2'),
'battery' => enelixDemoRequiredId($technical, 'BatterieIstleistung'),
]; ];
$powerBefore = enelixDemoControlledPower($contacts);
$temp1 = enelixDemoRequiredId($technical, 'Boiler1Temperatur'); $temp1 = enelixDemoRequiredId($technical, 'Boiler1Temperatur');
$temp2 = enelixDemoRequiredId($technical, 'Boiler2Temperatur'); $temp2 = enelixDemoRequiredId($technical, 'Boiler2Temperatur');
$bufferTemp = enelixDemoRequiredId($technical, 'PufferTemperatur');
$outsideTemp = enelixDemoRequiredId($technical, 'AussenTemperatur');
$batterySoc = enelixDemoRequiredId($technical, 'BatterieLadezustand');
$batteryMaxCharge = enelixDemoRequiredId($technical, 'BatterieMaxLaden');
$batteryMaxDischarge = enelixDemoRequiredId($technical, 'BatterieMaxEntladen');
$previousBatteryPower = (float) GetValue($contacts['battery']);
SetValue($outsideTemp, round($environment['outside'], 2));
SetValue($batteryMaxCharge, GetValue($batteryMaxCharge));
SetValue($batteryMaxDischarge, GetValue($batteryMaxDischarge));
if ($source === 'Timer') { if ($source === 'Timer') {
enelixDemoUpdateTemperature($temp1, (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0), $minutes); enelixDemoUpdateTemperature(
enelixDemoUpdateTemperature($temp2, (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0), $minutes); $temp1,
(bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0),
$minutes
);
enelixDemoUpdateTemperature(
$temp2,
(bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0),
$minutes
);
enelixDemoUpdateBufferTemperature(
$bufferTemp,
(bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0),
$environment['outside'],
$minutes
);
enelixDemoUpdateBatterySoc($batterySoc, $previousBatteryPower, $minutes);
} else {
SetValue($bufferTemp, GetValue($bufferTemp));
SetValue($batterySoc, GetValue($batterySoc));
} }
$powerBefore = enelixDemoControlledPower($contacts);
$gridId = enelixDemoRequiredId($dashboard, 'Netzleistung'); $gridId = enelixDemoRequiredId($dashboard, 'Netzleistung');
SetValue($gridId, round($base + $powerBefore - $pv, 1)); SetValue($gridId, round($base + $powerBefore - $pv, 1));
@@ -331,38 +549,60 @@ function enelixDemoTick(string $source = 'Manual'): void
enelixDemoRequiredId($ems, 'Boiler1'), enelixDemoRequiredId($ems, 'Boiler1'),
enelixDemoRequiredId($ems, 'Boiler2'), enelixDemoRequiredId($ems, 'Boiler2'),
enelixDemoRequiredId($ems, 'Entfeuchter'), enelixDemoRequiredId($ems, 'Entfeuchter'),
enelixDemoRequiredId($ems, 'Pufferspeicher'),
enelixDemoRequiredId($ems, 'Batterie'),
]; ];
foreach ($consumers as $consumerId) {
IPS_RequestAction($consumerId, 'Melden', true);
}
$manager = enelixDemoRequiredId($ems, 'Manager'); $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); $boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0);
$boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0); $boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0);
$dryer = (bool) GetValue($contacts['dryer']) ? 900 : 0; $dryer = (bool) GetValue($contacts['dryer']) ? 900 : 0;
$power = $boiler1 + $boiler2 + $dryer; $buffer = (bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0);
$house = $base + $power; $loads = $boiler1 + $boiler2 + $dryer + $buffer;
$grid = $house - $pv; $emsPower = $loads + $battery;
$house = $base + $loads;
$grid = $house + $battery - $pv;
$pvId = enelixDemoRequiredId($dashboard, 'PVLeistung');
$houseId = enelixDemoRequiredId($dashboard, 'Hausverbrauch');
$loadId = enelixDemoRequiredId($dashboard, 'Verbraucherleistung');
SetValue($gridId, round($grid, 1)); SetValue($gridId, round($grid, 1));
SetValue($pvId, round($pv, 1)); SetValue(enelixDemoRequiredId($dashboard, 'PVLeistung'), round($pv, 1));
SetValue($houseId, round($house, 1)); SetValue(enelixDemoRequiredId($dashboard, 'Hausverbrauch'), round($house, 1));
SetValue($loadId, (float) $power); SetValue(enelixDemoRequiredId($dashboard, 'Verbraucherleistung'), (float) $emsPower);
SetValue(enelixDemoRequiredId($dashboard, 'Boiler1Leistung'), (float) $boiler1); SetValue(enelixDemoRequiredId($dashboard, 'Boiler1Leistung'), (float) $boiler1);
SetValue(enelixDemoRequiredId($dashboard, 'Boiler2Leistung'), (float) $boiler2); SetValue(enelixDemoRequiredId($dashboard, 'Boiler2Leistung'), (float) $boiler2);
SetValue(enelixDemoRequiredId($dashboard, 'EntfeuchterLeistung'), (float) $dryer); SetValue(enelixDemoRequiredId($dashboard, 'EntfeuchterLeistung'), (float) $dryer);
SetValue(enelixDemoRequiredId($dashboard, 'PufferLeistung'), (float) $buffer);
SetValue(enelixDemoRequiredId($dashboard, 'BatterieLeistung'), $battery);
SetValue(enelixDemoRequiredId($technical, 'EnergieflussPVkW'), round($pv / 1000.0, 3)); SetValue(enelixDemoRequiredId($technical, 'EnergieflussPVkW'), round($pv / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussNetzkW'), round($grid / 1000.0, 3)); SetValue(enelixDemoRequiredId($technical, 'EnergieflussNetzkW'), round($grid / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussGrundlastkW'), round($base / 1000.0, 3)); SetValue(enelixDemoRequiredId($technical, 'EnergieflussGrundlastkW'), round($base / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler1kW'), round($boiler1 / 1000.0, 3)); SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler1kW'), round($boiler1 / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler2kW'), round($boiler2 / 1000.0, 3)); SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler2kW'), round($boiler2 / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussEntfeuchterkW'), round($dryer / 1000.0, 3)); SetValue(enelixDemoRequiredId($technical, 'EnergieflussEntfeuchterkW'), round($dryer / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussPufferkW'), round($buffer / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBatteriekW'), round($battery / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'DiagrammHausverbrauchkW'), round($house / 1000.0, 3)); SetValue(enelixDemoRequiredId($technical, 'DiagrammHausverbrauchkW'), round($house / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($power / 1000.0, 3)); SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($emsPower / 1000.0, 3));
$history = enelixDemoRequiredId($root, 'Historie'); $history = enelixDemoRequiredId($root, 'Historie');
$energyIds = [ $energyIds = [
@@ -378,7 +618,11 @@ function enelixDemoTick(string $source = 'Manual'): void
enelixDemoAddEnergy($energyIds['import'], max(0.0, $grid), $hours); enelixDemoAddEnergy($energyIds['import'], max(0.0, $grid), $hours);
enelixDemoAddEnergy($energyIds['export'], max(0.0, -$grid), $hours); enelixDemoAddEnergy($energyIds['export'], max(0.0, -$grid), $hours);
} }
}
function enelixDemoLimitPvPower(float $availablePower, float $limitPower): float
{
return min(max(0.0, $availablePower), max(0.0, $limitPower));
} }
function enelixDemoRenderFlow(array $state): string function enelixDemoRenderFlow(array $state): string
@@ -390,9 +634,15 @@ function enelixDemoRenderFlow(array $state): string
$gridLabel = $grid < -25 ? 'Einspeisung' : ($grid > 25 ? 'Netzbezug' : 'Ausgeglichen'); $gridLabel = $grid < -25 ? 'Einspeisung' : ($grid > 25 ? 'Netzbezug' : 'Ausgeglichen');
$gridArrow = $grid < -25 ? '&rarr;' : ($grid > 25 ? '&larr;' : '&harr;'); $gridArrow = $grid < -25 ? '&rarr;' : ($grid > 25 ? '&larr;' : '&harr;');
$gridClass = abs($grid) <= 250 ? 'good' : ($grid < 0 ? 'export' : 'import'); $gridClass = abs($grid) <= 250 ? 'good' : ($grid < 0 ? 'export' : 'import');
$buffer = (float) ($state['buffer'] ?? 0.0);
$battery = (float) ($state['battery'] ?? 0.0);
$bufferTemp = (float) ($state['bufferTemp'] ?? 36.0);
$batterySoc = (float) ($state['batterySoc'] ?? 50.0);
$b1Class = (float) $state['boiler1'] > 0 ? 'on' : ''; $b1Class = (float) $state['boiler1'] > 0 ? 'on' : '';
$b2Class = (float) $state['boiler2'] > 0 ? 'on' : ''; $b2Class = (float) $state['boiler2'] > 0 ? 'on' : '';
$dryClass = (float) $state['dryer'] > 0 ? 'on' : ''; $dryClass = (float) $state['dryer'] > 0 ? 'on' : '';
$bufferClass = $buffer > 0 ? 'on' : '';
$batteryClass = abs($battery) > 25 ? 'on' : '';
return '<style> return '<style>
.enelix-flow{box-sizing:border-box;font-family:Inter,Segoe UI,Arial,sans-serif;background:#10151c;color:#f4f7fa;padding:18px;min-height:420px} .enelix-flow{box-sizing:border-box;font-family:Inter,Segoe UI,Arial,sans-serif;background:#10151c;color:#f4f7fa;padding:18px;min-height:420px}
@@ -403,14 +653,14 @@ function enelixDemoRenderFlow(array $state): string
.enelix-node small{display:block;color:#9dacbd;margin-bottom:8px}.enelix-node b{font-size:22px} .enelix-node small{display:block;color:#9dacbd;margin-bottom:8px}.enelix-node b{font-size:22px}
.enelix-node.solar{border-color:#b99628}.enelix-node.bus{border-color:#2786ac}.enelix-node.good{border-color:#2bb673} .enelix-node.solar{border-color:#b99628}.enelix-node.bus{border-color:#2786ac}.enelix-node.good{border-color:#2bb673}
.enelix-node.export{border-color:#38a3db}.enelix-node.import{border-color:#e95d5d}.enelix-arrow{text-align:center;font-size:30px;color:#9dacbd} .enelix-node.export{border-color:#38a3db}.enelix-node.import{border-color:#e95d5d}.enelix-arrow{text-align:center;font-size:30px;color:#9dacbd}
.enelix-loads{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:18px} .enelix-loads{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin-top:18px}
.enelix-load{border-left:4px solid #566371;background:#17202a;padding:13px 14px;border-radius:6px} .enelix-load{border-left:4px solid #566371;background:#17202a;padding:13px 14px;border-radius:6px}
.enelix-load.on{border-left-color:#2bb673}.enelix-load b{display:block;font-size:17px;margin:4px 0}.enelix-load span{color:#9dacbd;font-size:12px} .enelix-load.on{border-left-color:#2bb673}.enelix-load b{display:block;font-size:17px;margin:4px 0}.enelix-load span{color:#9dacbd;font-size:12px}
.enelix-foot{display:flex;justify-content:space-between;gap:12px;margin-top:16px;padding-top:12px;border-top:1px solid #2a3541;color:#9dacbd;font-size:12px} .enelix-foot{display:flex;justify-content:space-between;gap:12px;margin-top:16px;padding-top:12px;border-top:1px solid #2a3541;color:#9dacbd;font-size:12px}
@media(max-width:640px){.enelix-line{grid-template-columns:1fr}.enelix-arrow{transform:rotate(90deg)}.enelix-loads{grid-template-columns:1fr}.enelix-head{align-items:start;flex-direction:column;gap:4px}} @media(max-width:640px){.enelix-line{grid-template-columns:1fr}.enelix-arrow{transform:rotate(90deg)}.enelix-loads{grid-template-columns:1fr}.enelix-head{align-items:start;flex-direction:column;gap:4px}}
</style><div class="enelix-flow"><div class="enelix-head"><div><div class="enelix-title">Enelix 2 Energiefluss</div><div class="enelix-sub">Manager regelt drei flexible Verbraucher</div></div><div class="enelix-sub">Simulation ' . $time . ' Uhr</div></div> </style><div class="enelix-flow"><div class="enelix-head"><div><div class="enelix-title">Enelix 2 Energiefluss</div><div class="enelix-sub">Manager regelt fünf flexible Teilnehmer aus vier Modultypen</div></div><div class="enelix-sub">Simulation ' . $time . ' Uhr</div></div>
<div class="enelix-line"><div class="enelix-node solar"><small>PV-Erzeugung</small><b>' . $format((float) $state['pv']) . ' W</b></div><div class="enelix-arrow">&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-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> <div class="enelix-loads"><div class="enelix-load ' . $b1Class . '"><span>Priorität 1</span><b>Boiler 1 · ' . $format((float) $state['boiler1']) . ' W</b><span>' . number_format((float) $state['temp1'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $b2Class . '"><span>Priorität 2</span><b>Boiler 2 · ' . $format((float) $state['boiler2']) . ' W</b><span>' . number_format((float) $state['temp2'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $dryClass . '"><span>Priorität 3</span><b>Entfeuchter · ' . $format((float) $state['dryer']) . ' W</b><span>einstufiger Verbraucher</span></div><div class="enelix-load ' . $bufferClass . '"><span>Priorität 4</span><b>Pufferspeicher · ' . $format($buffer) . ' W</b><span>' . number_format($bufferTemp, 1, ',', '') . ' °C · Heizkurve</span></div><div class="enelix-load ' . $batteryClass . '"><span>Priorität 5 / Peak 1</span><b>Batterie · ' . $format($battery) . ' W</b><span>' . number_format($batterySoc, 1, ',', '') . ' % · bidirektional</span></div></div>
<div class="enelix-foot"><span>Grundlast ' . $format((float) $state['base']) . ' W</span><span>Manager: ' . htmlspecialchars((string) $state['manager'], ENT_QUOTES, 'UTF-8') . '</span></div></div>'; <div class="enelix-foot"><span>Grundlast ' . $format((float) $state['base']) . ' W</span><span>Manager: ' . htmlspecialchars((string) $state['manager'], ENT_QUOTES, 'UTF-8') . '</span></div></div>';
} }
@@ -418,7 +668,65 @@ function enelixDemoControlledPower(array $contacts): int
{ {
$boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0); $boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0);
$boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0); $boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0);
return $boiler1 + $boiler2 + ((bool) GetValue($contacts['dryer']) ? 900 : 0); $buffer = (bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0);
$battery = (int) round((float) GetValue($contacts['battery']));
return $boiler1 + $boiler2 + ((bool) GetValue($contacts['dryer']) ? 900 : 0) + $buffer + $battery;
}
/** @return array{base: float, cloud: float, outside: float} */
function enelixDemoEnvironment(float $hour, float $base, int $cloud, bool $dynamic): array
{
$hour = fmod(max(0.0, $hour), 24.0);
$cloud = min(100, max(0, $cloud));
if (!$dynamic) {
return ['base' => $base, 'cloud' => (float) $cloud, 'outside' => 6.0];
}
$morning = exp(-pow(($hour - 7.5) / 1.5, 2));
$evening = exp(-pow(($hour - 19.0) / 2.2, 2));
$night = ($hour < 5.0 || $hour >= 23.0) ? 0.65 : 0.82;
$baseFactor = $night + (0.48 * $morning) + (0.72 * $evening);
$cloudVariation = (18.0 * sin(($hour + 0.4) * 1.7)) + (8.0 * sin($hour * 4.6));
$outside = 7.0 + (6.0 * sin(2.0 * M_PI * ($hour - 9.0) / 24.0));
return [
'base' => max(150.0, $base * $baseFactor),
'cloud' => min(100.0, max(0.0, $cloud + $cloudVariation)),
'outside' => $outside,
];
}
function enelixDemoBatteryRegisterPower(int $management, int $mode, float $power): float
{
if ($management !== 2) {
return 0.0;
}
if ($mode === 11) {
return max(0.0, $power);
}
if ($mode === 12) {
return -max(0.0, $power);
}
return 0.0;
}
function enelixDemoUpdateBufferTemperature(int $variableId, int $power, float $outside, int $minutes): void
{
$current = (float) GetValue($variableId);
$heating = ($power / 1000.0) * 0.018 * $minutes;
$loss = max(0.0, $current - $outside) * 0.0007 * $minutes;
SetValue($variableId, round(min(65.0, max(22.0, $current + $heating - $loss)), 2));
}
function enelixDemoUpdateBatterySoc(int $variableId, float $power, int $minutes): void
{
$current = (float) GetValue($variableId);
$energy = ($power / 1000.0) * ($minutes / 60.0);
$storedEnergy = $energy >= 0.0 ? $energy * 0.94 : $energy / 0.94;
$delta = ($storedEnergy / 10.0) * 100.0;
SetValue($variableId, round(min(100.0, max(5.0, $current + $delta)), 2));
} }
function enelixDemoUpdateTemperature(int $variableId, int $power, int $minutes): void function enelixDemoUpdateTemperature(int $variableId, int $power, int $minutes): void
@@ -439,6 +747,8 @@ function enelixDemoValidateRuntime(): void
ENELIX_DEMO_MANAGER_MODULE, ENELIX_DEMO_MANAGER_MODULE,
ENELIX_DEMO_BOILER_MODULE, ENELIX_DEMO_BOILER_MODULE,
ENELIX_DEMO_SINGLE_MODULE, ENELIX_DEMO_SINGLE_MODULE,
ENELIX_DEMO_BUFFER_MODULE,
ENELIX_DEMO_BATTERY_MODULE,
ENELIX_DEMO_DUMMY_MODULE, ENELIX_DEMO_DUMMY_MODULE,
ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE, ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE,
] as $moduleId) { ] as $moduleId) {
@@ -450,10 +760,11 @@ function enelixDemoValidateRuntime(): void
function enelixDemoEnsureProfiles(): void function enelixDemoEnsureProfiles(): void
{ {
enelixDemoEnsureProfile('ENELIX.Demo.Power', 2, -12000, 12000, 100, 0, ' W'); enelixDemoEnsureProfile('ENELIX.Demo.Power', 2, -20000, 20000, 100, 0, ' W');
enelixDemoEnsureProfile('ENELIX.Demo.PowerPositive', 2, 0, 12000, 100, 0, ' W'); enelixDemoEnsureProfile('ENELIX.Demo.PowerPositive', 2, 0, 20000, 100, 0, ' W');
enelixDemoEnsureProfile('ENELIX.Demo.Hour', 2, 0, 23.75, 0.25, 2, ' h'); enelixDemoEnsureProfile('ENELIX.Demo.Hour', 2, 0, 23.75, 0.25, 2, ' h');
enelixDemoEnsureProfile('ENELIX.Demo.Percent', 1, 0, 100, 5, 0, ' %'); enelixDemoEnsureProfile('ENELIX.Demo.Percent', 1, 0, 100, 5, 0, ' %');
enelixDemoEnsureProfile('ENELIX.Demo.PercentFloat', 2, 0, 100, 0.1, 1, ' %');
enelixDemoEnsureProfile('ENELIX.Demo.Speed', 1, 1, 60, 1, 0, ' min'); enelixDemoEnsureProfile('ENELIX.Demo.Speed', 1, 1, 60, 1, 0, ' min');
enelixDemoEnsureProfile('ENELIX.Demo.Energy', 2, 0, 1000000, 0.01, 2, ' kWh'); enelixDemoEnsureProfile('ENELIX.Demo.Energy', 2, 0, 1000000, 0.01, 2, ' kWh');
} }
@@ -628,15 +939,34 @@ function enelixDemoEnsureCyclicEvent(int $scriptId, string $ident, int $seconds)
return $id; return $id;
} }
function enelixDemoConfigure(int $instanceId, array $properties): void function enelixDemoConfigure(
int $instanceId,
array $properties,
array $allowedStatuses = [102]
): void
{ {
foreach ($properties as $name => $value) { foreach ($properties as $name => $value) {
IPS_SetProperty($instanceId, (string) $name, $value); IPS_SetProperty($instanceId, (string) $name, $value);
} }
IPS_ApplyChanges($instanceId); IPS_ApplyChanges($instanceId);
if ((int) IPS_GetInstance($instanceId)['InstanceStatus'] !== 102) { $deadline = microtime(true) + 2.0;
throw new RuntimeException('Instanz ist nach der Konfiguration nicht aktiv: ' . IPS_GetName($instanceId)); do {
$status = (int) IPS_GetInstance($instanceId)['InstanceStatus'];
if ($status === 102) {
return;
}
IPS_Sleep(50);
} while (microtime(true) < $deadline);
if (in_array($status, $allowedStatuses, true)) {
return;
} }
throw new RuntimeException(
'Instanz ist nach der Konfiguration nicht aktiv: '
. IPS_GetName($instanceId)
. ' (Status ' . $status . ', erwartet: ' . implode(', ', $allowedStatuses) . ')'
);
} }
function enelixDemoConfigureArchive(array $variableIds): void function enelixDemoConfigureArchive(array $variableIds): void
@@ -713,7 +1043,7 @@ function enelixDemoRemove(bool $confirmed = false): void
IPS_ApplyChanges($visualization); IPS_ApplyChanges($visualization);
} }
enelixDemoDeleteTree($root); enelixDemoDeleteTree($root);
foreach (['ENELIX.Demo.Power', 'ENELIX.Demo.PowerPositive', 'ENELIX.Demo.Hour', 'ENELIX.Demo.Percent', 'ENELIX.Demo.Speed', 'ENELIX.Demo.Energy'] as $profile) { foreach (['ENELIX.Demo.Power', 'ENELIX.Demo.PowerPositive', 'ENELIX.Demo.Hour', 'ENELIX.Demo.Percent', 'ENELIX.Demo.PercentFloat', 'ENELIX.Demo.Speed', 'ENELIX.Demo.Energy'] as $profile) {
if (IPS_VariableProfileExists($profile)) { if (IPS_VariableProfileExists($profile)) {
IPS_DeleteVariableProfile($profile); IPS_DeleteVariableProfile($profile);
} }
@@ -771,9 +1101,14 @@ function enelixDemoConfigureEnergyDistribution(int $instanceId, array $nodes): v
]); ]);
} }
function enelixDemoLinkInstanceVariables(int $categoryId, int $instanceId, int $sensorId): void function enelixDemoLinkInstanceVariables(
{ int $categoryId,
enelixDemoEnsureLink($categoryId, 'SensorTemperatur', 'Gemessene Temperatur', $sensorId, 5); int $instanceId,
int $sensorId,
string $sensorName = 'Gemessene Temperatur',
string $sensorIdent = 'SensorTemperatur'
): void {
enelixDemoEnsureLink($categoryId, $sensorIdent, $sensorName, $sensorId, 5);
$position = 10; $position = 10;
foreach (IPS_GetChildrenIDs($instanceId) as $childId) { foreach (IPS_GetChildrenIDs($instanceId) as $childId) {
if ((int) IPS_GetObject($childId)['ObjectType'] !== 2) { if ((int) IPS_GetObject($childId)['ObjectType'] !== 2) {
@@ -0,0 +1,71 @@
--- a/install.php
+++ b/install.php
@@ -19,6 +19,8 @@
}
$before=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$report['files']=v4StageInstall(__DIR__,'/var/lib/symcon/modules/Enelix-EMS');
+ require_once __DIR__.'/installed_capture_bootstrap.php';
+ $report['classLoading']=v4ApplicationLoadInstalledCapture('/var/lib/symcon/modules/Enelix-EMS',__DIR__.'/MANIFEST.json');
$controls=[];
foreach (IPS_GetModuleList() as $mid) if ((IPS_GetModule($mid)['Prefix']??'')==='MC') $controls=array_merge($controls,IPS_GetInstanceListByModuleID($mid));
if (count($controls)!==1||!function_exists('MC_ReloadModule')) throw new RuntimeException('Module Control nicht eindeutig.');
@@ -38,7 +40,7 @@
$marker=$dir.'/observer-import.json';
if (!is_file($marker)&&!IPS_GetProperty($manager,'NetzfahrplanV4MessdatenAktiv')) {
if (glob($dir.'/raw-*.jsonl')!==[]) throw new RuntimeException('Messdaten ohne Importabschluss vorhanden; nicht doppelt importieren.');
- require_once __DIR__.'/source/libs/NetzfahrplanV4Messaufnahme.php';
+ // Reuse the hash-checked installed class; never load the staged copy.
$count=0;$last=0;$cutoff=time()-172800;
$files=glob('/srv/agent/netplan-v4-separated-observer-stage/data/raw-*.jsonl');sort($files,SORT_STRING);
foreach ($files as $file) {
--- /dev/null
+++ b/installed_capture_bootstrap.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+/** Installer-only bootstrap. Load the same files the installed Manager uses.
+ * No IPS calls, source edits, state changes, or autoload of an unknown class.
+ */
+function v4ApplicationLoadInstalledCapture(string $target, string $manifestFile): array
+{
+ if (is_link($target) || realpath($target) !== $target || !is_dir($target)) {
+ throw new RuntimeException('Unerwartetes Modulverzeichnis.');
+ }
+ $manifest = json_decode((string) file_get_contents($manifestFile), true, 64, JSON_THROW_ON_ERROR);
+ if (($manifest['mode'] ?? null) !== 'data_application_only') {
+ throw new RuntimeException('Unerwarteter Installationsumfang.');
+ }
+ $classes = [
+ 'Belevo\\EnelixEMS\\NetzfahrplanV4Bilanzierung' => 'libs/NetzfahrplanV4Bilanzierung.php',
+ 'Belevo\\EnelixEMS\\NetzfahrplanV4Messaufnahme' => 'libs/NetzfahrplanV4Messaufnahme.php',
+ ];
+ // Check BOTH origins before loading either file, including the transitive dependency.
+ foreach ($classes as $class => $relative) {
+ $path = $target . '/' . $relative;
+ $expected = $manifest['files'][$relative]['after'] ?? null;
+ if (!is_string($expected) || !preg_match('/^[a-f0-9]{64}$/D', $expected)
+ || is_link($path) || !is_file($path) || realpath($path) !== $path
+ || hash_file('sha256', $path) !== $expected) {
+ throw new RuntimeException('Installierte Messbibliothek weicht vom geprueften Paket ab: ' . $relative);
+ }
+ if (class_exists($class, false)) {
+ $origin = (new ReflectionClass($class))->getFileName();
+ if ($origin !== $path) {
+ throw new RuntimeException('Messbibliothek bereits aus anderem Pfad geladen. Installationsaufruf separat ausfuehren, ohne weitere Sammler-Includes.');
+ }
+ }
+ }
+ foreach ($classes as $class => $relative) {
+ $path = $target . '/' . $relative;
+ if (!class_exists($class, false)) {
+ require_once $path;
+ }
+ if (!class_exists($class, false) || (new ReflectionClass($class))->getFileName() !== $path) {
+ throw new RuntimeException('Installierte Messbibliothek konnte nicht eindeutig geladen werden.');
+ }
+ }
+ return ['source' => 'installed_module_only', 'stagedClassesLoaded' => false,
+ 'files' => array_values($classes)];
+}
@@ -0,0 +1,41 @@
{
"scope": "confirmed_feedback_trial_disabled",
"managerId": 17004,
"batteryInstanceId": 44234,
"files": {
"libs/NetzfahrplanV4Rueckmeldung.php": {
"before": "a7459a208c4dd84ffe3a166d266dcd00216f3660fe60e5ee61d5042d0b840631",
"after": "0b261f6534c5ca9864018ea4c2caf97e6ce6913d92f13dcc926383b4f401a673"
},
"libs/NetzfahrplanV4Geraeteabruf.php": {
"before": null,
"after": "2b69a2ed27554139db6c996c906a4afe430918381e6359d23e8bb3369cd9be47"
},
"libs/BatterieNetzfahrplanV4RueckmeldungTrait.php": {
"before": "24cf0654f1a6bd545d64ce5f115f309b16d8ffca5172ec64fb37c390c6f001a7",
"after": "1de81499da07ad814c98ba778ca728e34817bdfcf29cc7e518f7b5f8fd7733b7"
}
},
"testSupport": {
"libs/BatterieNetzfahrplanV4TestTrait.php": "80474ff24cf9b2d32dfe481f0ce26bb94584c458b0fa3de232dec3897b64a4b2",
"libs/NetzfahrplanV4Regeltest.php": "bdac3e2e54c50a4c1541da8918ddf3ab2f665bb5cc6696848f546e7b05f5f249",
"libs/ManagerNetzfahrplanV4TestTrait.php": "c0d9b6336574dd8acf5f84adb12a32cad3b42ae91c5a31d513d0bc638a560f18",
"libs/NetzfahrplanV4Planpruefung.php": "24b935d80bad4ce2d9047b6777aaf74a18773236ca91a2844a0907012f4fadf4"
},
"tests": {
"tests/V4Feedback/checks.php": "b1aacc540ba11adabdd140aaded9e2968d1328f261ca15756e6002db3427330a",
"tests/V4Feedback/device_read_checks.php": "5c5d9e50dd1d0f80cb01a2d95e63018eeec558ba78c07afde208e3c0ef304cc3",
"tests/V4ControlTrial/checks.php": "2f12e94aeb207e6939a645f2f45b5f54e57c18530360ed3b16aa52ff89a1ef39",
"tests/V4ControlTrial/additional_checks.php": "df01119f2b9c0befe1d14ae6b6ddf39c75d3687b669142e9b24b5f9f4821f2c3",
"tests/V4ControlTrial/feedback_checks.php": "e87d41dff8e3ecd414a59b3dd7a234b34183d37acca8cddaf847b82eae92e60c",
"tests/V4Receiver/fixture.php": "0eeb65e63250aff5ca364a82c2b2a71f9f229be2d9d412e3037014979deb793a"
},
"configurationSha256": "e393c1f01b30febc1d0785233474be43e98d849b152b4b2450b72efa6b73d540",
"dependencies": {
"libs/NetzfahrplanV4Regeltest.php": "bdac3e2e54c50a4c1541da8918ddf3ab2f665bb5cc6696848f546e7b05f5f249",
"libs/ManagerNetzfahrplanV4TestTrait.php": "c0d9b6336574dd8acf5f84adb12a32cad3b42ae91c5a31d513d0bc638a560f18",
"libs/ManagerNetzfahrplanV4EmpfangTrait.php": "936939a3e4a5a5d54fdff68f6fc689a9400e8406aecd5ac09ebc8a6b569c0b66",
"Batterie/module.php": "3d5fcd83e4e7d999d5ac949fe357b492d5661ffa76639d2ca57a3f3934eabdbe",
"Manager/module.php": "cbbb33a8f71dda8cc80a4fc01cd3d6154da8153b5523306b2da723bb44f2dd79"
}
}
+18
View File
@@ -0,0 +1,18 @@
# V4 confirmed feedback for Lihrenmoos
This incremental package adds synchronous device reads before V4 feedback is evaluated.
- Grid input 40348 is refreshed through its M-Bus device.
- GoodWe and SolarEdge inputs are refreshed through their ModBus parent instances.
- A successful driver call is required; cached variable timestamps alone are not accepted.
- Daniel Haefliger accepted the existing `virtual_split` model for the bounded field test on 2026-10-04.
- The installer keeps the legacy schedule and both V4 trial permissions disabled.
- A real control trial still requires separate server authorization and evidence of an independent device-side watchdog.
Build a private staging directory:
```sh
python3 examples/V4ConfirmedFeedback/build_stage.py /srv/agent/netplan-v4-confirmed-feedback
```
Run the staged PHP checks before copying the package to the test plant. Execute `install.php` only from the IP-Symcon script editor on Lihrenmoos.
@@ -0,0 +1,49 @@
"""Create the reviewed Lihrenmoos confirmed-feedback package; never installs into Symcon."""
from pathlib import Path
import hashlib
import json
import sys
here = Path(__file__).resolve().parent
repo = here.parents[1]
if len(sys.argv) != 2:
raise SystemExit("Usage: python3 build_stage.py NEW_PRIVATE_STAGING_DIRECTORY")
target = Path(sys.argv[1]).resolve()
allowed_roots = [Path("/srv/agent"), Path("/home/agent/services/qa")]
if not any(target != root and target.is_relative_to(root) for root in allowed_roots):
raise SystemExit("Use a private agent staging directory, not live module paths")
if target.exists():
raise SystemExit("Destination must not exist")
manifest = json.loads((here / "MANIFEST.json").read_text())
contents = {}
for group in ("files", "testSupport", "tests"):
for name, record in manifest[group].items():
expected = record["after"] if group == "files" else record
source = repo / name
if source.is_symlink() or not source.resolve().is_relative_to(repo):
raise SystemExit("Unsafe source: " + name)
data = source.read_bytes()
if hashlib.sha256(data).hexdigest() != expected:
raise SystemExit("Reviewed source changed: " + name)
contents["source/" + name] = data
for name, expected in manifest["dependencies"].items():
if hashlib.sha256((repo / name).read_bytes()).hexdigest() != expected:
raise SystemExit("Dependency changed: " + name)
for name in ("install.php", "file_installer.php", "MANIFEST.json", "feedback-config.json", "README.md"):
contents[name] = (here / name).read_bytes()
if hashlib.sha256(contents["feedback-config.json"]).hexdigest() != manifest["configurationSha256"]:
raise SystemExit("Configuration changed")
target.mkdir(parents=True, mode=0o700)
for name, data in contents.items():
path = target / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
path.chmod(0o640)
(target / "PACKAGE_HASHES.json").write_text(
json.dumps({name: hashlib.sha256(data).hexdigest() for name, data in contents.items()}, indent=2) + "\n"
)
print("Private confirmed-feedback package created:", target, "; both trial permissions remain disabled.")
@@ -0,0 +1,71 @@
{
"version": 1,
"installationId": "e3a08f9e-af12-4695-99bd-8b51c0520021",
"assetId": "anlage01-virtual-ev",
"managerId": 17004,
"batteryInstanceId": 44234,
"mode": "virtual_split",
"allowEstimatedForTrial": true,
"maxSkewSeconds": 30,
"maxAgeSeconds": 60,
"idleToleranceW": 50.0,
"trackingToleranceW": 200.0,
"sources": [
{
"key": "grid",
"variableId": 40348,
"parentId": 11490,
"ident": "Power_8",
"factorToW": 1000,
"role": "grid"
},
{
"key": "physical_goodwe1",
"variableId": 47725,
"parentId": 19742,
"ident": "A_6_3_35182",
"factorToW": -1,
"role": "physical"
},
{
"key": "physical_goodwe2",
"variableId": 35724,
"parentId": 57658,
"ident": "A_6_3_35182",
"factorToW": -1,
"role": "physical"
},
{
"key": "physical_solaredge",
"variableId": 21447,
"parentId": 30789,
"ident": "Value",
"factorToW": 1,
"role": "physical"
},
{
"key": "ev_requested",
"variableId": 19651,
"parentId": 58448,
"ident": "Nennleistung_Soll_EV",
"factorToW": 1.0,
"role": "ev_request"
},
{
"key": "sdl_requested",
"variableId": 38943,
"parentId": 58448,
"ident": "Nennleistung_Soll_SDL",
"factorToW": 1.0,
"role": "sdl_request"
},
{
"key": "gateway_active",
"role": "gateway_active",
"variableId": 23483,
"parentId": 58448,
"ident": "State",
"factorToW": 1.0
}
]
}
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/** Atomic incremental installer for the confirmed-feedback update. */
function v4ConfirmedWrite(string $path, string $content, int $mode = 0644): void
{
if (is_link($path)) {
throw new RuntimeException('Symlink wird nicht ersetzt.');
}
$temp = tempnam(dirname($path), '.v4-confirmed-');
if ($temp === false) {
throw new RuntimeException('Temporaere Datei nicht verfuegbar.');
}
try {
if (file_put_contents($temp, $content, LOCK_EX) !== strlen($content)
|| !chmod($temp, $mode)
|| !rename($temp, $path)) {
throw new RuntimeException('Atomarer Dateitausch fehlgeschlagen.');
}
} finally {
if (is_file($temp)) {
unlink($temp);
}
}
}
function v4ConfirmedFiles(string $stage, string $target): array
{
$allowed = [
'libs/NetzfahrplanV4Rueckmeldung.php',
'libs/NetzfahrplanV4Geraeteabruf.php',
'libs/BatterieNetzfahrplanV4RueckmeldungTrait.php',
];
$manifest = json_decode((string) file_get_contents($stage . '/MANIFEST.json'), true, 32, JSON_THROW_ON_ERROR);
if (($manifest['scope'] ?? null) !== 'confirmed_feedback_trial_disabled'
|| count($manifest['files'] ?? []) !== count($allowed)
|| array_diff(array_keys($manifest['files']), $allowed)
|| array_diff($allowed, array_keys($manifest['files']))) {
throw new RuntimeException('Unerwarteter Dateiumfang.');
}
if (realpath($target) !== $target || is_link($target)) {
throw new RuntimeException('Modulpfad unerwartet.');
}
foreach ($manifest['dependencies'] as $name => $hash) {
if (!preg_match('~^(libs/[A-Za-z0-9]+\\.php|Batterie/module\\.php|Manager/module\\.php)$~D', $name)
|| is_link($target . '/' . $name)
|| hash_file('sha256', $target . '/' . $name) !== $hash) {
throw new RuntimeException('Abhaengigkeit wurde parallel geaendert: ' . $name);
}
}
$before = [];
$source = [];
$already = true;
foreach ($allowed as $name) {
$path = $target . '/' . $name;
$candidate = $stage . '/source/' . $name;
if (is_link($path) || is_link($candidate) || realpath(dirname($path)) !== $target . '/' . dirname($name)) {
throw new RuntimeException('Unerwarteter Dateipfad.');
}
$source[$name] = (string) file_get_contents($candidate);
if (hash('sha256', $source[$name]) !== $manifest['files'][$name]['after']) {
throw new RuntimeException('Paketpruefsumme geaendert: ' . $name);
}
token_get_all($source[$name], TOKEN_PARSE);
$before[$name] = is_file($path) ? file_get_contents($path) : null;
$hash = $before[$name] === null ? null : hash('sha256', $before[$name]);
if (!in_array($hash, [$manifest['files'][$name]['before'], $manifest['files'][$name]['after']], true)) {
throw new RuntimeException('Paralleler Modulstand; nichts ueberschrieben: ' . $name);
}
if ($hash !== $manifest['files'][$name]['after']) {
$already = false;
}
}
if ($already) {
return ['status' => 'already_installed', 'backup' => null, 'filesChanged' => []];
}
$backup = $stage . '/backups/' . gmdate('Ymd\\THis\\Z') . '-' . bin2hex(random_bytes(4));
if (!mkdir($backup, 0700, true)) {
throw new RuntimeException('Sicherung fehlgeschlagen.');
}
foreach ($allowed as $name) {
if ($before[$name] !== null) {
$destination = $backup . '/' . $name;
if (!is_dir(dirname($destination))) {
mkdir(dirname($destination), 0700, true);
}
v4ConfirmedWrite($destination, $before[$name], 0600);
}
}
v4ConfirmedWrite(
$backup . '/MANIFEST.json',
json_encode($manifest, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . "\n",
0600
);
$written = [];
try {
foreach ($allowed as $name) {
if ((is_file($target . '/' . $name) ? file_get_contents($target . '/' . $name) : null) !== $before[$name]) {
throw new RuntimeException('Parallele Aenderung waehrend Installation.');
}
if ($before[$name] === $source[$name]) {
continue;
}
v4ConfirmedWrite($target . '/' . $name, $source[$name]);
$written[] = $name;
}
} catch (Throwable $error) {
foreach (array_reverse($written) as $name) {
$path = $target . '/' . $name;
if (!is_link($path) && is_file($path)
&& hash_file('sha256', $path) === $manifest['files'][$name]['after']) {
if ($before[$name] === null) {
unlink($path);
} else {
v4ConfirmedWrite($path, $before[$name]);
}
}
}
throw $error;
}
return ['status' => 'installed', 'backup' => $backup, 'filesChanged' => $written];
}
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
// Execute only in Lihrenmoos Symcon. Installs confirmed reads and accepted virtual_split mapping; never starts a trial.
if (!function_exists('IPS_GetKernelVersion')) {
throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
}
require_once __DIR__ . '/file_installer.php';
$manager = 17004;
$battery = 44234;
$locked = false;
$changedMapping = false;
$oldMapping = null;
$report = [
'scope' => 'confirmed_feedback_trial_disabled',
'startedAt' => gmdate('c'),
'actuatorPermissionGranted' => false,
'serverUpdateRequired' => true,
'acceptedModel' => 'virtual_split',
];
try {
$hashes = json_decode((string) file_get_contents(__DIR__ . '/PACKAGE_HASHES.json'), true, 32, JSON_THROW_ON_ERROR);
foreach ($hashes as $name => $hash) {
if (str_contains($name, '..') || str_starts_with($name, '/') || is_link(__DIR__ . '/' . $name)
|| hash_file('sha256', __DIR__ . '/' . $name) !== $hash) {
throw new RuntimeException('Vorbereitetes Paket veraendert.');
}
}
foreach ([
$manager => '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
$battery => '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}',
] as $id => $module) {
if (!IPS_InstanceExists($id) || IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== $module) {
throw new RuntimeException('Modulzuordnung ungueltig.');
}
if (IPS_HasChanges($id)) {
throw new RuntimeException('Offene Aenderungen zuerst speichern oder verwerfen.');
}
}
$beforeManager = json_decode(IPS_GetConfiguration($manager), true, 128, JSON_THROW_ON_ERROR);
$beforeBattery = json_decode(IPS_GetConfiguration($battery), true, 128, JSON_THROW_ON_ERROR);
if (($beforeManager['NetzfahrplanAktiv'] ?? null) !== false
|| ($beforeManager['NetzfahrplanV4RegeltestErlaubt'] ?? false) !== false
|| ($beforeBattery['NetzfahrplanV4RegeltestErlaubt'] ?? false) !== false) {
throw new RuntimeException('Alte Fahrplanausgabe und beide Regeltestfreigaben muessen AUS bleiben.');
}
$configuration = json_decode((string) file_get_contents(__DIR__ . '/feedback-config.json'), true, 32, JSON_THROW_ON_ERROR);
$manifest = json_decode((string) file_get_contents(__DIR__ . '/MANIFEST.json'), true, 32, JSON_THROW_ON_ERROR);
if (($configuration['managerId'] ?? null) !== $manager
|| ($configuration['batteryInstanceId'] ?? null) !== $battery
|| ($configuration['mode'] ?? null) !== 'virtual_split'
|| ($configuration['allowEstimatedForTrial'] ?? null) !== true
|| hash_file('sha256', __DIR__ . '/feedback-config.json') !== $manifest['configurationSha256']) {
throw new RuntimeException('Falsche Rueckmeldekonfiguration.');
}
$desired = json_encode($configuration, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
$previousConfiguration = $configuration;
$previousConfiguration['allowEstimatedForTrial'] = false;
$previousDesired = json_encode($previousConfiguration, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
$oldMapping = $beforeBattery['NetzfahrplanV4RueckmeldungKonfiguration'] ?? '{}';
if (!in_array($oldMapping, [$previousDesired, $desired], true)) {
throw new RuntimeException('Abweichende Rueckmeldekonfiguration nicht ueberschrieben.');
}
$controls = [];
foreach (IPS_GetModuleList() as $module) {
if ((IPS_GetModule($module)['Prefix'] ?? '') === 'MC') {
$controls = array_merge($controls, IPS_GetInstanceListByModuleID($module));
}
}
if (count($controls) !== 1 || !function_exists('MC_ReloadModule')) {
throw new RuntimeException('Module Control nicht eindeutig.');
}
$locked = IPS_SemaphoreEnter('ENELIX.V4.ConfirmedFeedbackInstall', 3000);
if (!$locked) {
throw new RuntimeException('Installation bereits aktiv.');
}
$report['files'] = v4ConfirmedFiles(__DIR__, '/var/lib/symcon/modules/Enelix-EMS');
if (MC_ReloadModule($controls[0], 'Enelix-EMS') === false) {
throw new RuntimeException('Bibliotheks-Reload fehlgeschlagen.');
}
$afterManager = json_decode(IPS_GetConfiguration($manager), true, 128, JSON_THROW_ON_ERROR);
$afterBattery = json_decode(IPS_GetConfiguration($battery), true, 128, JSON_THROW_ON_ERROR);
foreach ([[$beforeManager, $afterManager], [$beforeBattery, $afterBattery]] as $pair) {
foreach ($pair[0] as $key => $value) {
if (!array_key_exists($key, $pair[1]) || $pair[1][$key] !== $value) {
throw new RuntimeException('Bestehender Parameter beim Reload veraendert: ' . $key);
}
}
}
if (!function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
$report['status'] = 'waiting_for_registration';
echo "Dateien installiert. Modulregistrierung wird abgeschlossen; diesen Aufruf einmal wiederholen. Keine Stellfreigabe.\n";
} else {
if ($afterBattery['NetzfahrplanV4RueckmeldungKonfiguration'] !== $desired) {
IPS_SetProperty($battery, 'NetzfahrplanV4RueckmeldungKonfiguration', $desired);
$changedMapping = true;
IPS_ApplyChanges($battery);
}
$finalManager = json_decode(IPS_GetConfiguration($manager), true, 128, JSON_THROW_ON_ERROR);
$finalBattery = json_decode(IPS_GetConfiguration($battery), true, 128, JSON_THROW_ON_ERROR);
foreach ([[$beforeManager, $finalManager], [$beforeBattery, $finalBattery]] as $pair) {
foreach ($pair[0] as $key => $value) {
if ($key !== 'NetzfahrplanV4RueckmeldungKonfiguration'
&& (!array_key_exists($key, $pair[1]) || $pair[1][$key] !== $value)) {
throw new RuntimeException('Bestehender Parameter unerwartet veraendert: ' . $key);
}
}
}
if (($finalManager['NetzfahrplanAktiv'] ?? null) !== false
|| ($finalManager['NetzfahrplanV4RegeltestErlaubt'] ?? null) !== false
|| ($finalBattery['NetzfahrplanV4RegeltestErlaubt'] ?? null) !== false) {
throw new RuntimeException('Unerwartete Stellfreigabe nach Installation.');
}
$feedback = json_decode(ENELIX_GetV4BatterieRueckmeldung($battery), true, 32, JSON_THROW_ON_ERROR);
$report['feedback'] = array_intersect_key($feedback, array_flip([
'status', 'kind', 'method', 'reason', 'reasons', 'estimated', 'batteryW', 'gridW',
'sourceOldestAt', 'sourceVariableOldestAt', 'confirmedAt', 'checkedAt', 'configHash',
'deviceReadConfirmed', 'usableForTrial', 'canDispatch',
]));
$report['status'] = 'confirmed_feedback_installed_trial_disabled';
$report['originalParametersPreserved'] = true;
echo "OK: Geraeteabfrage bestaetigt die physischen Messwerte; virtual_split ist fuer den begrenzten Regeltest akzeptiert.\n";
echo 'Rueckmeldung: ' . ($feedback['status'] ?? 'available')
. '; Geraeteabfrage: ' . (($feedback['deviceReadConfirmed'] ?? false) ? 'bestaetigt' : 'nicht bestaetigt')
. '; Modellanteil: ' . (($feedback['estimated'] ?? false) ? 'ja' : 'nein') . ".\n";
if (isset($feedback['reason'])) {
echo 'Aktueller Hinweis: ' . $feedback['reason'] . "\n";
}
echo "Beide Regeltestfreigaben AUS. Kein Stellbefehl ohne separat nachgewiesenen Geraete-Watchdog und Serverfreigabe.\n";
}
} catch (Throwable $error) {
$report['status'] = 'needs_review';
$report['reason'] = substr($error->getMessage(), 0, 260);
if ($changedMapping && $oldMapping !== null) {
try {
IPS_SetProperty($battery, 'NetzfahrplanV4RueckmeldungKonfiguration', $oldMapping);
IPS_ApplyChanges($battery);
$report['mappingRestored'] = true;
} catch (Throwable $ignored) {
$report['mappingRestored'] = false;
}
}
echo 'FEHLER: ' . $report['reason'] . "\nKeine V4-Stellfreigabe erteilt.\n";
} finally {
$report['finishedAt'] = gmdate('c');
v4ConfirmedWrite(__DIR__ . '/INSTALL_RESULT.json', json_encode($report, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . "\n", 0644);
if ($locked) {
IPS_SemaphoreLeave('ENELIX.V4.ConfirmedFeedbackInstall');
}
echo 'CONFIRMED FEEDBACK INSTALL REPORT: ' . __DIR__ . "/INSTALL_RESULT.json\n";
}
@@ -0,0 +1,65 @@
{
"scope": "corrected_feedback_trial_disabled",
"managerId": 17004,
"batteryInstanceId": 44234,
"files": {
"libs/BatterieNetzfahrplanV4RueckmeldungTrait.php": {
"before": null,
"after": "24cf0654f1a6bd545d64ce5f115f309b16d8ffca5172ec64fb37c390c6f001a7"
},
"libs/BatterieNetzfahrplanV4TestTrait.php": {
"before": null,
"after": "80474ff24cf9b2d32dfe481f0ce26bb94584c458b0fa3de232dec3897b64a4b2"
},
"libs/BatterieRegler.php": {
"before": "035988c54d4d0cc3a59d234f1c08a8edbf5fcac42e09a6bcea61d3dd8270771b",
"after": "beecc9c817b722336367e1c574c1a38afc8f3f5346442170d67c101b980adf7b"
},
"libs/ManagerNetzfahrplanV4EmpfangTrait.php": {
"before": "d083cfa6ae8e8026c134d2b53f34bb6fbe342fb7332a3e7da1b881de33e13e97",
"after": "936939a3e4a5a5d54fdff68f6fc689a9400e8406aecd5ac09ebc8a6b569c0b66"
},
"libs/ManagerNetzfahrplanV4TestTrait.php": {
"before": null,
"after": "c0d9b6336574dd8acf5f84adb12a32cad3b42ae91c5a31d513d0bc638a560f18"
},
"libs/NetzfahrplanV4Regeltest.php": {
"before": null,
"after": "bdac3e2e54c50a4c1541da8918ddf3ab2f665bb5cc6696848f546e7b05f5f249"
},
"libs/NetzfahrplanV4Rueckmeldung.php": {
"before": null,
"after": "a7459a208c4dd84ffe3a166d266dcd00216f3660fe60e5ee61d5042d0b840631"
},
"Batterie/module.php": {
"before": "34503645f7e21dd1e5bf0a3f754f6592175dd67f9a263bf431f3801209555401",
"after": "3d5fcd83e4e7d999d5ac949fe357b492d5661ffa76639d2ca57a3f3934eabdbe"
},
"Manager/module.php": {
"before": "83e80883c8f9b817c0af5e239b20bcb019c7cf7f7943ee5bcc6493ac5a743532",
"after": "cbbb33a8f71dda8cc80a4fc01cd3d6154da8153b5523306b2da723bb44f2dd79"
}
},
"configurationSha256": "f66cfb9ebf5e02259b902f876a4c78756a51b3f2f2c240fd92c0f01161268c56",
"dependencies": {
"libs/NetzfahrplanV4Planpruefung.php": "24b935d80bad4ce2d9047b6777aaf74a18773236ca91a2844a0907012f4fadf4",
"libs/Nachrichtenvertrag.php": "215ad7d4c5240b39b48cb7672f5057afa532aafdb34039862a1a22985a9ee839",
"libs/VerbraucherBasisTrait.php": "10fd9f54283c378104bce11d623d5581558355d26fc2f85bf04b316542874507",
"libs/VerbraucherSchnittstelle.php": "6329d423dca7b551f01fd884204be45be43f92cd6da6e4faedf2c3c9b4654a15",
"libs/ManagerNetzfahrplanV4Trait.php": "e27a3595e4ed8cdb62e2561149ef6a50adb9194e0239eb25c1216aee2716fa88",
"libs/ManagerNetzfahrplanV4DatenTrait.php": "59ae0ae283839ca82c58db9c071dcf28d0ab95e44f7a74c6473aca837ce416c4",
"libs/NetzfahrplanV4Datenarchiv.php": "57ef7a60c273c5270b3e93ff5528d7d294e708725c9edfe81ac7ed819587f983",
"libs/NetzfahrplanV4Messaufnahme.php": "ebe873bb714041e505a4f500b34c9bae3a5a0b93ac459161584ced758507a6e9",
"libs/NetzfahrplanV4Bilanzierung.php": "ffda2a03dbb226a4405a2ad66704c080a9dc475ce64282ad3f0d7d60034ab199",
"libs/NetzfahrplanV4Betriebsdaten.php": "6ff7d5710995778e7f941020a6f18555307ef51f16867f13efc204915e6dc9d9",
"libs/NetzfahrplanV4Bezugszaehler.php": "7aa01ce83a343eb767a889575fa04cece7f1c65cda347723e24dd68da40cea9a",
"libs/ManagerEnergieTrait.php": "2f89b0ad31ee6dd99180a7225173036eb6212b2ea7157e57fee23f0f8b441a51",
"libs/EnergieMessung.php": "16c64e1cbefde81c25f913aaca6546f0d30cc8f2fddecea0768a3760a98e97d2",
"libs/StoerungsSnapshot.php": "c871c1dd58d7fb1ef6c06985de066518688ef080f3cbdadda192d66cedcf49e1",
"libs/Anlagentopologie.php": "1b7e95d103a3048ed38c464a424f2f12821a147b269e149fd6b89bca84878c47",
"libs/Lizenzpruefung.php": "cb93cb49b93ed3ebb2060dba28b5b2dec2f29548d6ccff1afd9c7cb4624ad77e",
"libs/EinspeiseRegler.php": "8b982be5b95275c6dd6611a2bf2b1f89b991ba53cf94e46df0eed3249add65e0",
"libs/ManagerRegler.php": "608a4abebbe45da37a16316a530fa3f5ab21cb880bb341acbdcd3a9aaa93c6ee",
"libs/ManagerSchnittstelle.php": "0aae8c8c85eb252c577fcb0b5a50a12f3247e4a0bb083579122854f6c35878b3"
}
}
@@ -0,0 +1,25 @@
"""Recreate a reviewed Lihrenmoos package from this commit; never install in Symcon."""
from pathlib import Path
import hashlib,json,sys
here=Path(__file__).resolve().parent;repo=here.parents[1]
if len(sys.argv)!=2:raise SystemExit('Usage: python3 build_stage.py NEW_PRIVATE_STAGING_DIRECTORY')
target=Path(sys.argv[1]).resolve()
allowed=[Path('/srv/agent'),Path('/home/agent/services/qa')]
if not any(target!=p and target.is_relative_to(p) for p in allowed):raise SystemExit('Use a private agent staging directory, not live module paths')
if target.exists():raise SystemExit('Destination must not exist')
m=json.loads((here/'MANIFEST.json').read_text());contents={}
for n,v in m['files'].items():
p=repo/n
if p.is_symlink() or not p.resolve().is_relative_to(repo):raise SystemExit('Unsafe source')
data=p.read_bytes()
if hashlib.sha256(data).hexdigest()!=v['after']:raise SystemExit('Reviewed source changed: '+n)
contents['source/'+n]=data
for n,v in m['dependencies'].items():
if hashlib.sha256((repo/n).read_bytes()).hexdigest()!=v:raise SystemExit('Dependency changed: '+n)
for n in ['install.php','file_installer.php','MANIFEST.json','feedback-config.json']:contents[n]=(here/n).read_bytes()
if hashlib.sha256(contents['feedback-config.json']).hexdigest()!=m['configurationSha256']:raise SystemExit('Configuration changed')
target.mkdir(parents=True,mode=0o700)
for n,data in contents.items():
p=target/n;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data);p.chmod(0o640)
(target/'PACKAGE_HASHES.json').write_text(json.dumps({n:hashlib.sha256(v).hexdigest() for n,v in contents.items()},indent=2)+'\n')
print('Private staging package created:',target,'; no running module, settings or control changes.')
@@ -0,0 +1,71 @@
{
"version": 1,
"installationId": "e3a08f9e-af12-4695-99bd-8b51c0520021",
"assetId": "anlage01-virtual-ev",
"managerId": 17004,
"batteryInstanceId": 44234,
"mode": "virtual_split",
"allowEstimatedForTrial": false,
"maxSkewSeconds": 30,
"maxAgeSeconds": 60,
"idleToleranceW": 50.0,
"trackingToleranceW": 200.0,
"sources": [
{
"key": "grid",
"variableId": 40348,
"parentId": 11490,
"ident": "Power_8",
"factorToW": 1000,
"role": "grid"
},
{
"key": "physical_goodwe1",
"variableId": 47725,
"parentId": 19742,
"ident": "A_6_3_35182",
"factorToW": -1,
"role": "physical"
},
{
"key": "physical_goodwe2",
"variableId": 35724,
"parentId": 57658,
"ident": "A_6_3_35182",
"factorToW": -1,
"role": "physical"
},
{
"key": "physical_solaredge",
"variableId": 21447,
"parentId": 30789,
"ident": "Value",
"factorToW": 1,
"role": "physical"
},
{
"key": "ev_requested",
"variableId": 19651,
"parentId": 58448,
"ident": "Nennleistung_Soll_EV",
"factorToW": 1.0,
"role": "ev_request"
},
{
"key": "sdl_requested",
"variableId": 38943,
"parentId": 58448,
"ident": "Nennleistung_Soll_SDL",
"factorToW": 1.0,
"role": "sdl_request"
},
{
"key": "gateway_active",
"role": "gateway_active",
"variableId": 23483,
"parentId": 58448,
"ident": "State",
"factorToW": 1.0
}
]
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/** File-only installation helpers. No module classes loaded from the staging tree. */
function v4FeedbackWrite(string $path, string $content, int $mode=0644): void
{
if (is_link($path)) throw new RuntimeException('Symlink wird nicht ersetzt.');
$temp=tempnam(dirname($path),'.v4-feedback-');
if ($temp===false) throw new RuntimeException('Temporaere Datei nicht verfuegbar.');
try {
if (file_put_contents($temp,$content,LOCK_EX)!==strlen($content) || !chmod($temp,$mode) || !rename($temp,$path)) throw new RuntimeException('Atomarer Dateitausch fehlgeschlagen.');
} finally { if(is_file($temp))unlink($temp); }
}
function v4FeedbackFiles(string $stage,string $target): array
{
$allowed=['libs/NetzfahrplanV4Rueckmeldung.php','libs/BatterieNetzfahrplanV4RueckmeldungTrait.php','libs/NetzfahrplanV4Regeltest.php',
'libs/BatterieNetzfahrplanV4TestTrait.php','libs/ManagerNetzfahrplanV4TestTrait.php','libs/ManagerNetzfahrplanV4EmpfangTrait.php','libs/BatterieRegler.php','Batterie/module.php','Manager/module.php'];
$m=json_decode((string)file_get_contents($stage.'/MANIFEST.json'),true,32,JSON_THROW_ON_ERROR);
if (($m['scope']??null)!=='corrected_feedback_trial_disabled' || count($m['files']??[])!==count($allowed) || array_diff(array_keys($m['files']),$allowed)) throw new RuntimeException('Unerwarteter Dateiumfang.');
if (realpath($target)!==$target || is_link($target)) throw new RuntimeException('Modulpfad unerwartet.');
foreach($m['dependencies'] as $name=>$hash) {
if (!preg_match('~^libs/[A-Za-z0-9]+\.php$~D',$name) || is_link($target.'/'.$name) || hash_file('sha256',$target.'/'.$name)!==$hash) throw new RuntimeException('Abhaengigkeit wurde parallel geaendert: '.$name);
}
$before=[];$source=[];$already=true;
// Explicit dependency order. Both top-level modules are replaced only after all dependencies.
foreach($allowed as$name) {
$path=$target.'/'.$name;$candidate=$stage.'/source/'.$name;
if (is_link($path)||is_link($candidate)||realpath(dirname($path))!==$target.'/'.dirname($name)) throw new RuntimeException('Unerwarteter Dateipfad.');
$source[$name]=(string)file_get_contents($candidate);
if (hash('sha256',$source[$name])!==$m['files'][$name]['after']) throw new RuntimeException('Paketpruefsumme geaendert: '.$name);
token_get_all($source[$name],TOKEN_PARSE);
$before[$name]=is_file($path)?file_get_contents($path):null;
$hash=$before[$name]===null?null:hash('sha256',$before[$name]);
if (!in_array($hash,[$m['files'][$name]['before'],$m['files'][$name]['after']],true)) throw new RuntimeException('Paralleler Modulstand; nichts ueberschrieben: '.$name);
if($hash!==$m['files'][$name]['after'])$already=false;
}
if($already)return ['status'=>'already_installed','backup'=>null];
$backup=$stage.'/backups/'.gmdate('Ymd\THis\Z').'-'.bin2hex(random_bytes(4));
if(!mkdir($backup,0700,true))throw new RuntimeException('Sicherung fehlgeschlagen.');
foreach($allowed as$name) {
if($before[$name]!==null) {
$dest=$backup.'/'.$name;if(!is_dir(dirname($dest)))mkdir(dirname($dest),0700,true);
v4FeedbackWrite($dest,$before[$name],0600);
}
}
v4FeedbackWrite($backup.'/MANIFEST.json',json_encode($m,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0600);
$written=[];
try {
foreach($allowed as$name) {
if((is_file($target.'/'.$name)?file_get_contents($target.'/'.$name):null)!==$before[$name])throw new RuntimeException('Parallele Aenderung waehrend Installation.');
if($before[$name]===$source[$name])continue;
v4FeedbackWrite($target.'/'.$name,$source[$name]);$written[]=$name;
}
} catch(Throwable $e) {
foreach(array_reverse($written)as$name) {
$p=$target.'/'.$name;
if(!is_link($p)&&is_file($p)&&hash_file('sha256',$p)===$m['files'][$name]['after']) {
if($before[$name]===null)unlink($p);else v4FeedbackWrite($p,$before[$name]);
}
}
throw $e;
}
return ['status'=>'installed','backup'=>$backup,'filesChanged'=>$written];
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
// Execute only in Lihrenmoos Symcon. Installs code and readonly feedback mapping; never starts a trial.
if(!function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
require_once __DIR__.'/file_installer.php';
$manager=17004;$battery=44234;$locked=false;$changedMapping=false;$oldMapping=null;
$report=['scope'=>'corrected_feedback_trial_disabled','startedAt'=>gmdate('c'),'actuatorPermissionGranted'=>false,'serverUpdateRequired'=>false];
try {
$hashes=json_decode((string)file_get_contents(__DIR__.'/PACKAGE_HASHES.json'),true,32,JSON_THROW_ON_ERROR);
foreach($hashes as$name=>$hash) {
if(str_contains($name,'..')||str_starts_with($name,'/')||is_link(__DIR__.'/'.$name)||hash_file('sha256',__DIR__.'/'.$name)!==$hash)throw new RuntimeException('Vorbereitetes Paket veraendert.');
}
foreach([$manager=>'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',$battery=>'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}']as$id=>$module) {
if(!IPS_InstanceExists($id)||IPS_GetInstance($id)['ModuleInfo']['ModuleID']!==$module)throw new RuntimeException('Modulzuordnung ungueltig.');
if(IPS_HasChanges($id))throw new RuntimeException('Offene Aenderungen zuerst speichern oder verwerfen.');
}
$beforeM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$beforeB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR);
if(($beforeM['NetzfahrplanAktiv']??null)!==false || ($beforeM['NetzfahrplanV4RegeltestErlaubt']??false)!==false || ($beforeB['NetzfahrplanV4RegeltestErlaubt']??false)!==false)throw new RuntimeException('Alte Fahrplanausgabe und beide Regeltestfreigaben muessen AUS bleiben.');
$c=json_decode((string)file_get_contents(__DIR__.'/feedback-config.json'),true,32,JSON_THROW_ON_ERROR);
$manifest=json_decode((string)file_get_contents(__DIR__.'/MANIFEST.json'),true,32,JSON_THROW_ON_ERROR);
if(($c['managerId']??null)!==$manager || ($c['batteryInstanceId']??null)!==$battery || ($c['allowEstimatedForTrial']??null)!==false
|| hash_file('sha256',__DIR__.'/feedback-config.json')!==$manifest['configurationSha256'])throw new RuntimeException('Falsche Rueckmeldekonfiguration.');
$desired=json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION);
$oldMapping=$beforeB['NetzfahrplanV4RueckmeldungKonfiguration']??'{}';
if(!in_array($oldMapping,['','{}',$desired],true))throw new RuntimeException('Abweichende Rueckmeldekonfiguration nicht ueberschrieben.');
$controls=[];foreach(IPS_GetModuleList()as$module)if((IPS_GetModule($module)['Prefix']??'')==='MC')$controls=array_merge($controls,IPS_GetInstanceListByModuleID($module));
if(count($controls)!==1||!function_exists('MC_ReloadModule'))throw new RuntimeException('Module Control nicht eindeutig.');
$locked=IPS_SemaphoreEnter('ENELIX.V4.FeedbackInstall',3000);if(!$locked)throw new RuntimeException('Installation bereits aktiv.');
$report['files']=v4FeedbackFiles(__DIR__,'/var/lib/symcon/modules/Enelix-EMS');
if(MC_ReloadModule($controls[0],'Enelix-EMS')===false)throw new RuntimeException('Bibliotheks-Reload fehlgeschlagen.');
$afterM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$afterB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR);
foreach([[$beforeM,$afterM],[$beforeB,$afterB]]as$pair)foreach($pair[0]as$key=>$value)if(!array_key_exists($key,$pair[1])||$pair[1][$key]!==$value)throw new RuntimeException('Bestehender Parameter beim Reload veraendert: '.$key);
if(!array_key_exists('NetzfahrplanV4RueckmeldungKonfiguration',$afterB)||!array_key_exists('NetzfahrplanV4RegeltestErlaubt',$afterM)
||!function_exists('ENELIX_GetV4BatterieRueckmeldung')||!function_exists('ENELIX_GetV4ManagerTestStatus')) {
$report['status']='waiting_for_registration';
echo "Dateien installiert. Modulregistrierung wird abgeschlossen; diesen Aufruf einmal wiederholen. Keine Stellfreigabe.\n";
} else {
if($afterM['NetzfahrplanV4RegeltestErlaubt']!==false||$afterB['NetzfahrplanV4RegeltestErlaubt']!==false)throw new RuntimeException('Unerwartete Testfreigabe.');
if($afterB['NetzfahrplanV4RueckmeldungKonfiguration']!==$desired) {
IPS_SetProperty($battery,'NetzfahrplanV4RueckmeldungKonfiguration',$desired);$changedMapping=true;IPS_ApplyChanges($battery);
}
$finalM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$finalB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR);
foreach([[$beforeM,$finalM],[$beforeB,$finalB]]as$pair)foreach($pair[0]as$key=>$value)if($key!=='NetzfahrplanV4RueckmeldungKonfiguration'&&(!array_key_exists($key,$pair[1])||$pair[1][$key]!==$value))throw new RuntimeException('Bestehender Parameter unerwartet veraendert: '.$key);
if(($finalM['NetzfahrplanAktiv']??null)!==false||($finalM['NetzfahrplanV4RegeltestErlaubt']??null)!==false||($finalB['NetzfahrplanV4RegeltestErlaubt']??null)!==false)throw new RuntimeException('Unerwartete Stellfreigabe nach Installation.');
$f=json_decode(ENELIX_GetV4BatterieRueckmeldung($battery),true,32,JSON_THROW_ON_ERROR);
$report['feedback']=array_intersect_key($f,array_flip(['status','kind','method','reason','reasons','estimated','batteryW','gridW','sourceOldestAt','checkedAt','configHash','usableForTrial','canDispatch']));
$report['status']='corrected_feedback_installed_trial_disabled';$report['originalParametersPreserved']=true;
echo "OK: Korrigierte physische Rueckmeldung mit Manager-Vorschau und Batterietreiber verbunden.\n";
echo 'Rueckmeldung: '.($f['status']??'available').'; Modellanteil: '.(($f['estimated']??false)?'ja':'nein').".\n";
if(isset($f['reason']))echo 'Aktueller Hinweis: '.$f['reason']."\n";
echo "Beide Regeltestfreigaben AUS. Keine V4-Ausgabe aktiviert; vorhandene Regelung, Energiekonten und Datensender bleiben bestehen.\n";
}
} catch(Throwable $e) {
$report['status']='needs_review';$report['reason']=substr($e->getMessage(),0,260);
if($changedMapping && $oldMapping!==null) {
try{IPS_SetProperty($battery,'NetzfahrplanV4RueckmeldungKonfiguration',$oldMapping);IPS_ApplyChanges($battery);$report['mappingRestored']=true;}catch(Throwable $ignored){$report['mappingRestored']=false;}
}
echo 'FEHLER: '.$report['reason']."\nKeine V4-Stellfreigabe erteilt.\n";
} finally {
$report['finishedAt']=gmdate('c');v4FeedbackWrite(__DIR__.'/INSTALL_RESULT.json',json_encode($report,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0644);
if($locked)IPS_SemaphoreLeave('ENELIX.V4.FeedbackInstall');
echo 'FEEDBACK INSTALL REPORT: '.__DIR__."/INSTALL_RESULT.json\n";
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/runtime.php';
/** Creates ONLY dedicated capture objects. No module reload, device settings or archives. */
function v4captureEnsure(int $parent, string $ident, int $type, string $name, string $marker, array &$created): int
{
$existing = v4captureChild($parent, $ident);
if ($existing !== null) {
v4captureAssertObject($existing, $parent, $ident, $type, $marker);
return $existing;
}
$id = match ($type) { 0 => IPS_CreateCategory(), 2 => IPS_CreateVariable(3), 3 => IPS_CreateScript(0) };
if (!is_int($id) || $id <= 0) throw new RuntimeException('Cannot create capture object');
$created[] = $id;
if (!IPS_SetParent($id, $parent) || !IPS_SetIdent($id, $ident) || !IPS_SetName($id, $name) || !IPS_SetInfo($id, $marker)) {
throw new RuntimeException('Cannot label capture object');
}
return $id;
}
function v4captureScript(int $id, string $content, array $created): void
{
$current = IPS_GetScriptContent($id);
if ($current === $content) return;
if (!in_array($id, $created, true)) throw new RuntimeException('Existing capture script changed; not overwritten');
if (!IPS_SetScriptContent($id, $content) || IPS_GetScriptContent($id) !== $content) throw new RuntimeException('Cannot store capture script');
}
function v4captureInstall(string $stage): array
{
if (!function_exists('IPS_GetKernelVersion') || version_compare(IPS_GetKernelVersion(),'8.0','<')) {
throw new RuntimeException('Nur im IP-Symcon-Skripteditor der Testanlage ausfuehren.');
}
$c = v4captureConfig($stage); $marker = v4captureMarker($c);
if (!IPS_InstanceExists($c['managerId'])
|| IPS_GetInstance($c['managerId'])['ModuleInfo']['ModuleID'] !== '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}') {
throw new RuntimeException('Expected manager not found');
}
// Verify layout only; stale/missing sensor updates remain diagnostic raw data, not fake zeros.
foreach (array_merge($c['accounting']['sources'], $c['extraSources']) as $s) {
if (!IPS_VariableExists($s['variableId'])) throw new RuntimeException('Expected source missing: ' . $s['key']);
$o = IPS_GetObject($s['variableId']); $v = IPS_GetVariable($s['variableId']);
if ($o['ParentID'] !== $s['parentId'] || $o['ObjectIdent'] !== $s['ident'] || !in_array($v['VariableType'],[1,2],true)) {
throw new RuntimeException('Source identity changed: ' . $s['key']);
}
}
$data = $stage . '/data';
if (is_link($data) || !is_dir($data) || !is_writable($data)) throw new RuntimeException('Dedicated capture directory not writable');
$key = 'ENELIX.V4.CaptureInstall.' . $c['installationId'];
if (!IPS_SemaphoreEnter($key, 3000)) throw new RuntimeException('Capture installation busy');
$created = []; $script = null; $timerSet = false;
$report = ['startedAt' => gmdate('c'), 'scope' => 'passive_raw_capture_only', 'controlEnabled' => false,
'existingModulesChanged' => false, 'archivesChanged' => false, 'existingHistoriesChanged' => false];
try {
$category = v4captureEnsure(0,'ENELIX_V4_PASSIVE_CAPTURE',0,'V4 - separate Messaufnahme (keine Steuerung)',$marker,$created);
v4captureEnsure($category,'CaptureStatus',2,'Aufzeichnungsstatus (nur lesen)',$marker,$created);
$script = v4captureEnsure($category,'RawCapture',3,'Messwerte alle 30 Sekunden erfassen',$marker,$created);
$stop = v4captureEnsure($category,'StopCapture',3,'Nur diese Messaufnahme stoppen',$marker,$created);
$include = '<?php' . "\n" . 'require_once ' . var_export($stage . '/source/examples/V4DataCapture/runtime.php',true) . ';' . "\n";
v4captureScript($script, $include . 'v4captureRun(' . var_export($stage,true) . ', ' . $category . ');' . "\n", $created);
v4captureScript($stop, $include . 'v4captureStop(' . var_export($stage,true) . ', ' . $category . ');' . "\n", $created);
$first = v4captureRun($stage, $category);
if ($first['status'] !== 'recorded') throw new RuntimeException('Capture not stored: ' . ($first['reason'] ?? $first['status']));
// This new timer never invokes a manager, gateway, battery or Modbus function.
if (!IPS_SetScriptTimer($script, 30)) throw new RuntimeException('Cannot start dedicated capture timer');
$timerSet = true;
$report += ['status' => 'capture_installed', 'categoryId' => $category, 'scriptId' => $script,
'stopScriptId' => $stop, 'intervalSeconds' => 30, 'firstCapture' => $first,
'futureTimerExecutionVerified' => false];
echo "OK: Separate Rohdatenaufnahme eingerichtet, alle 30 Sekunden.\n";
echo 'Erste Aufnahme: ' . $first['capturedAt'] . '; Quellen: ' . $first['sourceCount'] . ".\n";
echo 'Bilanzqualitaet: ' . $first['accountingQuality'] . " (noch keine Trainings-/Stellfreigabe).\n";
echo "Bestehende Manager-, Batterie-, Archiv- und Pollingkonfiguration unveraendert.\n";
} catch (Throwable $e) {
$report['status'] = 'needs_review'; $report['reason'] = substr($e->getMessage(),0,300);
if ($timerSet && $script !== null) IPS_SetScriptTimer($script,0);
// Never delete unrelated objects or stored data during recovery.
echo 'FEHLER: ' . $report['reason'] . "\n";
} finally {
$report['createdObjects'] = $created; $report['finishedAt'] = gmdate('c');
try { v4captureAtomicJson($stage . '/INSTALL_RESULT.json', $report); }
finally { IPS_SemaphoreLeave($key); }
echo 'CAPTURE REPORT: ' . $stage . "/INSTALL_RESULT.json\n";
}
return $report;
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/libs/NetzfahrplanV4Messaufnahme.php';
use Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme;
/** Native bindings for a dedicated passive capture category. No network or actuator calls. */
function v4captureConfig(string $stage): array
{
if (!is_dir($stage) || is_link($stage) || realpath($stage) !== $stage) throw new RuntimeException('Invalid capture stage');
$manifest = json_decode((string)file_get_contents($stage . '/MANIFEST.json'), true, 64, JSON_THROW_ON_ERROR);
$expected = ['source/libs/NetzfahrplanV4Bilanzierung.php','source/libs/NetzfahrplanV4Messaufnahme.php',
'source/examples/V4DataCapture/runtime.php','source/examples/V4DataCapture/install.php',
'config.json','USER_REPORTED_INVENTORY.json'];
if (($manifest['scope'] ?? null) !== 'passive_raw_capture_only' || array_keys($manifest['hashes'] ?? []) !== $expected) {
throw new RuntimeException('Unexpected capture manifest');
}
foreach ($expected as $name) {
$p = $stage . '/' . $name;
if (is_link($p) || !is_file($p) || realpath($p) !== $p || hash_file('sha256', $p) !== $manifest['hashes'][$name]) {
throw new RuntimeException('Reviewed capture source changed: ' . $name);
}
}
$c = json_decode((string)file_get_contents($stage . '/config.json'), true, 64, JSON_THROW_ON_ERROR);
if (($c['managerId'] ?? null) !== 17004 || ($c['installationId'] ?? null) !== 'e3a08f9e-af12-4695-99bd-8b51c0520021'
|| ($c['reportedInventorySha256'] ?? null) !== $manifest['hashes']['USER_REPORTED_INVENTORY.json']) {
throw new RuntimeException('Wrong commissioning installation or inventory');
}
return NetzfahrplanV4Messaufnahme::configuration($c);
}
function v4captureMarker(array $c): string
{
return 'ENELIX passive raw capture v1|' . $c['installationId'];
}
function v4captureChild(int $parent, string $ident): ?int
{
$found = null;
foreach (IPS_GetChildrenIDs($parent) as $id) {
if (IPS_GetObject($id)['ObjectIdent'] !== $ident) continue;
if ($found !== null) throw new RuntimeException('Duplicate capture object');
$found = $id;
}
return $found;
}
function v4captureAssertObject(int $id, int $parent, string $ident, int $type, string $marker): void
{
$o = IPS_GetObject($id);
if ($o['ParentID'] !== $parent || $o['ObjectIdent'] !== $ident || $o['ObjectType'] !== $type || $o['ObjectInfo'] !== $marker) {
throw new RuntimeException('Unexpected capture object ownership');
}
if ($type === 2 && IPS_GetVariable($id)['VariableType'] !== 3) throw new RuntimeException('Capture status is not a string');
}
function v4captureAtomicJson(string $file, array $value): void
{
if (is_link($file)) throw new RuntimeException('Capture report symlink refused');
$tmp = tempnam(dirname($file), '.capture-');
if ($tmp === false) throw new RuntimeException('Cannot create report');
try {
$data = json_encode($value, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . "\n";
if (file_put_contents($tmp, $data, LOCK_EX) !== strlen($data) || !chmod($tmp, 0640) || !rename($tmp, $file)) {
throw new RuntimeException('Cannot store capture report');
}
} finally { if (is_file($tmp)) unlink($tmp); }
}
function v4captureKeepSnapshot(string $path, string $bytes): void
{
if (is_link($path)) throw new RuntimeException('Metadata symlink refused');
if (is_file($path)) {
if (hash_file('sha256', $path) !== hash('sha256', $bytes)) throw new RuntimeException('Immutable capture metadata differs');
return;
}
$h = fopen($path, 'xb');
if ($h === false) throw new RuntimeException('Cannot store capture metadata');
try {
if (fwrite($h, $bytes) !== strlen($bytes) || !fflush($h) || !chmod($path, 0640)) throw new RuntimeException('Capture metadata write failed');
} finally { fclose($h); }
}
function v4captureRead(int $id): array
{
if (!IPS_VariableExists($id)) throw new RuntimeException('Missing source');
$v = IPS_GetVariable($id); $o = IPS_GetObject($id);
if (!in_array($v['VariableType'], [1,2], true)) throw new RuntimeException('Non-numeric source');
// Capture layer reads all sources twice; source timestamps are NOT replaced with now.
return ['value' => GetValue($id), 'updated' => (int)$v['VariableUpdated'], 'changed' => (int)$v['VariableChanged'],
'parentID' => $o['ParentID'], 'ident' => $o['ObjectIdent']];
}
function v4captureRun(string $stage, int $category): array
{
if (!function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Run inside IP-Symcon only');
$c = v4captureConfig($stage); $marker = v4captureMarker($c);
v4captureAssertObject($category, 0, 'ENELIX_V4_PASSIVE_CAPTURE', 0, $marker);
$statusId = v4captureChild($category, 'CaptureStatus');
if ($statusId === null) throw new RuntimeException('Capture status missing');
v4captureAssertObject($statusId, $category, 'CaptureStatus', 2, $marker);
$lock = 'ENELIX.V4.RawCapture.' . $c['installationId'];
if (!IPS_SemaphoreEnter($lock, 0)) return ['status' => 'busy', 'controlEnabled' => false];
try {
try {
$record = NetzfahrplanV4Messaufnahme::capture($c, 'v4captureRead', static fn(): int => time());
v4captureKeepSnapshot($stage . '/data/mapping-' . $record['mappingSha256'] . '.json', json_encode($c, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
v4captureKeepSnapshot($stage . '/data/inventory-' . $record['reportedInventorySha256'] . '.json', (string)file_get_contents($stage . '/USER_REPORTED_INVENTORY.json'));
$path = NetzfahrplanV4Messaufnahme::append($stage . '/data', $record);
$status = ['status' => 'recorded', 'capturedAt' => $record['capturedAt'], 'journal' => $path,
'sourceCount' => count($record['raw']), 'rawIssueCount' => count($record['issues']),
'accountingQuality' => $record['assessment']['status'] ?? 'unavailable',
'qualityIssues' => $record['assessment']['issues'] ?? $record['issues'],
'baseLoadCandidateW' => $record['assessment']['baseLoadCandidateW'] ?? null,
'sourceSkewSeconds' => $record['assessment']['sourceSkewSeconds'] ?? null,
'trainingEligible' => false, 'controlEnabled' => false, 'measurementBoundaryVerified' => false];
v4captureAtomicJson($stage . '/data/STATUS.json', $status);
} catch (Throwable $e) {
$status = ['status' => 'capture_error', 'at' => gmdate('c'), 'reason' => substr($e->getMessage(),0,250),
'trainingEligible' => false, 'controlEnabled' => false];
try { v4captureAtomicJson($stage . '/data/STATUS.json', $status); } catch (Throwable $ignored) { }
}
// Only this newly created diagnosis variable, never a meter or controller variable.
SetValue($statusId, json_encode($status, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
return $status;
} finally { IPS_SemaphoreLeave($lock); }
}
function v4captureStop(string $stage, int $category): void
{
$c = v4captureConfig($stage); $marker = v4captureMarker($c);
v4captureAssertObject($category, 0, 'ENELIX_V4_PASSIVE_CAPTURE', 0, $marker);
$script = v4captureChild($category, 'RawCapture'); $status = v4captureChild($category, 'CaptureStatus');
if ($script === null || $status === null) throw new RuntimeException('Capture objects missing');
v4captureAssertObject($script, $category, 'RawCapture', 3, $marker);
v4captureAssertObject($status, $category, 'CaptureStatus', 2, $marker);
if (!IPS_SetScriptTimer($script, 0)) throw new RuntimeException('Cannot stop capture timer');
$r = ['status' => 'stopped', 'at' => gmdate('c'), 'oldDataRetained' => true, 'controlEnabled' => false];
SetValue($status, json_encode($r, JSON_THROW_ON_ERROR));
v4captureAtomicJson($stage . '/data/STATUS.json', $r);
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/runtime.php';
function v4viewsEnsure(int $parent,string $ident,int $type,string $name,string $marker,array &$created): int
{
$id=v4viewsChild($parent,$ident);if($id!==null){v4viewsAssert($id,$parent,$ident,$type,$marker);return $id;}
$id=match($type){0=>IPS_CreateCategory(),2=>IPS_CreateVariable(3),3=>IPS_CreateScript(0)};
if(!is_int($id)||$id<=0)throw new RuntimeException('Cannot create observer object');$created[]=$id;
if(!IPS_SetParent($id,$parent)||!IPS_SetIdent($id,$ident)||!IPS_SetName($id,$name)||!IPS_SetInfo($id,$marker))throw new RuntimeException('Cannot label observer object');
return $id;
}
function v4viewsScript(int $id,string $source,array $created): void
{
if(IPS_GetScriptContent($id)===$source)return;
if(!in_array($id,$created,true))throw new RuntimeException('Existing observer script differs; not overwritten');
if(!IPS_SetScriptContent($id,$source)||IPS_GetScriptContent($id)!==$source)throw new RuntimeException('Cannot write observer script');
}
function v4viewsInstall(string $stage): array
{
if(!function_exists('IPS_GetKernelVersion')||version_compare(IPS_GetKernelVersion(),'8.0','<'))throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
[$c,$p]=v4viewsConfig($stage);$marker=v4viewsMarker($c);
if(!IPS_InstanceExists($c['managerId'])||IPS_GetInstance($c['managerId'])['ModuleInfo']['ModuleID']!=='{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}')throw new RuntimeException('Expected manager missing');
foreach(array_merge($c['accounting']['sources'],$c['extraSources']) as $s){
if(!IPS_VariableExists($s['variableId']))throw new RuntimeException('Expected observation source missing');
$o=IPS_GetObject($s['variableId']);$v=IPS_GetVariable($s['variableId']);
if($o['ParentID']!==$s['parentId']||$o['ObjectIdent']!==$s['ident']||!in_array($v['VariableType'],[1,2],true))throw new RuntimeException('Source identity differs');
}
if(is_link($stage.'/data')||!is_dir($stage.'/data')||!is_writable($stage.'/data')||(fileperms($stage.'/data')&0077)!==0)throw new RuntimeException('Private data directory required');
$lock='ENELIX.V4.ViewsInstall.'.$c['installationId'];if(!IPS_SemaphoreEnter($lock,3000))throw new RuntimeException('Observer installation busy');
$created=[];$script=null;$newTimer=false;$report=['scope'=>'passive_separated_observation','startedAt'=>gmdate('c'),
'oldCollectorChanged'=>false,'modulesChanged'=>false,'pollingChanged'=>false,'socAccountsChanged'=>false,'trainingEnabled'=>false,'controlEnabled'=>false];
try{
$cat=v4viewsEnsure(0,'ENELIX_V4_SEPARATED_OBSERVATION',0,'V4 - getrennte Last und EV-SDL (nur Beobachtung)',$marker,$created);
foreach(['PhysicalLoad'=>'Physische Last - Kandidat, keine Trainingsfreigabe','AllocationModel'=>'EV-SDL-Modell - KEINE separate Messung','ObservationStatus'=>'Beobachtungsstatus'] as $key=>$name)v4viewsEnsure($cat,$key,2,$name,$marker,$created);
$script=v4viewsEnsure($cat,'Observe',3,'Getrennte Messsicht alle 30 Sekunden',$marker,$created);
$stop=v4viewsEnsure($cat,'Stop',3,'Nur diese zusaetzliche Beobachtung stoppen',$marker,$created);
$include='<?php'."\n".'require_once '.var_export($stage.'/source/examples/V4SeparatedObservation/runtime.php',true).';'."\n";
v4viewsScript($script,$include.'v4viewsRun('.var_export($stage,true).', '.$cat.');'."\n",$created);
v4viewsScript($stop,$include.'v4viewsStop('.var_export($stage,true).', '.$cat.');'."\n",$created);
$first=v4viewsRun($stage,$cat);if($first['status']!=='recorded')throw new RuntimeException('First derived observation failed');
if(!IPS_SetScriptTimer($script,30))throw new RuntimeException('Cannot activate observer timer');$newTimer=true;
$report+=['status'=>'observer_installed','categoryId'=>$cat,'scriptId'=>$script,'stopId'=>$stop,'firstObservation'=>$first,'futureTimerExecutionVerified'=>false];
echo "OK: Getrennte Last-/EV-SDL-Beobachtung eingerichtet, alle 30 Sekunden.\n";
echo 'Erste Aufnahme: '.$first['capturedAt'].'; Quellen: '.$first['sourceCount'].".\n";
echo 'Physische Last: '.$first['physicalLoadStatus'].'; EV-/SDL-Modell: '.$first['allocationModelStatus'].".\n";
echo "Keine Trainings-/Stellfreigabe. Bisheriger Sammler, Manager, Gateway, SDL und Polling unveraendert.\n";
}catch(Throwable $e){
$report['status']='needs_review';$report['reason']=substr($e->getMessage(),0,180);
if($newTimer&&$script!==null)IPS_SetScriptTimer($script,0);
echo 'FEHLER: '.$report['reason']."\n";
}finally{
$report['createdObjects']=$created;$report['finishedAt']=gmdate('c');
try{v4viewsJson($stage.'/INSTALL_RESULT.json',$report);}finally{IPS_SemaphoreLeave($lock);}
echo 'OBSERVATION REPORT: '.$stage."/INSTALL_RESULT.json\n";
}
return $report;
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/** Separate read-only observations. Only newly owned objects/files may be changed. */
function v4viewsConfig(string $stage): array
{
if (!is_dir($stage) || is_link($stage) || realpath($stage)!==$stage || (fileperms($stage)&0077)!==0) throw new RuntimeException('Private observer stage required');
$m=json_decode((string)file_get_contents($stage.'/MANIFEST.json'),true,64,JSON_THROW_ON_ERROR);
$files=['source/libs/NetzfahrplanV4Bilanzierung.php','source/libs/NetzfahrplanV4Messaufnahme.php','source/libs/NetzfahrplanV4Messsicht.php',
'source/examples/V4SeparatedObservation/runtime.php','source/examples/V4SeparatedObservation/install.php','config.json','policy.json','INVENTORY.json'];
if (($m['scope']??null)!=='passive_separated_observation' || array_keys($m['hashes']??[])!==$files) throw new RuntimeException('Unexpected observer manifest');
foreach($files as $name){$p=$stage.'/'.$name;if(is_link($p)||!is_file($p)||realpath($p)!==$p||hash_file('sha256',$p)!==$m['hashes'][$name])throw new RuntimeException('Observer source drift: '.$name);}
require_once $stage.'/source/libs/NetzfahrplanV4Messsicht.php';
$c=\Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme::configuration(json_decode((string)file_get_contents($stage.'/config.json'),true,64,JSON_THROW_ON_ERROR));
$p=\Belevo\EnelixEMS\NetzfahrplanV4Messsicht::policy(json_decode((string)file_get_contents($stage.'/policy.json'),true,64,JSON_THROW_ON_ERROR),$c);
if($c['reportedInventorySha256']!==$m['hashes']['INVENTORY.json'])throw new RuntimeException('Inventory identity differs');
return [$c,$p];
}
function v4viewsMarker(array $c): string { return 'ENELIX separated physical observer v1|'.$c['installationId']; }
function v4viewsChild(int $parent,string $ident): ?int
{
$result=null;foreach(IPS_GetChildrenIDs($parent) as $id)if(IPS_GetObject($id)['ObjectIdent']===$ident){if($result!==null)throw new RuntimeException('Duplicate observer object');$result=$id;}
return $result;
}
function v4viewsAssert(int $id,int $parent,string $ident,int $type,string $marker): void
{
$o=IPS_GetObject($id);if($o['ParentID']!==$parent||$o['ObjectIdent']!==$ident||$o['ObjectType']!==$type||$o['ObjectInfo']!==$marker)throw new RuntimeException('Observer object ownership mismatch');
if($type===2 && IPS_GetVariable($id)['VariableType']!==3)throw new RuntimeException('Observer diagnosis must be string');
}
function v4viewsRead(int $id): array
{
if(!IPS_VariableExists($id))throw new RuntimeException('Missing numeric source');
$a=IPS_GetVariable($id);$o=IPS_GetObject($id);
if(!in_array($a['VariableType'],[1,2],true))throw new RuntimeException('Non-numeric source');
return ['value'=>GetValue($id),'updated'=>(int)$a['VariableUpdated'],'changed'=>(int)$a['VariableChanged'],'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
}
function v4viewsJson(string $path,array $data): void
{
if(is_link($path)||realpath(dirname($path))!==dirname($path)||(fileperms(dirname($path))&0077)!==0)throw new RuntimeException('Private observer output required');
$tmp=tempnam(dirname($path),'.views-');if($tmp===false)throw new RuntimeException('Cannot create observer report');
try{$json=json_encode($data,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT|JSON_PRESERVE_ZERO_FRACTION)."\n";
if(file_put_contents($tmp,$json,LOCK_EX)!==strlen($json)||!chmod($tmp,0644)||!rename($tmp,$path))throw new RuntimeException('Cannot store observer report');
}finally{if(is_file($tmp))unlink($tmp);}
}
function v4viewsRun(string $stage,int $category): array
{
if(!function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Run inside Symcon');
[$c,$policy]=v4viewsConfig($stage);$marker=v4viewsMarker($c);
v4viewsAssert($category,0,'ENELIX_V4_SEPARATED_OBSERVATION',0,$marker);
$ids=[];foreach(['PhysicalLoad','AllocationModel','ObservationStatus'] as $key){
$id=v4viewsChild($category,$key);if($id===null)throw new RuntimeException('Observer object missing');v4viewsAssert($id,$category,$key,2,$marker);$ids[$key]=$id;}
$lock='ENELIX.V4.SeparatedView.'.$c['installationId'];if(!IPS_SemaphoreEnter($lock,0))return ['status'=>'busy','controlEnabled'=>false];
try{
try{
$raw=\Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme::capture($c,'v4viewsRead',static fn():int=>time());
$view=\Belevo\EnelixEMS\NetzfahrplanV4Messsicht::calculate($raw,$c,$policy);
$record=['schemaVersion'=>1,'kind'=>'physical_and_allocation_observation','installationId'=>$c['installationId'],'capturedAt'=>$raw['capturedAt'],
'capture'=>$raw,'views'=>$view,'trainingEligible'=>false,'controlEnabled'=>false];
$dir=$stage.'/data';if(is_link($dir)||realpath($dir)!==$dir||(fileperms($dir)&0077)!==0)throw new RuntimeException('Private data directory required');
$path=\Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme::append($dir,$record);
// Derived-only files readable to authorized agent through a mode-0700 stage/data directory.
// Original collector files and permissions are never touched.
if(!chmod($path,0644))throw new RuntimeException('Cannot expose selected derived observation');
$status=['status'=>'recorded','capturedAt'=>$raw['capturedAt'],'sourceCount'=>count($raw['raw']),'journal'=>$path,
'physicalLoadStatus'=>$view['physicalLoad']['status'],'allocationModelStatus'=>$view['allocationModel']['status'],
'solarOriginStatus'=>$view['solarOrigin']['status'],'trainingEligible'=>false,'controlEnabled'=>false];
v4viewsJson($dir.'/LATEST.json',$record);
SetValue($ids['PhysicalLoad'],json_encode(['captureAt'=>$raw['capturedAt'],'physical'=>$view['physicalLoad'],'solarAcAlternative'=>$view['solarAcAlternative'],'trainingEligible'=>false],JSON_THROW_ON_ERROR));
SetValue($ids['AllocationModel'],json_encode(['captureAt'=>$raw['capturedAt'],'model'=>$view['allocationModel'],'storage'=>$view['storageFeedback'],'canDispatch'=>false],JSON_THROW_ON_ERROR));
}catch(Throwable $e){
$status=['status'=>'observation_error','at'=>gmdate('c'),'errorType'=>get_class($e),'sourceLine'=>$e->getLine(),'trainingEligible'=>false,'controlEnabled'=>false];
// Never leave a stale numeric proposal in the new diagnosis variables.
SetValue($ids['PhysicalLoad'],json_encode($status,JSON_THROW_ON_ERROR));SetValue($ids['AllocationModel'],json_encode($status,JSON_THROW_ON_ERROR));
try{v4viewsJson($stage.'/data/LATEST.json',$status);}catch(Throwable $ignored){}
}
SetValue($ids['ObservationStatus'],json_encode($status,JSON_THROW_ON_ERROR));v4viewsJson($stage.'/data/STATUS.json',$status);return $status;
}finally{IPS_SemaphoreLeave($lock);}
}
function v4viewsStop(string $stage,int $category): void
{
[$c,$p]=v4viewsConfig($stage);$marker=v4viewsMarker($c);v4viewsAssert($category,0,'ENELIX_V4_SEPARATED_OBSERVATION',0,$marker);
$id=v4viewsChild($category,'Observe');if($id===null)throw new RuntimeException('Observer script missing');v4viewsAssert($id,$category,'Observe',3,$marker);
if(!IPS_SetScriptTimer($id,0))throw new RuntimeException('Cannot stop observer');
$s=['status'=>'stopped','at'=>gmdate('c'),'controlEnabled'=>false,'existingCollectorChanged'=>false];
foreach(['PhysicalLoad','AllocationModel','ObservationStatus'] as $key){$var=v4viewsChild($category,$key);if($var===null)throw new RuntimeException('Observer status missing');v4viewsAssert($var,$category,$key,2,$marker);SetValue($var,json_encode($s,JSON_THROW_ON_ERROR));}
v4viewsJson($stage.'/data/STATUS.json',$s);v4viewsJson($stage.'/data/LATEST.json',$s);
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
function v4UnifiedWrite(string $path,string $text,int $mode=0644):void
{
$tmp=tempnam(dirname($path),'.v4-rc-');if($tmp===false)throw new RuntimeException('Temporaere Datei fehlt.');
try {if(file_put_contents($tmp,$text,LOCK_EX)!==strlen($text)||!chmod($tmp,$mode)||!rename($tmp,$path))throw new RuntimeException('Datei konnte nicht geschrieben werden.');}
finally {if(is_file($tmp))unlink($tmp);}
}
function v4UnifiedFiles(string $stage,string $target):array
{
$m=json_decode(file_get_contents($stage.'/MANIFEST.json'),true,64,JSON_THROW_ON_ERROR);
$names=['libs/NetzfahrplanV4Datenarchiv.php','libs/NetzfahrplanV4Bilanzierung.php','libs/ManagerNetzfahrplanV4DatenTrait.php'];
if($m['scope']!=='unified_native_data_only'||array_keys($m['files'])!==$names)throw new RuntimeException('Falscher Installationsumfang.');
$old=[];$new=[];$complete=true;
foreach($names as$name){
$p=$target.'/'.$name;$s=$stage.'/source/'.$name;
if(is_link($p)||is_link($s)||realpath(dirname($p))!==realpath($target).'/libs')throw new RuntimeException('Unerwarteter Dateipfad.');
$new[$name]=file_get_contents($s);token_get_all($new[$name],TOKEN_PARSE);
if(hash('sha256',$new[$name])!==$m['files'][$name]['after'])throw new RuntimeException('Vorbereitete Datei geaendert.');
$old[$name]=is_file($p)?file_get_contents($p):null;$h=$old[$name]===null?null:hash('sha256',$old[$name]);
if($h!==$m['files'][$name]['after'])$complete=false;
if($h!==$m['files'][$name]['before']&&$h!==$m['files'][$name]['after'])throw new RuntimeException('Parallele Modulaenderung; nichts ueberschrieben.');
}
if($complete)return ['status'=>'already_installed'];
foreach($names as$name)if(($old[$name]===null?null:hash('sha256',$old[$name]))!==$m['files'][$name]['before'])throw new RuntimeException('Teilstand zuerst pruefen.');
$backup=$stage.'/backups/'.gmdate('YmdTHis').'-'.bin2hex(random_bytes(4));
if(!mkdir($backup.'/libs',0700,true))throw new RuntimeException('Sicherung fehlt.');
foreach($names as$name)if($old[$name]!==null)v4UnifiedWrite($backup.'/'.$name,$old[$name],0600);
v4UnifiedWrite($backup.'/MANIFEST.json',json_encode($m,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0600);
$written=[];
try {foreach($names as$name){$p=$target.'/'.$name;if((is_file($p)?file_get_contents($p):null)!==$old[$name])throw new RuntimeException('Parallele Dateiaenderung.');v4UnifiedWrite($p,$new[$name]);$written[]=$name;}}
catch(Throwable $e){foreach(array_reverse($written)as$name){$p=$target.'/'.$name;if(hash_file('sha256',$p)!==$m['files'][$name]['after'])continue;if($old[$name]===null)unlink($p);else v4UnifiedWrite($p,$old[$name]);}throw $e;}
return ['status'=>'installed','backup'=>$backup];
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
if(!function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
require_once __DIR__.'/file_installer.php';
$manager=17004;$lock=false;$report=['scope'=>'unified_native_data_only','startedAt'=>gmdate('c'),'newActuatorPermission'=>false,'cursorReset'=>false,'outboxRewritten'=>false];
try {
$package=json_decode(file_get_contents(__DIR__.'/PACKAGE_HASHES.json'),true,64,JSON_THROW_ON_ERROR);
foreach($package as$name=>$hash)if(str_contains($name,'..')||str_starts_with($name,'/')||is_link(__DIR__.'/'.$name)||hash_file('sha256',__DIR__.'/'.$name)!==$hash)throw new RuntimeException('Installationspaket wurde geaendert.');
if(!IPS_InstanceExists($manager)||IPS_GetInstance($manager)['ModuleInfo']['ModuleID']!=='{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}')throw new RuntimeException('Erwarteter Manager fehlt.');
if(IPS_HasChanges($manager))throw new RuntimeException('Offene Manageraenderungen zuerst speichern oder verwerfen.');
if(IPS_GetProperty($manager,'NetzfahrplanAktiv')!==false||IPS_GetProperty($manager,'NetzfahrplanV4MessdatenAktiv')!==true)throw new RuntimeException('Erwartet: Datenaufnahme EIN, alter Netzfahrplan AUS.');
$controls=[];foreach(IPS_GetModuleList()as$mid)if((IPS_GetModule($mid)['Prefix']??'')==='MC')$controls=array_merge($controls,IPS_GetInstanceListByModuleID($mid));
if(count($controls)!==1||!function_exists('MC_ReloadModule'))throw new RuntimeException('Module Control nicht eindeutig.');
$lock=IPS_SemaphoreEnter('ENELIX.V4.UnifiedRelease',3000);if(!$lock)throw new RuntimeException('Installation bereits aktiv.');
$before=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$report['files']=v4UnifiedFiles(__DIR__,'/var/lib/symcon/modules/Enelix-EMS');
// No staged application classes are loaded into this script context.
if(MC_ReloadModule($controls[0],'Enelix-EMS')===false)throw new RuntimeException('Bibliotheks-Reload fehlgeschlagen.');
$after=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
foreach($before as$key=>$value)if(!array_key_exists($key,$after)||$after[$key]!==$value)throw new RuntimeException('Bestehender Parameter unerwartet geaendert: '.$key);
$report['status']='native_data_candidate_installed';$report['parametersUnchanged']=true;
echo "OK: Einheitliche Messdatenkennung und dauerhafte Versandbestaetigung installiert. Archivierung vorbereitet, standardmaessig AUS.\n";
echo "Der bestehende Datentimer setzt den Versand fort. Sendezeiger und Originalbestand wurden nicht umgeschrieben.\n";
echo "Keine V4-Stellfreigabe und kein Wechsel der Prognosequelle.\n";
} catch(Throwable $e) {
$report['status']='needs_review';$report['reason']=substr($e->getMessage(),0,250);
echo 'FEHLER: '.$report['reason']."\nKeine neue Stellfreigabe.\n";
} finally {
$report['finishedAt']=gmdate('c');v4UnifiedWrite(__DIR__.'/INSTALL_RESULT.json',json_encode($report,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0644);
if($lock)IPS_SemaphoreLeave('ENELIX.V4.UnifiedRelease');
echo 'UNIFIED MANAGER REPORT: '.__DIR__."/INSTALL_RESULT.json\n";
}
+2 -2
View File
@@ -7,6 +7,6 @@
"version": "8.0" "version": "8.0"
}, },
"version": "0.1", "version": "0.1",
"build": 0, "build": 1,
"date": 0 "date": 1790662541
} }
+406
View File
@@ -0,0 +1,406 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
use JsonException;
final class Anlagentopologie
{
public const VERSION = '1.0';
/**
* @return array<string, mixed>
* @throws JsonException
*/
public static function ausJson(
string $wechselrichterJson,
string $pvFlaechenJson,
string $batterienJson,
string $installationID
): array {
$wechselrichter = self::dekodiereListe($wechselrichterJson, 'AnlagenWechselrichter');
$pvFlaechen = self::dekodiereListe($pvFlaechenJson, 'AnlagenPVFlaechen');
$batterien = self::dekodiereListe($batterienJson, 'AnlagenBatterien');
$inverters = [];
$inverterById = [];
foreach ($wechselrichter as $index => $eintrag) {
$pfad = 'Wechselrichter ' . ($index + 1);
$id = self::kennung($eintrag['ID'] ?? null, $pfad . ': ID');
if (isset($inverterById[$id])) {
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
}
$typ = self::auswahl(
$eintrag['Typ'] ?? null,
['pv', 'battery', 'hybrid'],
$pfad . ': Typ'
);
$acRatedKw = self::positiveZahl(
$eintrag['ACNennleistung_kW'] ?? null,
$pfad . ': AC-Nennleistung'
);
$inverter = [
'id' => $id,
'name' => self::name($eintrag['Name'] ?? null, $id),
'type' => $typ,
'acRatedKw' => $acRatedKw,
];
$inverters[] = $inverter;
$inverterById[$id] = $inverter;
}
$pvArrays = [];
$pvIds = [];
foreach ($pvFlaechen as $index => $eintrag) {
$pfad = 'PV-Flaeche ' . ($index + 1);
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $pvIds, $pfad);
$inverterId = self::kennung(
$eintrag['WechselrichterID'] ?? null,
$pfad . ': Wechselrichter-ID'
);
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
if (!in_array($inverter['type'], ['pv', 'hybrid'], true)) {
throw new InvalidArgumentException(
$pfad . ': benoetigt einen PV- oder Hybridwechselrichter.'
);
}
$moduleCount = self::optionalePositiveGanzzahl(
$eintrag['Modulanzahl'] ?? null,
$pfad . ': Modulanzahl'
);
$modulePowerWp = self::optionalePositiveZahl(
$eintrag['Modulleistung_Wp'] ?? null,
$pfad . ': Modulleistung'
);
if (($moduleCount === null) !== ($modulePowerWp === null)) {
throw new InvalidArgumentException(
$pfad . ': Modulanzahl und Modulleistung muessen gemeinsam gepflegt werden.'
);
}
$pvArrays[] = [
'id' => $id,
'name' => self::name($eintrag['Name'] ?? null, $id),
'dcKwp' => self::positiveZahl(
$eintrag['DCLeistung_kWp'] ?? null,
$pfad . ': DC-Leistung'
),
'tiltDeg' => self::zahlImBereich(
$eintrag['Neigung_Grad'] ?? null,
0.0,
90.0,
$pfad . ': Neigung'
),
'azimuthDeg' => self::zahlImBereich(
$eintrag['Azimut_Grad'] ?? null,
-180.0,
180.0,
$pfad . ': Azimut'
),
'inverterId' => $inverterId,
'mpptInput' => self::optionalerText($eintrag['MPPT'] ?? null),
'moduleCount' => $moduleCount,
'modulePowerWp' => $modulePowerWp,
];
}
$batteryItems = [];
$batteryIds = [];
foreach ($batterien as $index => $eintrag) {
$pfad = 'Batterie ' . ($index + 1);
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $batteryIds, $pfad);
$inverterId = self::kennung(
$eintrag['WechselrichterID'] ?? null,
$pfad . ': Wechselrichter-ID'
);
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
$kopplung = self::auswahl(
$eintrag['Kopplung'] ?? null,
['ac', 'dc', 'hybrid'],
$pfad . ': Kopplung'
);
if ($kopplung === 'ac' && !in_array($inverter['type'], ['battery', 'hybrid'], true)) {
throw new InvalidArgumentException(
$pfad . ': AC-Kopplung benoetigt einen Batterie- oder Hybridwechselrichter.'
);
}
if ($kopplung !== 'ac' && $inverter['type'] !== 'hybrid') {
throw new InvalidArgumentException(
$pfad . ': DC- und Hybridkopplung benoetigen einen Hybridwechselrichter.'
);
}
$nominalCapacityKwh = self::positiveZahl(
$eintrag['Nennkapazitaet_kWh'] ?? null,
$pfad . ': Nennkapazitaet'
);
$usableCapacityKwh = self::positiveZahl(
$eintrag['Nutzkapazitaet_kWh'] ?? null,
$pfad . ': Nutzkapazitaet'
);
if ($usableCapacityKwh > $nominalCapacityKwh) {
throw new InvalidArgumentException(
$pfad . ': Nutzkapazitaet darf die Nennkapazitaet nicht uebersteigen.'
);
}
$batteryItems[] = [
'id' => $id,
'name' => self::name($eintrag['Name'] ?? null, $id),
'nominalCapacityKwh' => $nominalCapacityKwh,
'usableCapacityKwh' => $usableCapacityKwh,
'maxChargeKw' => self::positiveZahl(
$eintrag['MaxLadeleistung_kW'] ?? null,
$pfad . ': maximale Ladeleistung'
),
'maxDischargeKw' => self::positiveZahl(
$eintrag['MaxEntladeleistung_kW'] ?? null,
$pfad . ': maximale Entladeleistung'
),
'inverterId' => $inverterId,
'coupling' => $kopplung,
];
}
return [
'version' => self::VERSION,
'installationId' => trim($installationID),
'inverters' => $inverters,
'pvArrays' => $pvArrays,
'batteries' => $batteryItems,
'sharedAcLimits' => self::gemeinsameAcGrenzen(
$inverters,
$pvArrays,
$batteryItems
),
'summary' => self::zusammenfassung($inverters, $pvArrays, $batteryItems),
];
}
/** @return list<array<string, mixed>> */
private static function dekodiereListe(string $json, string $name): array
{
$daten = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)
|| ($daten !== [] && array_keys($daten) !== range(0, count($daten) - 1))
) {
throw new InvalidArgumentException($name . ' muss eine JSON-Liste sein.');
}
foreach ($daten as $eintrag) {
if (!is_array($eintrag)) {
throw new InvalidArgumentException($name . ' enthaelt einen ungueltigen Eintrag.');
}
}
return $daten;
}
/** @param array<string, bool> $verwendet */
private static function eindeutigeKennung($wert, array &$verwendet, string $pfad): string
{
$id = self::kennung($wert, $pfad . ': ID');
if (isset($verwendet[$id])) {
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
}
$verwendet[$id] = true;
return $id;
}
private static function kennung($wert, string $feld): string
{
if (!is_string($wert)
|| preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', trim($wert)) !== 1
) {
throw new InvalidArgumentException(
$feld . ' muss 1 bis 64 Zeichen aus Buchstaben, Zahlen, Punkt, Minus oder Unterstrich enthalten.'
);
}
return trim($wert);
}
private static function name($wert, string $fallback): string
{
if ($wert === null || $wert === '') {
return $fallback;
}
if (!is_string($wert) || strlen(trim($wert)) > 120) {
throw new InvalidArgumentException('Name muss ein Text mit maximal 120 Zeichen sein.');
}
return trim($wert);
}
/** @param list<string> $werte */
private static function auswahl($wert, array $werte, string $feld): string
{
if (!is_string($wert) || !in_array($wert, $werte, true)) {
throw new InvalidArgumentException($feld . ' ist ungueltig.');
}
return $wert;
}
private static function positiveZahl($wert, string $feld): float
{
if (!is_int($wert) && !is_float($wert)) {
throw new InvalidArgumentException($feld . ' muss eine Zahl sein.');
}
if (!is_finite((float) $wert) || (float) $wert <= 0.0) {
throw new InvalidArgumentException($feld . ' muss groesser als 0 sein.');
}
return (float) $wert;
}
private static function optionalePositiveZahl($wert, string $feld): ?float
{
if ($wert === null || $wert === '' || $wert === 0 || $wert === 0.0) {
return null;
}
return self::positiveZahl($wert, $feld);
}
private static function optionalePositiveGanzzahl($wert, string $feld): ?int
{
if ($wert === null || $wert === '' || $wert === 0) {
return null;
}
if (!is_int($wert) || $wert <= 0) {
throw new InvalidArgumentException($feld . ' muss eine positive Ganzzahl sein.');
}
return $wert;
}
private static function zahlImBereich(
$wert,
float $minimum,
float $maximum,
string $feld
): float {
if ((!is_int($wert) && !is_float($wert))
|| !is_finite((float) $wert)
|| (float) $wert < $minimum
|| (float) $wert > $maximum
) {
throw new InvalidArgumentException(
sprintf('%s muss zwischen %g und %g liegen.', $feld, $minimum, $maximum)
);
}
return (float) $wert;
}
private static function optionalerText($wert): ?string
{
if ($wert === null || $wert === '') {
return null;
}
if (!is_string($wert) || strlen(trim($wert)) > 64) {
throw new InvalidArgumentException('MPPT muss ein Text mit maximal 64 Zeichen sein.');
}
return trim($wert);
}
/**
* @param array<string, array<string, mixed>> $wechselrichter
* @return array<string, mixed>
*/
private static function wechselrichter(array $wechselrichter, string $id, string $pfad): array
{
if (!isset($wechselrichter[$id])) {
throw new InvalidArgumentException(
$pfad . ': referenzierter Wechselrichter ist nicht vorhanden.'
);
}
return $wechselrichter[$id];
}
/**
* @param list<array<string, mixed>> $inverters
* @param list<array<string, mixed>> $pvArrays
* @param list<array<string, mixed>> $batteries
* @return list<array<string, mixed>>
*/
private static function gemeinsameAcGrenzen(
array $inverters,
array $pvArrays,
array $batteries
): array {
$result = [];
foreach ($inverters as $inverter) {
if ($inverter['type'] !== 'hybrid') {
continue;
}
$pvIds = [];
foreach ($pvArrays as $pvArray) {
if ($pvArray['inverterId'] === $inverter['id']) {
$pvIds[] = $pvArray['id'];
}
}
$batteryIds = [];
foreach ($batteries as $battery) {
if ($battery['inverterId'] === $inverter['id']) {
$batteryIds[] = $battery['id'];
}
}
if ($pvIds === [] && $batteryIds === []) {
continue;
}
$result[] = [
'inverterId' => $inverter['id'],
'acRatedKw' => $inverter['acRatedKw'],
'pvArrayIds' => $pvIds,
'batteryIds' => $batteryIds,
];
}
return $result;
}
/**
* @param list<array<string, mixed>> $inverters
* @param list<array<string, mixed>> $pvArrays
* @param list<array<string, mixed>> $batteries
* @return array<string, float>
*/
private static function zusammenfassung(
array $inverters,
array $pvArrays,
array $batteries
): array {
$pvInverterIds = [];
$pvDcKwp = 0.0;
foreach ($pvArrays as $pvArray) {
$pvDcKwp += $pvArray['dcKwp'];
$pvInverterIds[$pvArray['inverterId']] = true;
}
$pvAcKw = 0.0;
foreach ($inverters as $inverter) {
if (isset($pvInverterIds[$inverter['id']])) {
$pvAcKw += $inverter['acRatedKw'];
}
}
$usableBatteryKwh = 0.0;
$maxBatteryChargeKw = 0.0;
$maxBatteryDischargeKw = 0.0;
foreach ($batteries as $battery) {
$usableBatteryKwh += $battery['usableCapacityKwh'];
$maxBatteryChargeKw += $battery['maxChargeKw'];
$maxBatteryDischargeKw += $battery['maxDischargeKw'];
}
return [
'pvDcKwp' => $pvDcKwp,
'pvAcKw' => $pvAcKw,
'usableBatteryKwh' => $usableBatteryKwh,
'maxBatteryChargeKw' => $maxBatteryChargeKw,
'maxBatteryDischargeKw' => $maxBatteryDischargeKw,
];
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
require_once __DIR__.'/NetzfahrplanV4Rueckmeldung.php';
require_once __DIR__.'/NetzfahrplanV4Geraeteabruf.php';
/** Shared confirmed physical reading used by receiver and battery before a trial command. */
trait BatterieNetzfahrplanV4RueckmeldungTrait
{
private function registriereV4Rueckmeldung(): void
{
$this->RegisterPropertyString('NetzfahrplanV4RueckmeldungKonfiguration', '{}');
$this->RegisterTimer('NetzfahrplanV4RueckmeldungAktualisieren', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4RueckmeldungAktualisieren', 0);");
}
private function konfiguriereV4Rueckmeldung(): void
{
$this->SetBuffer('V4ConfirmedFeedback', '');
$this->SetBuffer('V4ConfirmedFeedbackConfig', '');
$this->SetBuffer('V4ConfirmedFeedbackMono', '');
$configured = !in_array(trim($this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration')), ['', '{}'], true);
$this->SetTimerInterval('NetzfahrplanV4RueckmeldungAktualisieren', $configured ? 1000 : 0);
}
/** No transport I/O under the battery control lock. Original sample times never move. */
private function v4BatterieRueckmeldung(): array
{
$json = $this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
$sample = json_decode($this->GetBuffer('V4ConfirmedFeedback'), true);
$mono = (float) $this->GetBuffer('V4ConfirmedFeedbackMono');
$now = time();$age = hrtime(true) / 1e9 - $mono;
if (!is_array($sample) || !is_int($sample['checkedAt'] ?? null)
|| $sample['checkedAt'] > $now || $now - $sample['checkedAt'] > 2
|| $age < 0 || $age > 2
|| $this->GetBuffer('V4ConfirmedFeedbackConfig') !== hash('sha256', $json)) {
throw new \RuntimeException('feedback_confirmed_cache_stale_or_invalid');
}
$config = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
if (($config['batteryInstanceId'] ?? null) !== $this->InstanceID
|| !in_array($config['managerId'] ?? 0, $this->zugeordneteManagerIDs(), true)) {
throw new \RuntimeException('feedback_cache_binding_invalid');
}
foreach ($config['sources'] as $source) {
if (!IPS_VariableExists($source['variableId'])) throw new \RuntimeException('feedback_variable_missing');
$object = IPS_GetObject($source['variableId']);
if ($object['ParentID'] !== $source['parentId'] || $object['ObjectIdent'] !== $source['ident']) {
throw new \RuntimeException('feedback_cache_source_changed');
}
}
return $sample;
}
/** Independent acquisition; never waits behind another read or takes the actuator lock. */
private function aktualisiereV4Rueckmeldung(): void
{
$json=$this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
if (strlen($json)>65536) throw new \RuntimeException('feedback_configuration_too_large');
$c=json_decode($json,true,32,JSON_THROW_ON_ERROR);
if (!is_array($c) || ($c['batteryInstanceId']??null)!==$this->InstanceID) throw new \RuntimeException('feedback_battery_binding_invalid');
if (!in_array($c['managerId']??0,$this->zugeordneteManagerIDs(),true)) throw new \RuntimeException('feedback_manager_binding_invalid');
$lock='ENELIX.V4.Feedback.'.$this->InstanceID;
if (!IPS_SemaphoreEnter($lock,0)) return;
try {
$reader=static function(int $id):array {
if (!IPS_VariableExists($id)) throw new \RuntimeException('feedback_variable_missing');
$v=IPS_GetVariable($id);$o=IPS_GetObject($id);$value=GetValue($id);
return ['value'=>$value,'updated'=>$v['VariableUpdated'],'changed'=>$v['VariableChanged'],
'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
};
$result=null;
for ($attempt=1;$attempt<=3;++$attempt) {
try {
$result=NetzfahrplanV4Geraeteabruf::snapshot(
$c,
static function(int $id):string {
if (!IPS_InstanceExists($id)) throw new \RuntimeException('feedback_parent_instance_missing');
return (string)(IPS_GetInstance($id)['ModuleInfo']['ModuleID']??'');
},
static function(int $id,string $module):bool {
if ($module===NetzfahrplanV4Geraeteabruf::MBUS_DEVICE) {
if (!function_exists('MBUS_UpdateValues') && !function_exists(__NAMESPACE__.'\\MBUS_UpdateValues')) throw new \RuntimeException('feedback_mbus_refresh_unavailable');
return MBUS_UpdateValues($id)===true;
}
if (in_array($module,[NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE,NetzfahrplanV4Geraeteabruf::MODBUS_ADDRESS],true)) {
if (!function_exists('ModBus_RequestRead') && !function_exists(__NAMESPACE__.'\\ModBus_RequestRead')) throw new \RuntimeException('feedback_modbus_refresh_unavailable');
return ModBus_RequestRead($id)===true;
}
return false;
},
$reader,
static fn():int=>time(),
static function(int $microseconds):void { usleep($microseconds); }
);
break;
} catch (\InvalidArgumentException $e) {
if (!str_starts_with($e->getMessage(),'feedback_source_changed_after_confirmed_read:') || $attempt>=3) throw $e;
usleep(150000);
}
}
if (!is_array($result)) throw new \RuntimeException('feedback_snapshot_unavailable');
$this->SetBuffer('V4ConfirmedFeedback',json_encode($result,JSON_THROW_ON_ERROR));
$this->SetBuffer('V4ConfirmedFeedbackConfig', hash('sha256', $json));
$this->SetBuffer('V4ConfirmedFeedbackMono', (string) (hrtime(true) / 1e9));
} catch (\Throwable $error) {
$this->SetBuffer('V4ConfirmedFeedback', '');
throw $error;
} finally {
IPS_SemaphoreLeave($lock);
}
}
/** Read only. A returned current value is NOT actuator permission. */
public function GetV4BatterieRueckmeldung(): string
{
try {
try { $r=$this->v4BatterieRueckmeldung(); }
catch (\Throwable) {
$this->aktualisiereV4Rueckmeldung();
$r=$this->v4BatterieRueckmeldung();
}
}
catch (\Throwable $e) { $r=['kind'=>'local_physical_feedback','status'=>'unavailable','reason'=>substr($e->getMessage(),0,200),
'batteryW'=>null,'gridW'=>null,'usableForTrial'=>false,'canDispatch'=>false,'deviceReadConfirmed'=>false]; }
return json_encode($r,JSON_THROW_ON_ERROR);
}
}
+227
View File
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
require_once __DIR__ . '/BatterieNetzfahrplanV4RueckmeldungTrait.php';
/** Dedicated short-lease battery input. Disabled until explicitly armed on BOTH modules. */
trait BatterieNetzfahrplanV4TestTrait
{
use BatterieNetzfahrplanV4RueckmeldungTrait;
private function registriereV4BatterieTest(): void
{
$this->registriereV4Rueckmeldung();
$this->RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4AktivtestErlaubt', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt', false);
$this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', '');
$this->RegisterPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis', '');
$this->RegisterAttributeString('NetzfahrplanV4TestStatus', '{"status":"disabled"}');
$this->RegisterTimer('NetzfahrplanV4TestWatchdog', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4TestWatchdog', 0);");
}
private function v4BatterieTestSitzung(): array
{
$s = json_decode($this->GetBuffer('V4BatteryTrialSession') ?: '{}', true);
return is_array($s) ? $s : [];
}
private function v4BatterieTestAktiv(): bool
{
return ($this->v4BatterieTestSitzung()['active'] ?? false) === true;
}
/** Local test arming by the bound manager; never called by ordinary forecast traffic. */
public function V4BatterieTestStarten(string $json): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
try { $this->v4BatterieStartIntern($json); }
finally { IPS_SemaphoreLeave($lock); }
}
private function v4BatterieStartIntern(string $json): void
{
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')) throw new RuntimeException('Batterietest lokal nicht erlaubt.');
$s = json_decode($json, true, 32, JSON_THROW_ON_ERROR);$now = time();
$activeTest = ($s['kind'] ?? null) === 'local_active_test_consent';
if (!is_array($s)
|| !in_array($s['kind'] ?? null, ['local_control_trial_consent', 'local_active_test_consent'], true)
|| ($s['batteryInstanceId'] ?? null) !== $this->InstanceID || $this->v4BatterieTestAktiv()) {
throw new RuntimeException('Batterie-Testanmeldung ungueltig oder bereits aktiv.');
}
NetzfahrplanV4Regeltest::uuid($s['sessionId'] ?? null);
if ($this->ReadAttributeString('NetzfahrplanV4LetzteTestsitzung') === $s['sessionId']) throw new RuntimeException('Testsitzung bereits beendet/verwendet.');
NetzfahrplanV4Regeltest::uuid($s['installationId'] ?? null);
$manager = NetzfahrplanV4Regeltest::integer($s['managerId'] ?? null, 'Manager', 1, 99999);
$managerPermission = $activeTest ? 'NetzfahrplanV4AktivtestErlaubt' : 'NetzfahrplanV4RegeltestErlaubt';
if (!in_array($manager, $this->zugeordneteManagerIDs(), true)
|| IPS_GetProperty($manager, $managerPermission) !== true) {
throw new RuntimeException('Manager nicht fuer Batterietest freigegeben/zugeordnet.');
}
$evidence = $this->ReadPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis');
$watchdogWaived = $activeTest
&& $this->ReadPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt')
&& ($s['watchdogWaived'] ?? null) === true
&& ($s['actuatorWatchdogEvidenceId'] ?? null) === 'explicit_test_plant_watchdog_waiver';
if (!$watchdogWaived && (strlen($evidence) < 8 || $evidence !== ($s['actuatorWatchdogEvidenceId'] ?? null))) {
throw new RuntimeException('Externer Geraete-Watchdog muss separat nachgewiesen sein.');
}
$maxDuration = $activeTest
? NetzfahrplanV4Regeltest::MAX_ACTIVE_TEST_SECONDS
: NetzfahrplanV4Regeltest::MAX_TEST_SECONDS;
$end = NetzfahrplanV4Regeltest::integer($s['expiresAt'] ?? null, 'Testende', $now + 1, $now + $maxDuration);
$s['maxChargeW'] = NetzfahrplanV4Regeltest::number($s['maxChargeW'] ?? null, 'Testladen', 1, 1e9);
$s['maxDischargeW'] = NetzfahrplanV4Regeltest::number($s['maxDischargeW'] ?? null, 'Testentladen', 1, 1e9);
if (!is_string($s['assetId'] ?? null) || $s['assetId'] === '') throw new RuntimeException('Batterie-ID fehlt.');
if (!$this->GetValue('Aktiv') || $this->ReadPropertyInteger('Batteriemanagement') !== 2) {
throw new RuntimeException('Batterie nicht unter ENELIX-Regelung.');
}
$feedback = $this->v4BatterieRueckmeldung();
$now = time();
if ($now >= $end) throw new RuntimeException('Testsitzung waehrend Geraeteabruf abgelaufen.');
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $s, $now);
$this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $s['sessionId']);
$s['active'] = true;$s['lastSequence'] = 0;$s['lastWallClockAt'] = $now;
$s['monotonicDeadline'] = hrtime(true) / 1e9 + $end - $now;
$s['commandMonotonicDeadline'] = hrtime(true) / 1e9 + 10;
$this->SetBuffer('V4BatteryTrialCommand', '{}');
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
// Old ordinary values may not resume after the test without a NEW manager message.
$this->setzeZustand('SollwertGueltig', false);
$this->SetTimerInterval('NetzfahrplanV4TestWatchdog', 1000);
$this->WriteAttributeString('NetzfahrplanV4TestStatus', '{"status":"armed_waiting_command","physicalResponseVerified":false}');
}
public function V4BatterieTestBefehl(string $json): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
try { $this->v4BatterieBefehlIntern($json); }
finally { IPS_SemaphoreLeave($lock); }
}
private function v4BatterieBefehlIntern(string $json): void
{
$s = $this->v4BatterieTestSitzung();$now = time();$mono = hrtime(true) / 1e9;
try {
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')
|| (($s['kind'] ?? null) === 'local_active_test_consent'
&& !$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt'))) {
throw new RuntimeException('Batterietest gesperrt.');
}
$c = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
NetzfahrplanV4Regeltest::pruefeBatteriebefehl($c, $s, $now, $mono);
$s['lastSequence'] = $c['sequence'];$s['lastWallClockAt'] = $now;
$s['commandMonotonicDeadline'] = $mono + min(10, $c['expiresAt'] - $now);
$this->SetBuffer('V4BatteryTrialCommand', json_encode($c, JSON_THROW_ON_ERROR));
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
$this->aktualisiereIntern(true);
if (!$this->v4BatterieTestAktiv() || $this->ReadAttributeString('Registerfehler') !== '') {
$state = json_decode($this->ReadAttributeString('NetzfahrplanV4TestStatus'), true);
$detail = $this->ReadAttributeString('Registerfehler') ?: ($state['reason'] ?? '');
throw new RuntimeException('Batterie konnte Testbefehl nicht sicher uebernehmen: ' . substr($detail, 0, 300));
}
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode([
'status' => 'controller_command_accepted', 'sessionId' => $s['sessionId'],
'sourceShadowPlanId' => $c['sourceShadowPlanId'], 'sequence' => $c['sequence'],
'commandExpiresAt' => $c['expiresAt'], 'actualRequestedW' => $this->ReadAttributeInteger('LetzterSollwert'),
'physicalResponseVerified' => false], JSON_THROW_ON_ERROR));
} catch (Throwable $e) {
$this->beendeV4BatterieTest('Befehl abgelehnt: ' . substr($e->getMessage(), 0, 500), true);
throw $e;
}
}
/** Explicit local abort or ApplyChanges: revoke before any register action. */
private function beendeV4BatterieTest(string $reason, bool $writeNow): void
{
$oldStatus = json_decode($this->ReadAttributeString('NetzfahrplanV4TestStatus'), true);
$active = $this->v4BatterieTestAktiv() || in_array($oldStatus['status'] ?? '', ['controller_command_accepted', 'armed_waiting_command', 'stop_pending', 'stop_failed'], true);
$this->SetBuffer('V4BatteryTrialSession', '{}');$this->SetBuffer('V4BatteryTrialCommand', '{}');
$this->SetTimerInterval('NetzfahrplanV4TestWatchdog', 0);
if (!$active) return;
$this->setzeZustand('SollwertGueltig', false);$this->setzeZustand('Sollleistung', 0);
$report = ['status' => 'stop_pending', 'reason' => $reason, 'physicalResponseVerified' => false];
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
if ($writeNow) {
try { $this->schreibeRegister(0, true);$report['status'] = 'stopped'; }
catch (Throwable $e) {
$report['status'] = 'stop_failed';
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
throw $e;
}
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
}
}
public function V4BatterieTestStoppen(): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
try { $this->beendeV4BatterieTest('Lokaler Testabbruch', true); }
finally { IPS_SemaphoreLeave($lock); }
}
public function GetV4BatterieTestStatus(): string
{
return $this->ReadAttributeString('NetzfahrplanV4TestStatus');
}
/** Called inside the existing battery update, immediately before writing registers. */
private function v4BatterieTestZiel(array $measurements, bool $available, int $now): ?int
{
if (!$this->v4BatterieTestAktiv()) return null;
$s = $this->v4BatterieTestSitzung();
try {
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')
|| (($s['kind'] ?? null) === 'local_active_test_consent'
&& !$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt'))) {
throw new RuntimeException('Lokale Testfreigabe entzogen.');
}
$feedback = $this->v4BatterieRueckmeldung();
// The confirmed device read may cross a wall-clock second. All freshness,
// watchdog and switching decisions below use the post-read clocks.
$now = time();$mono = hrtime(true) / 1e9;
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $s, $now);
$c = json_decode($this->GetBuffer('V4BatteryTrialCommand') ?: '{}', true);
if ($now >= $s['expiresAt'] || $mono >= $s['monotonicDeadline'] || $now < $s['lastWallClockAt']) throw new RuntimeException('Testsitzung abgelaufen/Zeitsprung.');
if (!$c) {
if ($mono >= $s['commandMonotonicDeadline']) throw new RuntimeException('Kein Startbefehl innerhalb Watchdog.');
return 0;
}
$w = NetzfahrplanV4Regeltest::batterieZiel($c, $s, $measurements, [
'available' => $available, 'reserve' => $this->ReadPropertyFloat('ReserveLadezustand'),
'minimum' => $this->ReadPropertyFloat('MindestLadezustand'),
'blocked' => (bool) $this->leseZustand('HystereseAktiv'),
'rechargeLimitW' => $this->ReadPropertyBoolean('NachladenMitMaximalleistung') ? null : $this->ReadPropertyInteger('MaximaleNachladeleistung'),
], $now, $mono);
// Respect switching lock for increases/reversals; safety reductions and zero are immediate.
$last = $this->ReadAttributeInteger('LetzterSollwert');
if ($now < $this->ReadAttributeInteger('AenderungFreigabeAb') && $w !== $last) {
if ($last * $w < 0) $w = 0;
elseif (abs($w) > abs($last)) $w = $last;
}
// Final hardware-policy/ramp clipping must not invalidate the binding or grid limits.
if (($c['feedbackConfigHash'] ?? null) !== $feedback['configHash']) throw new RuntimeException('feedback_configuration_changed');
$expectedGrid = $feedback['gridW'] - $feedback['batteryW'] + $w;
if (($c['importLimitW'] !== null && $expectedGrid > $c['importLimitW'] + 1)
|| ($c['exportLimitW'] !== null && -$expectedGrid > $c['exportLimitW'] + 1)) {
throw new RuntimeException('final_battery_clamp_cannot_meet_grid_limit');
}
$s['lastWallClockAt'] = $now;
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
return $w;
} catch (Throwable $e) {
$this->beendeV4BatterieTest($e->getMessage(), false);
return 0; // revoke stale value even if ordinary cached setpoint existed
}
}
}
+259
View File
@@ -0,0 +1,259 @@
<?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,
?float $maxNachladenW = null
): 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 ($maxNachladenW !== null && $maxNachladenW < 0.0) {
throw new InvalidArgumentException('Maximale Nachladeleistung darf nicht negativ sein.');
}
if (!$aktiv || !$enelixSteuert) {
return [0];
}
$maxLaden = (int) floor($maxLadenW);
$maxNachladen = (int) floor(min($maxLadenW, $maxNachladenW ?? $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, $maxNachladen);
}
return self::leistungsbereich(0, $maxNachladen);
}
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, $maxNachladen);
}
return [$maxNachladen];
}
/**
* 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.');
}
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
final class EaseeGatewayProtokoll
{
/** @var list<int> */
public const BEOBACHTUNGEN = [
31, 41, 42, 43, 47, 48, 62, 96, 100, 102, 104, 109, 110,
111, 112, 113, 114, 119, 120, 182, 183, 184, 185, 250,
];
public static function seriennummer(string $wert): string
{
return strtoupper(trim($wert));
}
/**
* @param mixed $knoten
* @param list<string> $abonnements
* @return list<array{Seriennummer: string, ID: int, Wert: mixed}>
*/
public static function extrahiereBeobachtungen(
$knoten,
array $abonnements,
string $seriennummer = '',
int $tiefe = 0
): array {
if (!is_array($knoten) || $tiefe > 12) {
return [];
}
$abonnements = array_values(array_unique(array_map(
[self::class, 'seriennummer'],
$abonnements
)));
foreach (['serialNumber', 'SerialNumber', 'chargerId', 'ChargerId', 'mid', 'Mid'] as $feld) {
if (!isset($knoten[$feld]) || !is_scalar($knoten[$feld])) {
continue;
}
$kandidat = self::seriennummer((string) $knoten[$feld]);
if (in_array($kandidat, $abonnements, true)) {
$seriennummer = $kandidat;
break;
}
}
if (self::istListe($knoten)) {
foreach ($knoten as $wert) {
if (!is_string($wert)) {
continue;
}
$kandidat = self::seriennummer($wert);
if (in_array($kandidat, $abonnements, true)) {
$seriennummer = $kandidat;
}
}
}
$id = null;
foreach (['id', 'Id', 'observationId', 'ObservationId'] as $feld) {
if (array_key_exists($feld, $knoten) && is_numeric($knoten[$feld])) {
$id = (int) $knoten[$feld];
break;
}
}
$wertVorhanden = false;
$wert = null;
foreach (['value', 'Value'] as $feld) {
if (array_key_exists($feld, $knoten)) {
$wertVorhanden = true;
$wert = $knoten[$feld];
break;
}
}
$ergebnis = [];
if (
$seriennummer !== ''
&& $wertVorhanden
&& in_array($id, self::BEOBACHTUNGEN, true)
) {
$ergebnis[] = [
'Seriennummer' => $seriennummer,
'ID' => $id,
'Wert' => $wert,
];
}
foreach ($knoten as $kind) {
if (is_array($kind)) {
$ergebnis = array_merge(
$ergebnis,
self::extrahiereBeobachtungen(
$kind,
$abonnements,
$seriennummer,
$tiefe + 1
)
);
}
}
return $ergebnis;
}
/** @param array<mixed> $wert */
private static function istListe(array $wert): bool
{
$index = 0;
foreach ($wert as $schluessel => $_) {
if ($schluessel !== $index++) {
return false;
}
}
return true;
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class EaseeLadestatus
{
public const BETRIEBSMODUS_EASEE = 0;
public const BETRIEBSMODUS_NUR_SOLAR = 1;
/**
* @param array<string, mixed> $beobachtungen
* @return array{
* StatusGueltig: bool,
* FahrzeugVerbunden: bool,
* FahrzeugGeladen: bool,
* Fahrzeugstatus: int,
* Phasenzahl: int,
* Istleistung_W: float,
* Ladestrom_A: float,
* ApiMaximalstrom_A: int,
* EaseeAktiviert: bool,
* AutorisierungErforderlich: bool,
* RemoteStartErforderlich: bool,
* SmartCharging: bool,
* GrundKeinLadestrom: int,
* Stoerung: bool,
* Stoertext: string
* }
*/
public static function ausBeobachtungen(array $beobachtungen, int $konfigurierterMaximalstrom): array
{
if ($konfigurierterMaximalstrom < 6 || $konfigurierterMaximalstrom > 32) {
throw new InvalidArgumentException(
'MaximalerLadestrom muss zwischen 6 und 32 A liegen.'
);
}
$rohBetriebszustand = $beobachtungen['109'] ?? null;
$statusGueltig = is_numeric($rohBetriebszustand)
&& floor((float) $rohBetriebszustand) === (float) $rohBetriebszustand
&& in_array((int) $rohBetriebszustand, [0, 1, 2, 3, 4, 5, 6, 7, 8], true);
$betriebszustand = $statusGueltig ? (int) $rohBetriebszustand : 0;
$verbunden = $statusGueltig
&& in_array($betriebszustand, [2, 3, 4, 5, 6, 7, 8], true);
$geladen = $betriebszustand === 4;
$fahrzeugstatus = self::fahrzeugstatus($betriebszustand);
$phasenzahl = $verbunden
? self::phasenzahl((int) ($beobachtungen['110'] ?? 0))
: 0;
$laedt = $betriebszustand === 3;
$leistung = $laedt
? max(0.0, (float) ($beobachtungen['120'] ?? 0.0) * 1000.0)
: 0.0;
$stroeme = [];
if ($laedt) {
foreach ([182, 183, 184, 185] as $id) {
if (array_key_exists((string) $id, $beobachtungen)) {
$stroeme[] = abs((float) $beobachtungen[(string) $id]);
}
}
}
$ladestrom = $stroeme === [] ? 0.0 : max($stroeme);
$grenzen = [$konfigurierterMaximalstrom];
foreach ([47, 104] as $id) {
$grenze = (float) ($beobachtungen[(string) $id] ?? 0.0);
if ($grenze >= 6.0) {
$grenzen[] = (int) floor($grenze);
}
}
$apiMaximalstrom = max(6, min($grenzen));
$easeeAktiviert = !array_key_exists('31', $beobachtungen)
|| (bool) $beobachtungen['31'];
$autorisierungErforderlich = (bool) (
$beobachtungen['42'] ?? $beobachtungen['41'] ?? false
);
$remoteStartErforderlich = (bool) ($beobachtungen['43'] ?? false);
$smartCharging = (bool) ($beobachtungen['102'] ?? false);
$grundKeinLadestrom = is_numeric($beobachtungen['96'] ?? null)
? (int) $beobachtungen['96']
: 0;
$fehlercode = (int) ($beobachtungen['119'] ?? 0);
$stoerung = $betriebszustand === 5 || $fehlercode !== 0;
$stoertext = '';
if ($betriebszustand === 5) {
$stoertext = 'Easee meldet einen Fehlerzustand.';
}
if ($fehlercode !== 0) {
$stoertext .= ($stoertext === '' ? '' : ' ') . 'Easee-Fehlercode: ' . $fehlercode . '.';
}
return [
'StatusGueltig' => $statusGueltig,
'FahrzeugVerbunden' => $verbunden,
'FahrzeugGeladen' => $geladen,
'Fahrzeugstatus' => $fahrzeugstatus,
'Phasenzahl' => $phasenzahl,
'Istleistung_W' => $leistung,
'Ladestrom_A' => $ladestrom,
'ApiMaximalstrom_A' => $apiMaximalstrom,
'EaseeAktiviert' => $easeeAktiviert,
'AutorisierungErforderlich' => $autorisierungErforderlich,
'RemoteStartErforderlich' => $remoteStartErforderlich,
'SmartCharging' => $smartCharging,
'GrundKeinLadestrom' => $grundKeinLadestrom,
'Stoerung' => $stoerung,
'Stoertext' => trim($stoertext),
];
}
public static function solarladenErzwungen(int $betriebsmodus): bool
{
if (!in_array($betriebsmodus, [
self::BETRIEBSMODUS_EASEE,
self::BETRIEBSMODUS_NUR_SOLAR,
], true)) {
throw new InvalidArgumentException('Unbekannter Easee-Betriebsmodus.');
}
return $betriebsmodus === self::BETRIEBSMODUS_NUR_SOLAR;
}
private static function fahrzeugstatus(int $betriebszustand): int
{
if ($betriebszustand === 1) {
return 1;
}
if (in_array($betriebszustand, [2, 6, 7, 8], true)) {
return 2;
}
if ($betriebszustand === 3) {
return 3;
}
if ($betriebszustand === 4) {
return 4;
}
if ($betriebszustand === 5) {
return 5;
}
return 0;
}
private static function phasenzahl(int $ausgangsphase): int
{
if ($ausgangsphase >= 10 && $ausgangsphase <= 15) {
return 1;
}
if ($ausgangsphase === 30) {
return 3;
}
return 0;
}
}
+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.');
}
}
+78
View File
@@ -0,0 +1,78 @@
<?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 $sdl = 0.0): float
{
return max(
0.0,
self::endlich($pv) + self::endlich($netz) - self::endlich($batterie) - self::endlich($sdl)
);
}
public static function hausenergie(
float $pv,
float $netzbezug,
float $netzeinspeisung,
float $batterieLaden,
float $batterieEntladen,
float $sdlLaden = 0.0,
float $sdlEntladen = 0.0
): float {
return max(
0.0,
self::endlich($pv)
+ self::endlich($netzbezug)
+ self::endlich($batterieEntladen)
- self::endlich($netzeinspeisung)
- self::endlich($batterieLaden)
- self::endlich($sdlLaden)
+ self::endlich($sdlEntladen)
);
}
/**
* @param array{PV: float, Haus: float, Netz: float, Batterie: float, SDL?: float} $leistungen
* @return array{
* PVEnergie: float,
* Hausenergie: float,
* NetzbezugEnergie: float,
* EinspeisungEnergie: float,
* BatterieLadenEnergie: float,
* BatterieEntladenEnergie: float,
* SDLLadenEnergie: float,
* SDLEntladenEnergie: 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']);
$sdl = self::endlich($leistungen['SDL'] ?? 0.0);
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,
'SDLLadenEnergie' => max(0.0, $sdl) * $faktor,
'SDLEntladenEnergie' => max(0.0, -$sdl) * $faktor,
];
}
private static function endlich(float $wert): float
{
return is_finite($wert) ? $wert : 0.0;
}
}
+40 -5
View File
@@ -91,7 +91,7 @@ final class LadestationAdapter
} }
/** /**
* @return array{Rohstatus: int, Istleistung_W: float} * @return array{Rohstatus: int, Istleistung_W: float, Sollstrom_A?: int, Maximalstrom_A?: int, Iststrom_A?: float, Phasenzahl?: int}
*/ */
public static function dekodiereStatus(int $geraetetyp, string $antwort): array public static function dekodiereStatus(int $geraetetyp, string $antwort): array
{ {
@@ -110,12 +110,47 @@ final class LadestationAdapter
throw new RuntimeException('Die go-e-Antwort enthaelt nicht alle Statusfelder.'); throw new RuntimeException('Die go-e-Antwort enthaelt nicht alle Statusfelder.');
} }
$faktor = $geraetetyp === self::GO_E_ALT ? 10.0 : 1.0; $leistungsfaktor = $geraetetyp === self::GO_E_ALT ? 10.0 : 1.0;
$status = [
return [
'Rohstatus' => (int) $daten['car'], 'Rohstatus' => (int) $daten['car'],
'Istleistung_W' => (float) $daten['nrg'][11] * $faktor, 'Istleistung_W' => (float) $daten['nrg'][11] * $leistungsfaktor,
]; ];
if (isset($daten['amp']) && is_numeric($daten['amp'])) {
$sollstrom = (int) $daten['amp'];
$freigabeEntzogen = isset($daten['alw']) && $daten['alw'] === false;
$erzwungenAus = $geraetetyp === self::GO_E_GEMINI
&& isset($daten['frc'])
&& (int) $daten['frc'] === 1;
$status['Sollstrom_A'] = $freigabeEntzogen || $erzwungenAus ? 0 : $sollstrom;
}
if (isset($daten['ama']) && is_numeric($daten['ama'])) {
$maximalstrom = (int) $daten['ama'];
if ($maximalstrom >= 6 && $maximalstrom <= 32) {
$status['Maximalstrom_A'] = $maximalstrom;
}
}
if (isset($daten['nrg'][4], $daten['nrg'][5], $daten['nrg'][6])
&& is_numeric($daten['nrg'][4])
&& is_numeric($daten['nrg'][5])
&& is_numeric($daten['nrg'][6])
) {
$stromfaktor = $geraetetyp === self::GO_E_ALT ? 0.1 : 1.0;
$phasenstroeme = [
(float) $daten['nrg'][4] * $stromfaktor,
(float) $daten['nrg'][5] * $stromfaktor,
(float) $daten['nrg'][6] * $stromfaktor,
];
$status['Iststrom_A'] = max($phasenstroeme);
$aktivePhasen = count(array_filter(
$phasenstroeme,
static fn (float $strom): bool => $strom >= 1.0
));
if ($aktivePhasen > 0) {
$status['Phasenzahl'] = $aktivePhasen >= 2 ? 3 : 1;
}
}
return $status;
} }
if ($geraetetyp === self::SMART_ME_PICO) { if ($geraetetyp === self::SMART_ME_PICO) {

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