diff --git a/DOKUMENTATION_CHATGPT.md b/DOKUMENTATION_CHATGPT.md new file mode 100644 index 0000000..75a32d2 --- /dev/null +++ b/DOKUMENTATION_CHATGPT.md @@ -0,0 +1,865 @@ +# Belevo Energiemanagement fuer IP-Symcon + +Stand: 2026-05-10 + +Diese Dokumentation ist als Arbeitsgrundlage fuer ChatGPT/Codex und fuer zukuenftige Software-Erweiterungen gedacht. Sie beschreibt die Funktion, Architektur, Modul-Schnittstellen und die wichtigsten Regeln, um neue Module sauber an den Energiemanager anzubinden. + +## 1. Kurzueberblick + +Das Repository ist eine IP-Symcon Modulbibliothek fuer ein Energiemanagementsystem der Belevo AG. Das System verteilt elektrische Leistung dynamisch auf steuerbare Verbraucher wie Ladestationen, Boiler, Pufferspeicher, Batterie, Waermepumpe und externe Schaltgruppen. + +Die zentrale Idee: + +1. Der `Manager` liest den aktuellen Netzbezug. +2. Der `Manager` entscheidet zwischen Solarladen und Peak-Shaving. +3. Jeder Verbraucher meldet seine aktuell moeglichen Leistungsstufen als `PowerSteps`. +4. Der `Manager` verteilt die verfuegbare oder zu reduzierende Leistung nach Prioritaet und Fairness. +5. Die Verbraucher setzen die zugeteilte Leistung in konkrete Schaltkontakte, API-Aufrufe oder Geraetemodi um. + +Das System ist fuer Einzelhaeuser, ZEV/V-ZEV-Anlagen und groessere Lastmanagement-Strukturen ausgelegt. Fuer verteilte Anlagen gibt es neben dem lokalen `Manager` den `HauptManager`, der mehrere untergeordnete Manager ueber JSON-Datenpunkte zusammenfassen kann. + +## 2. Repository-Struktur + +Jedes IP-Symcon Modul liegt in einem eigenen Ordner und enthaelt typischerweise: + +- `module.php`: PHP-Klasse mit der eigentlichen Logik. +- `module.json`: IP-Symcon Modul-Metadaten, GUID, Name, Typ, Hersteller, Prefix. +- `form.json`: Konfigurationsformular fuer IP-Symcon. +- `README.md`: Modulbeschreibung, aktuell oft noch Template oder veraltet. + +Wichtige Dateien auf Root-Ebene: + +- `library.json`: Modulbibliothek, aktuell Version `2.001`, IP-Symcon-Kompatibilitaet `8.0`. +- `README.md`: vorhandene Systembeschreibung des Energiemanagers. +- `DOKUMENTATION_CHATGPT.md`: diese technische Gesamtuebersicht. + +## 3. Modulgruppen + +| Modul | Rolle | Manager-Anbindung | +| --- | --- | --- | +| `Manager` | Lokaler Energiemanager, verteilt Leistung an Verbraucher | Zentrale Instanz | +| `HauptManager` | Aggregiert mehrere Manager/Unteranlagen ueber JSON | Uebergeordnete Instanz | +| `Ladestation_v2` | Moderne Ladestationsanbindung mit Fahrzeugerkennung, Mindeststrom, 1p/3p, Easee/ECarUp/Smart-Me/Go-E | Verbraucher | +| `Ladestation_Universal` | Aeltere/einfachere Ladestation | Verbraucher | +| `Batterie` | Batterie mit Lade-/Entladeleistungssteuerung fuer Goodwe, Solaredge, Sig Energy | Verbraucher, bidirektional | +| `Boiler_2_Stufig_Mit_Fueler` | Boiler mit Teil- und Volllastkontakt, Temperatur- und Legionellenlogik | Verbraucher | +| `Boiler_x_Stufig` | Boiler mit frei konfigurierbaren Leistungsstufen | Verbraucher | +| `Ansteuerung_Askoheat` | Stufenlose/mehrstufige Askoheat-Ansteuerung ueber externe Leistungsvariable | Verbraucher | +| `Pufferspeicher` | Puffer mit Teil-/Volllastkontakt und Aussentemperatur-Funktion | Verbraucher | +| `Puffer_Speicher` | Puffer mit frei konfigurierbaren Leistungsstufen | Verbraucher | +| `Verbraucher_1_Stufig` | Einfacher Ein/Aus-Verbraucher mit Mindestlaufzeit/Nachtlogik | Verbraucher | +| `Verbraucher_Sperrbar` | Verbraucher, der im Peak-Fall gesperrt werden kann | Verbraucher | +| `Verbraucher_extern` | Buendelt externe Schaltverbraucher zu Leistungskombinationen | Verbraucher/Adapter | +| `WP_Steuerung` | Waermepumpe mit Sperr-/Erhoehungskontakten, Wetter-/Sonnenlogik | Verbraucher | +| `CC100_HW` | Hardwarezugriff auf digitale Ein-/Ausgaenge und PT-Fuehler | Hilfs-/Hardwaremodul | +| `Shelly_Parser_MQTT` | MQTT-Parser fuer Shelly-Geraete, legt Variablen dynamisch an | Hilfs-/Integrationsmodul | +| `Belevo_Server_Kommunikation` | Wetterdaten und Influx/Server-Kommunikation | Monitoring/Kommunikation | +| `Energy_Pie` | Visualisierung von Produktion, Netz, Einspeisung, Hausverbrauch | Visualisierung | +| `Belevo_Bezahl_Modul` | Experimentelles Google-Pay/Stripe HTML-Modul | Nebenmodul, nicht produktionsreif | + +## 4. Grundarchitektur + +### 4.1 Schichten + +Die Software hat logisch vier Schichten: + +1. Datenquellen: + - Netzbezug, Batterieladezustand, aktuelle Ladeleistung, Temperaturen, Schaltzustand, Wetterdaten. + - Meist als IP-Symcon Variablen konfiguriert. + +2. Energiemanagement: + - `Manager` fuer lokale Anlagen. + - `HauptManager` fuer uebergeordnete Verteilung ueber mehrere Manager. + +3. Verbraucheradapter: + - Module, die den gemeinsamen Verbraucher-Vertrag umsetzen. + - Beispiele: `Ladestation_v2`, `Batterie`, `Boiler_x_Stufig`. + +4. Aktoren und externe Systeme: + - Schaltkontakte, Wallbox HTTP APIs, Cloud APIs, Wechselrichtervariablen, Shelly MQTT, CC100 Linux-Dateisystem. + +### 4.2 Normaler lokaler Ablauf + +Ein typischer Zyklus laeuft so: + +1. `Manager` wird ueber `Timer_DistributeEnergy` aufgerufen. +2. `Manager` liest `Netzbezug`, `Peakleistung`, `Ueberschussleistung` und `Verbraucher_Liste`. +3. Der Modus wird bestimmt: + - Solarladen, wenn der Netzbezug unterhalb des Umschaltbereichs liegt. + - Peak-Shaving, wenn der Netzbezug ueber dem Umschaltbereich liegt. +4. Fuer jeden Verbraucher werden die EMS-Variablen gelesen: + - `Power` + - `Bezogene_Energie` + - `PV_Prio` + - `Sperre_Prio` + - `Idle` + - `PowerSteps` + - `Leistung_Delta` +5. Der Manager fordert per `IPS_RequestAction($verbraucher, "GetCurrentData", $Is_Peak_Shaving)` neue Daten an. +6. Wenn ein Verbraucher nicht `Idle` ist oder sich der Modus geaendert hat, bleiben alle aktuellen Leistungen im aktuellen Zyklus stehen. +7. Sonst sortiert der Manager Verbraucher nach Prioritaet und verteilt die Leistung. +8. Die Zuteilung wird ueber `IPS_RequestAction($verbraucher, "SetAktuelle_Leistung", $leistung)` an die Verbraucher gegeben. +9. Die Verbraucher setzen diese Vorgabe in ihrem eigenen `Do_UserCalc`-Timer um. + +Wichtig: Viele Verbraucher setzen die Leistung nicht direkt im `RequestAction("SetAktuelle_Leistung")`, sondern speichern den Wert zuerst in der Variable `Power`. Die reale Umsetzung erfolgt im naechsten `Do_UserCalc`. + +### 4.3 Modusentscheidung + +Der lokale `Manager` kennt zwei Betriebsarten: + +- `Is_Peak_Shaving = false`: Solarladen / Nutzung verfuegbarer Leistung. +- `Is_Peak_Shaving = true`: Peak-Shaving / Reduktion oder Begrenzung der Leistung. + +Wenn `UmschaltpunktStatisch = false`, nutzt der Manager die Mitte zwischen `Peakleistung` und `Ueberschussleistung`: + +```text +Schwellwert = (Peakleistung + Ueberschussleistung) / 2 +Netzbezug < Schwellwert -> Solarladen +Netzbezug >= Schwellwert -> Peak-Shaving +``` + +Wenn `UmschaltpunktStatisch = true`, werden `Umschalt_Solarladen` und `Umschalt_Peakshaving` als Hysterese verwendet. Zwischen den beiden Werten bleibt der bisherige Modus erhalten. + +### 4.4 Leistungsverteilung + +Der Manager arbeitet mit diskreten Leistungsstufen. Jeder Verbraucher liefert ein JSON-Array `PowerSteps`, zum Beispiel: + +```json +[0, 1500, 3000, 6000] +``` + +oder bei bidirektionalen Verbrauchern: + +```json +[-3000, -1000, 0, 1000, 3000] +``` + +Die Verteilung laeuft prioritaetsweise: + +1. Prioritaetsschluessel waehlen: + - Solarladen: `PV_Prio` + - Peak-Shaving: `Sperre_Prio` +2. Verbraucher sortieren: + - zuerst nach Prioritaet, niedriger Wert bedeutet hoeher priorisiert. + - bei gleicher Prioritaet nach `Bezogene_Energie / 2000`, damit weniger versorgte Verbraucher bevorzugt werden. +3. Verbraucher gleicher Prioritaet werden gruppiert. +4. Innerhalb einer Gruppe werden alle moeglichen Schritte sortiert. +5. Der Manager nimmt nur Schritte, die in die verbleibende Leistung passen. +6. Verbraucher, die nicht `0` annehmen koennen, werden gesondert behandelt. + +`Leistung_Delta` korrigiert Soll-/Ist-Abweichungen. Das ist besonders wichtig bei Ladestationen, weil die effektive Ladeleistung von der Vorgabe abweichen kann. + +## 5. Der gemeinsame Verbraucher-Vertrag + +Jedes neue Modul, das vom `Manager` gesteuert werden soll, muss mindestens die folgenden Variablen und Actions anbieten. + +### 5.1 Pflichtvariablen + +| Ident | Typ | Bedeutung | +| --- | --- | --- | +| `Sperre_Prio` | Integer | Prioritaet im Peak-Shaving. Niedriger = wichtiger. | +| `PV_Prio` | Integer | Prioritaet im Solarladen. Niedriger = wichtiger. | +| `Idle` | Boolean | `true`, wenn das Modul fuer eine neue Leistungszuteilung bereit ist. | +| `Aktuelle_Leistung` | Integer/Float | Aktuell umgesetzte oder zuletzt gesetzte Leistung in W. | +| `Bezogene_Energie` | Float | Aufsummierte Energie fuer Fairness. Aktuell in W * h gerechnet, faktisch Wh. | +| `PowerSteps` | String | JSON-Array moeglicher Leistungsstufen in W. | +| `Power` | Integer/Float | Vom Manager gewuenschte Leistung, oft als Zwischenspeicher. | +| `Is_Peak_Shaving` | Boolean | Letzter vom Manager uebergebener Modus. | +| `Leistung_Delta` | Integer/Float | Abweichung zwischen Soll und Ist, damit der Manager die reale Leistung beruecksichtigen kann. | +| `IdleCounter` | Integer | Hilfszaehler bis `Idle` wieder `true` wird. | + +### 5.2 Pflicht-Actions + +| Action | Parameter | Aufgabe | +| --- | --- | --- | +| `GetCurrentData` | Boolean `Peak` | Aktuellen Modus speichern und `PowerSteps` neu berechnen. | +| `SetAktuelle_Leistung` | Integer/Float `power` | Neue Leistungsvorgabe entgegennehmen oder direkt umsetzen. | +| `Do_UserCalc` | leer | Interner zyklischer Modulschritt, setzt `Power` um und aktualisiert `PowerSteps`. | + +### 5.3 Bedeutung der Vorzeichen + +Im bestehenden Code gilt implizit: + +- Positive Leistung: Verbraucher bezieht oder laedt Energie. +- `0`: Verbraucher aus oder neutral. +- Negative Leistung: Einspeisen, Entladen oder Gegenrichtung, aktuell vor allem fuer `Batterie` relevant. + +Neue Module sollten diese Vorzeichenregel konsequent einhalten. Wenn ein Geraet physikalisch anders arbeitet, sollte der Adapter intern umrechnen, aber gegenueber dem Manager diese Semantik behalten. + +### 5.4 Idle-Regel + +`Idle = false` bedeutet: Das Modul hat gerade eine Aenderung bekommen oder wartet auf Mindestlaufzeiten, Hardware-Reaktion oder Stabilisierung. Der Manager soll in dieser Zeit keine neue Verteilung erzwingen. + +Typisches Muster: + +1. Wenn neue Leistung ungleich alter Leistung: + - `Idle = false` + - `IdleCounter = IdleCounterMax` +2. Pro Zyklus: + - `IdleCounter` dekrementieren. + - Wenn `IdleCounter == 0`, dann `Idle = true`. + +Dieses Muster verhindert schnelles Takten von Relais, Wallboxen und Wechselrichtervorgaben. + +## 6. Manager im Detail + +### 6.1 `Manager` + +Datei: `Manager/module.php` + +Wichtige Properties: + +- `Peakleistung`: Sollwert bzw. Obergrenze fuer Peak-Shaving in W. +- `Ueberschussleistung`: Sollwert fuer Solarladen in W. +- `Netzbezug`: IP-Symcon Variable mit aktuellem Netzbezug. +- `Verbraucher_Liste`: Liste der zu steuernden Verbraucher-Instanzen. +- `UmschaltpunktStatisch`: aktiviert feste Umschaltpunkte. +- `Umschalt_Solarladen`: untere Schwelle bei statischer Hysterese. +- `Umschalt_Peakshaving`: obere Schwelle bei statischer Hysterese. +- `HauptmanagerAktiv`: aktiviert Kopplung an `HauptManager`. +- `ManagerID`: ID des Untermanagers fuer externe Strukturen. +- `DatenHoch`: String-Variable, in die lokale Daten fuer den Hauptmanager geschrieben werden. +- `DatenZuruck`: String-Variable, aus der Zuweisungen des Hauptmanagers gelesen werden. +- `Interval`: Zykluszeit in Sekunden. + +Wichtige Funktionen: + +- `DistributeEnergy()`: lokale Verteilung. +- `DistributeEnergy_Extern()`: JSON-Austausch mit Hauptmanager. +- `RequestAction("DistributeEnergy")`: Einstieg des Timers. + +### 6.2 `HauptManager` + +Datei: `HauptManager/module.php` + +Der `HauptManager` verbindet mehrere lokale Manager oder Teilanlagen. Er liest pro Unteranlage: + +- eine JSON-Variable mit User-/Verbraucherdaten (`User_Up`) +- eine JSON-Variable fuer Rueckgabe/Zuweisung (`User_Down`) + +Er aggregiert alle Verbraucher, summiert den Netzbezug, entscheidet global zwischen Solarladen und Peak-Shaving und schreibt pro Untermanager ein JSON mit finalen `Set_Leistung`-Werten zurueck. + +JSON-Erwartung vom Untermanager: + +```json +{ + "User": [ + { + "InstanceID": 12345, + "Aktuelle_Leistung": 0, + "Bezogene_Energie": 0, + "PV_Prio": 1, + "Sperre_Prio": 1, + "Idle": true, + "PowerSteps": [0, 1500, 3000], + "Leistung_Delta": 0 + } + ], + "Netzbezug": 0, + "Timestamp": 1710000000 +} +``` + +Rueckgabe an Untermanager: + +```json +{ + "Timestamp": 1710000000, + "Is_Peak_Shaving": false, + "User": [ + { + "InstanceID": 12345, + "Set_Leistung": 1500 + } + ] +} +``` + +Wenn Daten aelter als 30 Sekunden sind, setzt der Hauptmanager fuer diese Unteranlage eine sichere Rueckgabe mit leerer User-Liste. + +## 7. Verbraucher-Module + +### 7.1 Ladestationen + +#### `Ladestation_v2` + +Das wichtigste Ladestationsmodul. Es kann fachlich bereits viele Elemente eines spaeteren OCPP-orientierten Moduls abbilden, aber aktuell noch mit herstellerspezifischer Logik direkt im Modul. + +Unterstuetzte Typen laut Formular: + +- Go-E Wallbox alte Version +- Go-E Gemini/Gemini Flex +- Smart-Me Pico +- Dummy Station +- Easee nur Solarladen +- Easee + +Zentrale Eigenschaften: + +- `IP_Adresse`, `ID`, `Seriennummer`, `Username`, `Password` +- `Token_Easee`, `Token_ECarUp` +- `Max_Current_abs` +- `Ein_Zeit`, `Aus_Zeit` +- `Mindestaldestrom` als Variable mit Action + +Zentrale Betriebsvariablen: + +- `Ladebereit`: Freigabe. +- `Solarladen`: PV-Lademodus. +- `Car_detected`: Fahrzeug erkannt. +- `Car_is_full`: Fahrzeug voll. +- `Is_1_ph`: erkannter 1-phasiger Betrieb. +- `Max_Current`: intern berechneter Maximalstrom. +- `Fahrzeugstatus`: herstellerspezifischer Status. +- `Ladeleistung_Effektiv`: gemessene Ladeleistung. +- `Pending_Counter`: Zaehler fuer ausbleibende Ist-Leistung. +- `IsTimerActive`, `IsTimerActive_Null_Timer`: Mindestlaufzeit-/Null-Timer. +- `Letzer_User`: Benutzerbezug fuer Easee/ECarUp-Logik. + +Wichtige Funktionen: + +- `Detect_Car()`: Fahrzeugerkennung je Stationstyp. +- `Get_Car_Status()`: Statusabfrage je Stationstyp. +- `Calc_Max_Current()`: dynamische Ermittlung des maximalen Stroms aus effektiver Ladeleistung. +- `Get_Current_From_Power()`: Umrechnung W -> A fuer 1p/3p. +- `Get_Array_From_Current()`: erzeugt moegliche Leistungsstufen aus Stromstufen. +- `SetAktuelle_Leistung()`: setzt EMS-Vorgabe und sendet Stromvorgabe an Station. +- `GetCurrentData()`: erzeugt `PowerSteps` je Modus, Freigabe, Fahrzeugzustand und Timer. +- `sendPowerToStation()`: setzt Strom ueber Go-E, Smart-Me, Easee oder Dummy. +- `Refresh_Token()`: Easee Login und Token-Pufferung. + +#### `Ladestation_Universal` + +Aelteres, einfacheres Ladestationsmodul. Es arbeitet staerker mit Leistungsgrenzen in W statt mit dynamischer Strom-/Fahrzeuglogik. + +Zentrale Unterschiede zu `Ladestation_v2`: + +- Konfiguriert `MinLeistung`, `MaxLeistung`, `MinLeistung_1ph`, `MaxLeistung_1ph`. +- Hat `Lademodus` statt `Is_1_ph`. +- Unterstuetzt Go-E, Smart-Me Pico und Dummy. +- Weniger ausgebaute Timer-/Pending-/Tokenlogik. + +### 7.2 Batterie + +Datei: `Batterie/module.php` + +Das Batteriemodul ist bidirektional im Sinne des Managers: Es erzeugt positive und negative `PowerSteps`. + +Eigenschaften: + +- `MaxBatterieleistung`: Variable fuer maximale Entladeleistung. +- `MaxNachladen`: Variable fuer maximale Ladeleistung. +- `Batterieladezustand`: SoC-Variable. +- `AufdasNachladen`: Zielwert fuer Nachladen. +- `MinimumEntladen`: untere SoC-Grenze. +- `Batteriemanagement`: 1 = Wechselrichter, 2 = Symcon EMS. +- `Batterietyp`: 1 = Goodwe, 2 = Solaredge, 3 = Sig Energy. +- `Netzbezug`: Netzbezug fuer Sonderlogik. + +Besonderheiten: + +- `GeneratePowerSteps()` erzeugt ein Raster aus groben 250-W-Schritten und feinen 50-W-Schritten um die aktuelle Leistung. +- Im Wechselrichter-Modus (`Batteriemanagement = 1`) meldet das Modul nur `[0]`. +- Im EMS-Modus werden je nach SoC, Hysterese und Modus Lade-/Entladestufen freigegeben. +- `SetAktuelle_Leistung()` schreibt je Batterietyp andere Steuerwerte: + - Goodwe: `Goodwe_EntLadeleistung`, `Laden_Entladen`. + - Solaredge: `Ladeleistung`, `Entladeleistung`, `Laden_Entladen`. + - Sig Energy: Leistung in kW und andere Moduswerte. + +### 7.3 Boiler + +#### `Boiler_2_Stufig_Mit_Fueler` + +Boiler mit zwei Schaltkontakten: + +- `Kontakt_Teillast` +- `Kontakt_Volllast` + +Leistungsstufen: + +```json +[0, BoilerLeistungTeillast, BoilerLeistungVolllast] +``` + +Die `PowerSteps` werden aus Temperatur, Mindesttemperatur, Maximaltemperatur, Legionellenlogik, Zeitplan und Modus erzeugt. + +#### `Boiler_x_Stufig` + +Boiler mit frei konfigurierbarer Liste `LeistungsStufen`. Jede Stufe hat: + +- `Stufe` +- `Leistung` +- `Schaltkontakt_Stufe` + +Das Modul erzeugt daraus ein sortiertes Leistungsarray und schaltet genau die passende Stufe. + +#### `Ansteuerung_Askoheat` + +Askoheat-Modul mit 7-stufiger Leistungslogik: + +- `BoilerLeistung` ist die Maximalleistung. +- `Calc_Seven_Steps()` erzeugt 8 Werte von 0 bis Volllast. +- `SetAktuelle_Leistung()` schreibt eine Stufe in `Variable_Leistung`. +- Temperatur kommt aus `Variable_Temperatur_Ist`. + +### 7.4 Pufferspeicher + +#### `Pufferspeicher` + +Puffer mit zwei festen Leistungsstufen: + +- `PufferTeilLeistung` +- `PufferLeistung` + +Schaltkontakte: + +- `Heizkontakt_Puffer_Teillast` +- `Heizkontakt_Puffer` + +Die Zieltemperatur wird als lineare Funktion der Aussentemperatur berechnet: + +```text +VT = f(AT) +``` + +mit den Parametern: + +- `MinVT_Temp` +- `MaxVT_Temp` +- `MinAT_Temp` +- `MaxAT_Temp` + +Im Peak-Modus meldet der Puffer normalerweise `[0]`. Im Solarlade-Modus gibt er Stufen frei, wenn die Puffertemperatur unter dem berechneten Zielwert liegt. + +#### `Puffer_Speicher` + +Variante mit frei konfigurierbaren Leistungsstufen analog zu `Boiler_x_Stufig`. + +### 7.5 Einfache und externe Verbraucher + +#### `Verbraucher_1_Stufig` + +Ein Ein/Aus-Verbraucher mit: + +- `BoilerLeistung` +- `Schaltkontakt1` +- `Mindesttlaufzeit` +- Nachtlogik 22:00 bis 07:00 +- `DailyOnTime` +- Mindestzeit zwischen Zustandswechseln + +Im Solarlade-Modus kann er `[0, BoilerLeistung]` melden. Bei Nacht und nicht erreichter Mindestlaufzeit kann er Leistung erzwingen. + +#### `Verbraucher_Sperrbar` + +Ein Verbraucher, der im Peak-Fall gesperrt werden kann. + +Eigenschaften: + +- `Leistung`: Variable mit aktueller Verbraucherleistung. +- `Schaltkontakt1`: Sperrkontakt. +- `Mindestsperrleistung`: Mindestwert, ab dem Sperre relevant wird. +- `MaxSperrZeit`: maximale Sperrzeit pro Tag. + +Im Peak-Modus meldet das Modul je nach aktueller Leistung `[0, Letzte_Sperrleistung]`. Im Solarlade-Modus meldet es `[0]`. + +#### `Verbraucher_extern` + +Adapter fuer mehrere externe Verbraucher. Es liest eine Liste von Paaren: + +- `Read_Var`: anfragende/aktive Variable. +- `Write_Var`: zu schaltende Variable. +- `P_Nenn`: Nennleistung. + +Aus aktiven Einzelverbrauchern werden alle moeglichen Leistungskombinationen berechnet. Bei einer Manager-Zuweisung wird eine passende Kombination gesucht und die zugehoerigen `Write_Var` geschaltet. + +### 7.6 Waermepumpe + +Datei: `WP_Steuerung/module.php` + +Die Waermepumpe nutzt: + +- `Sperrkontakt` +- `Kontakt_Erhoeung` +- `WP_Leistung` +- Wetter-/Wolkenvariable +- Aussentemperatur +- Referenzzeit fuer Sonnenaufgang +- optionale Warmwasser-Schwellwertlogik + +Sie hat interne Zustaende: + +- `Zustand_WP = 1`: Normalbetrieb. +- `Zustand_WP = 2`: Sperre. +- `Zustand_WP = 3`: Erhoehung. + +`GetCurrentData()` meldet normalerweise `[0, WP_Leistung]`, kann aber bei Mindestlaufzeit oder Warmwasser-Schwellwert auf einen festen Zustand begrenzen. + +## 8. Hilfs- und Integrationsmodule + +### 8.1 `CC100_HW` + +Dieses Modul greift direkt auf Linux-Dateipfade eines CC100-Systems zu: + +- Digitale Ausgaenge: `/sys/kernel/dout_drv/DOUT_DATA` +- Digitale Eingaenge: `/sys/devices/platform/soc/44009000.spi/spi_master/spi0/spi0.0/din` +- PT-Fuehler: `/sys/bus/iio/devices/iio:device2/...` + +Es stellt bereit: + +- `Bit1` bis `Bit4` als digitale Ausgaenge. +- `DI1` bis `DI8` als digitale Eingaenge. +- `PT1`, `PT2` als Temperaturwerte. + +Dieses Modul kann als Hardware-Abstraktion fuer Schaltkontakte und Sensorwerte dienen. + +### 8.2 `Shelly_Parser_MQTT` + +Das Shelly-Modul verbindet sich mit der IP-Symcon MQTT-Server-Instanz und subscribed auf `#`. + +Es verarbeitet: + +- `/online` +- `/events/rpc` + +Pro Shelly-Geraet wird ein Kategorieordner angelegt. Darin werden Variablen erzeugt: + +- `Online` +- `Typ` +- `Output x` +- `Input x` +- `Temperatur` + +Outputs erhalten ein gemeinsames Action-Script. Beim Schalten wird ein RPC `Switch.Set` an `/rpc` publiziert. + +Die Parserlogik liegt in `Shelly_Parser_MQTT/libs/ShellyParser.php`. + +### 8.3 `Belevo_Server_Kommunikation` + +Aufgaben: + +- Wetterdaten von `https://brain.belevo.ch/v2wetter` abfragen. +- `Temperatur` und `Wolkenwarscheinlichkeit` setzen. +- Zusatzvariablen als JSON sammeln. +- Daten an `BaseURL`, standardmaessig `https://brain.belevo.ch/storedata`, senden. + +Die Aufzeichnung wird ueber `InfluxJaNein` aktiviert. Der Timer laeuft dann alle 5 Minuten. + +### 8.4 `Energy_Pie` + +Visualisierungsmodul fuer Produktions-/Verbrauchsdaten. + +Konfigurierte Quellvariablen: + +- `VarProduction` +- `VarConsumption` +- `VarFeedIn` +- `VarGrid` + +Das Modul liest geloggte Archivwerte, berechnet Differenzen fuer Tag, Woche, Monat, Jahr oder Total und schiebt ein JSON an das HTML-Tile `module.html`. + +### 8.5 `Belevo_Bezahl_Modul` + +Experimentelles Zahlungsmodul fuer Google Pay/Stripe. Es schreibt HTML in eine konfigurierte HTMLBox. + +Aktueller Zustand: + +- enthaelt einen Stripe Test-Publishable-Key. +- Backend-Pfad ist Platzhalter (`/path-to-your-backend.php`). +- keine produktive Zahlungslogik im Modul. + +Dieses Modul sollte nicht als produktionsreif betrachtet werden. + +## 9. Neue Module an den Energiemanager anbinden + +### 9.1 Entscheidung: Verbraucher, Manager, Hilfsmodul oder Visualisierung? + +Vor der Umsetzung klaeren: + +- Muss das Modul vom Energiemanager Leistung zugeteilt bekommen? Dann ist es ein Verbraucher. +- Liefert es nur Daten oder Schaltkontakte? Dann ist es ein Hilfsmodul. +- Soll es mehrere Verbraucher aggregieren? Dann ist es ein Adapter oder Manager-nahes Modul. +- Zeigt es nur Daten an? Dann ist es eine Visualisierung. + +Nur Verbraucher muessen den vollen Manager-Vertrag umsetzen. + +### 9.2 Minimaler Verbraucher-Aufbau + +Ein neues Verbraucher-Modul sollte in `Create()` mindestens registrieren: + +```php +$this->RegisterVariableInteger("Sperre_Prio", "Sperre_Prio"); +$this->RegisterVariableInteger("PV_Prio", "PV_Prio"); +$this->RegisterVariableBoolean("Idle", "Idle", "", true); +$this->RegisterVariableInteger("Aktuelle_Leistung", "Aktuelle_Leistung", "", 0); +$this->RegisterVariableFloat("Bezogene_Energie", "Bezogene_Energie", "", 0); +$this->RegisterVariableString("PowerSteps", "PowerSteps"); +$this->RegisterVariableInteger("Power", "Power", "", 0); +$this->RegisterVariableBoolean("Is_Peak_Shaving", "Is_Peak_Shaving", "", false); +$this->RegisterVariableInteger("Leistung_Delta", "Leistung_Delta", "", 0); +$this->RegisterPropertyInteger("IdleCounterMax", 2); +$this->RegisterVariableInteger("IdleCounter", "IdleCounter", "", 0); +$this->RegisterPropertyInteger("Interval", 5); +$this->RegisterTimer( + "Timer_Do_UserCalc", + $this->ReadPropertyInteger("Interval") * 1000, + "IPS_RequestAction(" . $this->InstanceID . ', "Do_UserCalc", "");' +); +``` + +`RequestAction()` sollte mindestens diese Faelle enthalten: + +```php +public function RequestAction($Ident, $Value) +{ + switch ($Ident) { + case "SetAktuelle_Leistung": + $this->SetValue("Power", (int)$Value); + break; + + case "GetCurrentData": + $this->SetValue("Is_Peak_Shaving", (bool)$Value); + break; + + case "Do_UserCalc": + $this->SetAktuelle_Leistung($this->GetValue("Power")); + $this->GetCurrentData($this->GetValue("Is_Peak_Shaving")); + break; + + default: + throw new Exception("Invalid Ident"); + } +} +``` + +### 9.3 `GetCurrentData()` richtig implementieren + +`GetCurrentData(bool $Peak)` muss: + +1. den aktuellen Betriebsmodus beruecksichtigen. +2. interne Sensoren/Statuswerte aktualisieren. +3. die erlaubten Leistungsstufen als JSON in `PowerSteps` schreiben. +4. `Leistung_Delta` setzen, falls Soll und Ist abweichen. +5. bei Mindestlaufzeiten oder Sperrzeiten nur die aktuelle Leistung melden. + +Beispiel: + +```php +public function GetCurrentData(bool $Peak) +{ + $this->SetValue("Is_Peak_Shaving", $Peak); + + if (!$this->GetValue("Idle")) { + $this->SetValue("PowerSteps", json_encode([$this->GetValue("Aktuelle_Leistung")])); + return; + } + + if ($Peak) { + $steps = [0, 1000]; + } else { + $steps = [0, 1000, 2000, 3000]; + } + + $this->SetValue("PowerSteps", json_encode($steps)); + $this->SetValue("Leistung_Delta", 0); +} +``` + +### 9.4 `SetAktuelle_Leistung()` richtig implementieren + +`SetAktuelle_Leistung()` muss: + +1. Aenderung erkennen und `IdleCounter` setzen. +2. Die physische Ansteuerung durchfuehren. +3. `Aktuelle_Leistung` setzen. +4. `Bezogene_Energie` fortschreiben. +5. `IdleCounter` weiterverarbeiten. + +Beispiel: + +```php +public function SetAktuelle_Leistung(int $power) +{ + $lastPower = $this->GetValue("Aktuelle_Leistung"); + + if ($lastPower !== $power) { + $this->SetValue("Idle", false); + $this->SetValue("IdleCounter", $this->ReadPropertyInteger("IdleCounterMax")); + } + + // Hier Geraet ansteuern, z.B. Relais, HTTP API oder Wechselrichtervariable. + // SetValue($this->ReadPropertyInteger("Schaltkontakt"), $power > 0); + + $this->SetValue("Aktuelle_Leistung", $power); + $this->SetValue( + "Bezogene_Energie", + $this->GetValue("Bezogene_Energie") + ($power * ($this->ReadPropertyInteger("Interval") / 3600)) + ); + + $this->ProcessIdleCounter(); +} +``` + +### 9.5 Checkliste fuer neue Verbraucher + +- `module.json` mit neuer GUID, Name, Vendor und Prefix erstellen. +- `form.json` mit allen benoetigten Einstellungen erstellen. +- Alle Pflichtvariablen des Verbraucher-Vertrags registrieren. +- `RequestAction` mit `SetAktuelle_Leistung`, `GetCurrentData`, `Do_UserCalc` implementieren. +- `PowerSteps` immer als gueltiges JSON-Array schreiben. +- `PowerSteps` immer numerisch sortierbar halten. +- `0` als Stufe anbieten, wenn das Geraet wirklich ausgeschaltet/neutral sein darf. +- Negative Stufen nur verwenden, wenn das Modul Einspeisung/Entladung sauber unterstuetzt. +- `Idle` bei Schaltvorgaengen und Mindestlaufzeiten korrekt auf `false` setzen. +- Geraetefehler und Kommunikationsfehler abfangen. +- Bei Fehlern sichere `PowerSteps` setzen, meist `[0]` oder aktuelle Leistung. +- Im Manager unter `Verbraucher_Liste` eintragen. + +## 10. Empfehlungen fuer zukuenftige Verbesserungen + +### 10.1 Gemeinsame Basisklasse oder Trait fuer Verbraucher + +Der gleiche EMS-Vertrag ist in fast allen Verbrauchern mehrfach implementiert. Sinnvoll waere eine gemeinsame Datei, zum Beispiel: + +- `libs/EmsConsumerTrait.php` +- `libs/EmsPowerStepHelper.php` + +Diese koennte bereitstellen: + +- Registrierung der Standardvariablen. +- Einheitliche `IdleCounter`-Logik. +- Energiezaehlung. +- JSON-Validierung fuer `PowerSteps`. +- Hilfen fuer Sortierung und Minimalstufen. + +### 10.2 Manager-Zyklus robuster machen + +Im aktuellen `Manager::DistributeEnergy()` werden Verbraucherdaten gelesen und danach `GetCurrentData` aufgerufen. Dadurch kann der Manager im selben Zyklus noch alte `PowerSteps` verwenden, sofern die Verbraucher nicht ohnehin ueber ihren eigenen Timer aktuell sind. + +Robuster waere: + +1. Alle Verbraucher per `GetCurrentData($Is_Peak_Shaving)` aktualisieren. +2. Danach `PowerSteps`, `Idle`, `Leistung_Delta` neu lesen. +3. Dann verteilen. + +Alternativ koennte `GetCurrentData()` synchron Daten zurueckgeben statt nur Variablen zu schreiben. + +### 10.3 Validierung und Fail-Safe + +Viele Properties sind Variable-IDs. Hauefig ist `0` Default. Vor `GetValue(0)` oder `SetValue(0)` sollte konsequent geprueft werden: + +```php +$id = $this->ReadPropertyInteger("Netzbezug"); +if ($id <= 0 || !IPS_VariableExists($id)) { + $this->SetStatus(201); // oder eigener Fehlerstatus + return; +} +``` + +Empfehlung: + +- Konfigurationspruefung in `ApplyChanges()`. +- Statuscodes fuer unvollstaendige Konfiguration. +- sichere PowerSteps bei Kommunikationsfehlern. + +### 10.4 Einheitliche Logging- und Diagnosevariablen + +Aktuell gibt es viele `IPS_LogMessage()`-Eintraege, teils sehr haeufig in Schleifen. Besser waeren: + +- `Letzter_Fehler` als String. +- `Letzte_Aktion` als String. +- `Letzte_Kommunikation` als Timestamp. +- `Kommunikation_OK` als Boolean. +- Debug-Logging nur optional per Property. + +### 10.5 Ladestation v3 / OCPP-orientierte Architektur + +`Ladestation_v2` enthaelt bereits viele Funktionen, die fuer ein OCPP-Modul relevant sind: + +- Freigabe +- Solarladen +- Fahrzeugerkennung +- Statusabfrage +- effektive Ladeleistung +- Stromvorgabe +- Mindeststrom +- 1p/3p-Erkennung +- externe Leistungszuweisung +- PowerSteps +- Peak-Shaving-Einfluss +- Idle-/Pending-/Timer-Logik +- Token-/Login-Mechanismen + +Fuer eine neue OCPP-orientierte Version sollte diese Logik fachlich erhalten bleiben, aber in Adapter aufgeteilt werden: + +- `ChargePointModel`: internes Datenmodell. +- `ChargingSession`: Ladevorgang, Benutzer, Energie. +- `MeterValues`: Messwerte. +- `SmartCharging`: Sollstrom/Sollleistung. +- `ConnectorState`: Status pro Ladepunkt. +- `VendorAdapterInterface`: Herstelleradapter. +- `Ocpp16Adapter`, `Ocpp201Adapter`, `GoEAdapter`, `EaseeAdapter`, `SmartMeAdapter`, `ECarUpAdapter`. + +Die EMS-Schnittstelle zum Manager sollte gleich bleiben: `PowerSteps`, `SetAktuelle_Leistung`, `GetCurrentData`, `Idle`. + +### 10.6 Tests und Simulation + +Das Projekt hat aktuell keine automatisierten Tests. Sinnvoll waere ein Simulationsmodus: + +- Fake-Verbraucher mit frei definierbaren `PowerSteps`. +- Fake-Netzbezug. +- Testfaelle fuer Solarladen und Peak-Shaving. +- Testfaelle fuer Verbraucher mit und ohne `0`. +- Testfaelle fuer negative PowerSteps. +- Testfaelle fuer gleiche Prioritaet/Fairness. +- Testfaelle fuer Hauptmanager-JSON. + +## 11. Bekannte technische Risiken und Auffaelligkeiten + +Diese Punkte sind aus dem aktuellen Code abgeleitet und sollten vor groesseren Erweiterungen geprueft werden: + +- Mehrere Dateien zeigen Encoding-Probleme in deutschen Texten. Das deutet auf UTF-8/ANSI-Mischung hin. +- `Manager::DistributeEnergy()` aktualisiert Verbraucher mit `GetCurrentData`, liest aber vorher bereits viele Verbraucherdaten. Das kann zu veralteten `PowerSteps` fuehren. +- Viele Module pruefen konfigurierte Variablen-IDs nicht konsequent vor `GetValue()` oder `SetValue()`. +- `Ansteuerung_Askoheat` registriert `Boilertemperatur` doppelt, einmal Float und spaeter Integer. +- `Boiler_x_Stufig` nutzt in der Zeitplanpruefung offenbar `$vollLeistung`, ohne diese Variable vorher eindeutig zu setzen. +- `Ladestation_v2::GetCurrentData()` verwendet in einem Zweig fuer `solarladen && Peak` die Variable `$is_1_ph`, die dort nicht definiert ist. Gemeint ist wahrscheinlich `$this->GetValue("Is_1_ph")`. +- `Pufferspeicher` prueft in der Glaettungslogik teilweise `GetIDForIdent("Boilertemperatur")`, obwohl die Variable `Puffertemperatur` heisst. +- `Batterie::CheckIdle()` verwendet `GetValue("Aktuelle_Leistung")` mit String statt Variablen-ID. Die Funktion scheint aktuell nicht zentral genutzt zu werden, sollte aber korrigiert werden. +- `Verbraucher_Sperrbar::ist_nachts()` nutzt `24:00` als Startzeit. Das ist fachlich ungewoehnlich und sollte geprueft werden. +- Mehrere Module setzen `date_default_timezone_set("Europe/Berlin")`; fuer Schweizer Anlagen waere `Europe/Zurich` konsistenter. +- `Belevo_Bezahl_Modul` ist nur Prototyp/Teststand und enthaelt noch Platzhalter. +- Logging in Schleifen kann im Dauerbetrieb sehr viele Meldungen erzeugen. + +## 12. Arbeitsanweisung fuer ChatGPT/Codex bei zukuenftigen Aenderungen + +Wenn ChatGPT/Codex spaeter an dieser Software weiterarbeitet, sollte es so vorgehen: + +1. Zuerst `DOKUMENTATION_CHATGPT.md`, `README.md`, `Manager/module.php` und das betroffene Modul lesen. +2. Vor jeder Aenderung pruefen, ob das Modul ein Verbraucher ist und den EMS-Vertrag einhalten muss. +3. Bestehende Variablen-Idents nicht ohne Migrationsplan umbenennen, weil IP-Symcon Installationen davon abhaengen. +4. Neue Module mit eigenem Ordner, `module.php`, `module.json`, `form.json` anlegen. +5. Bei neuen Verbrauchern die Pflichtvariablen exakt gleich benennen. +6. `PowerSteps` und `Leistung_Delta` besonders sorgfaeltig testen. +7. Bei Hardware/API-Modulen immer Fehlerfaelle abfangen und sichere Werte setzen. +8. Keine bestehenden Benutzer- oder Anlagenkonfigurationen brechen. +9. Bei Ladestationen die EMS-Schnittstelle stabil halten, auch wenn intern OCPP oder Herstelleradapter eingefuehrt werden. +10. Nach Aenderungen mindestens PHP-Syntax pruefen und, wenn moeglich, mit einer simulierten Manager-Konfiguration testen. + +## 13. Kurzreferenz fuer neue ChatGPT-Prompts + +Wenn ein neues ChatGPT-Fenster mit dieser Software weiterarbeiten soll, kann folgender Kontext verwendet werden: + +```text +Dies ist eine IP-Symcon Modulbibliothek fuer das Belevo Energiemanagement. +Der zentrale Manager verteilt Leistung an Verbraucher ueber einen gemeinsamen Vertrag: +Variablen: Sperre_Prio, PV_Prio, Idle, Aktuelle_Leistung, Bezogene_Energie, +PowerSteps, Power, Is_Peak_Shaving, Leistung_Delta, IdleCounter. +Actions: GetCurrentData(bool Peak), SetAktuelle_Leistung(power), Do_UserCalc. +PowerSteps ist ein JSON-Array moeglicher Leistungsstufen in Watt. +Positive Werte bedeuten Verbrauch/Laden, negative Werte Entladen/Einspeisen. +Der Manager entscheidet zwischen Solarladen und Peak-Shaving und verteilt nach Prioritaet. +Neue steuerbare Module muessen diesen Vertrag exakt umsetzen. +Wichtige Dateien: Manager/module.php, HauptManager/module.php, +Ladestation_v2/module.php, Batterie/module.php, DOKUMENTATION_CHATGPT.md. +``` diff --git a/Ladestation_OCPP/README.md b/Ladestation_OCPP/README.md new file mode 100644 index 0000000..c4f43b6 --- /dev/null +++ b/Ladestation_OCPP/README.md @@ -0,0 +1,65 @@ +# Ladestation_OCPP + +`Ladestation_OCPP` ist das neue OCPP-orientierte Ladestationsmodul fuer das Belevo/Enelix Energiemanagement in IP-Symcon. + +Status dieser Version: **M1 Scaffold**. Das Modul ist installierbar, haelt den bestehenden EMS-Vertrag ein und stellt die OCPP-Zielarchitektur bereit. Der produktive OCPP-WebSocket-Dauerbetrieb ist bewusst noch als Transport-Spike ueber `OCPP_Server` gekennzeichnet. + +## Ziel + +- OCPP-only Architektur fuer Ladestationen. +- Keine Hersteller-HTTP-APIs, keine Cloud-Token, keine direkte Kopie von `Ladestation_v2`. +- Fachliche Paritaet zu `Ladestation_v2` als Zielbild. +- Stabile Anbindung an den bestehenden `Manager` ueber `PowerSteps`, `GetCurrentData`, `SetAktuelle_Leistung` und `Do_UserCalc`. + +## Manager-Anbindung + +Das Modul ist aus Sicht des Energiemanagers ein Verbraucher. Es legt die Pflichtvariablen mit exakt diesen Idents an: + +- `Sperre_Prio` +- `PV_Prio` +- `Idle` +- `Aktuelle_Leistung` +- `Bezogene_Energie` +- `PowerSteps` +- `Power` +- `Is_Peak_Shaving` +- `Leistung_Delta` +- `IdleCounter` + +Pflicht-Actions: + +- `GetCurrentData(bool Peak)` +- `SetAktuelle_Leistung(power)` +- `Do_UserCalc()` + +## Phase-1-Modi + +Diese Modi sind im Scaffold sichtbar und in der PowerStep-Berechnung vorbereitet: + +- `0` = Nie laden +- `1` = Immer laden +- `2` = Konstanter Strom +- `3` = Nur Solar + +Weitere Modi sind als Architektur vorbereitet, aber noch nicht produktiv ausgearbeitet. + +## OCPP-Struktur + +Die OCPP-Klassen liegen in `Ladestation_OCPP/libs/`: + +- Nachrichtenmodell: `OCPPMessage`, `OCPPTransport` +- Versionsadapter: `OCPP16Adapter`, `OCPP201Adapter`, `OCPP21Adapter` +- Fachlogik: `PowerStepCalculator`, `ChargingProfileBuilder`, `MeterValueNormalizer`, `TransactionStore` +- Sicherheit/Diagnose: `FailSafeManager`, `Diagnostics`, `CapabilityModel`, `DataTransferRegistry`, `PhaseManager` + +## Transport + +OCPP braucht eine WebSocket-Verbindung zwischen Ladestation und CSMS. In dieser Architektur uebernimmt Symcon die CSMS-Rolle. Der Transport ist getrennt im Modul `OCPP_Server` vorbereitet. + +Wichtig: Der erste Commit ist kein vollstaendiger produktiver OCPP-CSMS. Er dokumentiert und kapselt den Transport-Spike, damit echte Stationstests gezielt folgen koennen. + +## Dokumentation + +Die fachliche und technische Gesamtdokumentation liegt unter: + +`docs/OCPP/README.md` diff --git a/Ladestation_OCPP/form.json b/Ladestation_OCPP/form.json new file mode 100644 index 0000000..8c8d20f --- /dev/null +++ b/Ladestation_OCPP/form.json @@ -0,0 +1,125 @@ +{ + "elements": [ + { + "type": "Label", + "caption": "OCPP-only Ladestationsmodul. Phase 1 ist ein EMS-faehiger Scaffold mit OCPP-Transport-Spike." + }, + { + "type": "Select", + "name": "OCPPVersionMode", + "caption": "OCPP Version", + "options": [ + { "caption": "Automatisch", "value": "auto" }, + { "caption": "OCPP 1.6", "value": "1.6" }, + { "caption": "OCPP 2.0.1", "value": "2.0.1" }, + { "caption": "OCPP 2.1 vorbereitet", "value": "2.1" } + ] + }, + { + "type": "ValidationTextBox", + "name": "ChargePointId", + "caption": "ChargePointId" + }, + { + "type": "NumberSpinner", + "name": "EVSEId", + "caption": "EVSE ID" + }, + { + "type": "NumberSpinner", + "name": "ConnectorId", + "caption": "Connector ID" + }, + { + "type": "SelectInstance", + "name": "OCPPServerInstance", + "caption": "OCPP Server Instanz", + "test": true + }, + { + "type": "NumberSpinner", + "name": "MinCurrent", + "caption": "Mindeststrom", + "suffix": "A" + }, + { + "type": "NumberSpinner", + "name": "MaxCurrentAbs", + "caption": "Maximalstrom Installation", + "suffix": "A" + }, + { + "type": "NumberSpinner", + "name": "SafeCurrent", + "caption": "SafeCurrent", + "suffix": "A" + }, + { + "type": "Select", + "name": "SafeOffStrategy", + "caption": "Fail-Safe Strategie", + "options": [ + { "caption": "0 A", "value": "0A" }, + { "caption": "SafeCurrent", "value": "SafeCurrent" }, + { "caption": "Letzten Wert begrenzen", "value": "LastKnown" }, + { "caption": "Unavailable", "value": "Unavailable" } + ] + }, + { + "type": "CheckBox", + "name": "AllowAutomaticPhaseSwitch", + "caption": "Automatische Phasenumschaltung erlauben" + }, + { + "type": "CheckBox", + "name": "AllowDataTransfer", + "caption": "OCPP DataTransfer erlauben" + }, + { + "type": "NumberSpinner", + "name": "Interval", + "caption": "Regelintervall", + "suffix": "Sekunden" + }, + { + "type": "NumberSpinner", + "name": "IdleCounterMax", + "caption": "Zyklen bis Idle" + }, + { + "type": "NumberSpinner", + "name": "EMSWatchdogSeconds", + "caption": "EMS Watchdog", + "suffix": "Sekunden" + }, + { + "type": "NumberSpinner", + "name": "OCPPHeartbeatTimeoutSeconds", + "caption": "OCPP Heartbeat Timeout", + "suffix": "Sekunden" + }, + { + "type": "NumberSpinner", + "name": "CommandAckTimeoutSeconds", + "caption": "Command ACK Timeout", + "suffix": "Sekunden" + }, + { + "type": "NumberSpinner", + "name": "DebugLevel", + "caption": "Debug Level" + } + ], + "actions": [ + { + "type": "Button", + "caption": "Reset Diagnose", + "onClick": "IPS_RequestAction($id, \"Reset\", \"\");" + }, + { + "type": "Button", + "caption": "Clear ChargingProfile", + "onClick": "IPS_RequestAction($id, \"ClearChargingProfile\", \"\");" + } + ] +} diff --git a/Ladestation_OCPP/libs/CapabilityModel.php b/Ladestation_OCPP/libs/CapabilityModel.php new file mode 100644 index 0000000..71ae807 --- /dev/null +++ b/Ladestation_OCPP/libs/CapabilityModel.php @@ -0,0 +1,62 @@ + false, + 'meterValues' => false, + 'phaseMetering' => false, + 'currentImportPerPhase' => false, + 'voltagePerPhase' => false, + 'powerImport' => false, + 'powerExport' => false, + 'energyImport' => false, + 'energyExport' => false, + 'numberPhases' => false, + 'phaseToUse' => false, + 'phaseSwitching' => false, + 'dataTransfer' => false, + 'getVariables' => false, + 'setVariables' => false, + 'transactionEvent' => false, + 'soc' => false, + 'temperature' => false, + 'signedMeterValue' => false, + 'bidirectional' => false + ]; + } + + public static function detectFromBoot(string $version, array $payload): array + { + $capabilities = self::defaults(); + $capabilities['meterValues'] = true; + + if ($version === '2.0.1' || $version === '2.1') { + $capabilities['getVariables'] = true; + $capabilities['setVariables'] = true; + $capabilities['transactionEvent'] = true; + $capabilities['smartCharging'] = true; + } + + if ($version === '2.1') { + $capabilities['bidirectional'] = true; + $capabilities['powerExport'] = true; + $capabilities['energyExport'] = true; + } + + if (isset($payload['chargingStation']['model']) || isset($payload['chargePointModel'])) { + $capabilities['dataTransfer'] = true; + } + + return $capabilities; + } + + public static function merge(array $base, array $updates): array + { + return array_replace($base, array_intersect_key($updates, $base)); + } +} + +?> diff --git a/Ladestation_OCPP/libs/ChargingProfileBuilder.php b/Ladestation_OCPP/libs/ChargingProfileBuilder.php new file mode 100644 index 0000000..7b17862 --- /dev/null +++ b/Ladestation_OCPP/libs/ChargingProfileBuilder.php @@ -0,0 +1,51 @@ + (int)($setpoint['connectorId'] ?? 1), + 'csChargingProfiles' => [ + 'chargingProfileId' => (int)(time() % 100000), + 'stackLevel' => 0, + 'chargingProfilePurpose' => 'TxDefaultProfile', + 'chargingProfileKind' => 'Absolute', + 'validTo' => $validTo, + 'chargingSchedule' => [ + 'chargingRateUnit' => 'A', + 'chargingSchedulePeriod' => [ + ['startPeriod' => 0, 'limit' => $currentA] + ] + ] + ] + ]; + } + + return [ + 'evseId' => (int)($setpoint['evseId'] ?? 1), + 'chargingProfile' => [ + 'id' => (int)(time() % 100000), + 'stackLevel' => 0, + 'chargingProfilePurpose' => 'TxDefaultProfile', + 'chargingProfileKind' => 'Absolute', + 'validTo' => $validTo, + 'chargingSchedule' => [ + [ + 'id' => 1, + 'chargingRateUnit' => 'A', + 'chargingSchedulePeriod' => [ + ['startPeriod' => 0, 'limit' => $currentA] + ] + ] + ] + ] + ]; + } +} + +?> diff --git a/Ladestation_OCPP/libs/DataTransferRegistry.php b/Ladestation_OCPP/libs/DataTransferRegistry.php new file mode 100644 index 0000000..e38a41b --- /dev/null +++ b/Ladestation_OCPP/libs/DataTransferRegistry.php @@ -0,0 +1,31 @@ +allowed = $allowed; + $this->entries = $entries; + } + + public function isAllowed(string $vendorId, string $messageId = ''): bool + { + if (!$this->allowed) { + return false; + } + if (empty($this->entries)) { + return true; + } + foreach ($this->entries as $entry) { + if (($entry['vendorId'] ?? '') === $vendorId && (($entry['messageId'] ?? '') === '' || ($entry['messageId'] ?? '') === $messageId)) { + return true; + } + } + return false; + } +} + +?> diff --git a/Ladestation_OCPP/libs/Diagnostics.php b/Ladestation_OCPP/libs/Diagnostics.php new file mode 100644 index 0000000..946808f --- /dev/null +++ b/Ladestation_OCPP/libs/Diagnostics.php @@ -0,0 +1,25 @@ + time(), + 'severity' => $severity, + 'source' => $source, + 'text' => $text, + 'code' => $code + ]; + } + + public static function statusFromFlags(bool $online, bool $fault): string + { + if ($fault) { + return 'Stoerung'; + } + return $online ? 'Online' : 'Offline'; + } +} + +?> diff --git a/Ladestation_OCPP/libs/FailSafeManager.php b/Ladestation_OCPP/libs/FailSafeManager.php new file mode 100644 index 0000000..f1c0e21 --- /dev/null +++ b/Ladestation_OCPP/libs/FailSafeManager.php @@ -0,0 +1,49 @@ + 0 && $emsAge > (int)($state['emsWatchdogSeconds'] ?? 120)) { + $warnings[] = 'EMS watchdog expired'; + $safePowerW = $this->safePower($state); + } + + $heartbeatAge = $now - (int)($state['lastOcppHeartbeat'] ?? 0); + if (($state['ocppOnline'] ?? false) && ($state['lastOcppHeartbeat'] ?? 0) > 0 && $heartbeatAge > (int)($state['ocppHeartbeatTimeoutSeconds'] ?? 90)) { + $warnings[] = 'OCPP heartbeat timeout'; + $blockingReason = 'OCPP timeout'; + } + + if (($state['criticalFault'] ?? false) === true) { + $warnings[] = 'Critical fault'; + $blockingReason = 'Critical fault'; + $safePowerW = 0; + } + + return [ + 'warnings' => $warnings, + 'blockingReason' => $blockingReason, + 'safePowerW' => $safePowerW + ]; + } + + private function safePower(array $state): int + { + $strategy = (string)($state['safeOffStrategy'] ?? 'SafeCurrent'); + if ($strategy === '0A' || $strategy === 'Unavailable') { + return 0; + } + $phases = PhaseManager::normalizePhaseCount((int)($state['numberPhases'] ?? 3)); + $safeCurrent = (float)($state['safeCurrentA'] ?? 6.0); + return PhaseManager::wattsFromCurrent($safeCurrent, $phases); + } +} + +?> diff --git a/Ladestation_OCPP/libs/MeterValueNormalizer.php b/Ladestation_OCPP/libs/MeterValueNormalizer.php new file mode 100644 index 0000000..c0a8300 --- /dev/null +++ b/Ladestation_OCPP/libs/MeterValueNormalizer.php @@ -0,0 +1,157 @@ + null, + 'powerExportW' => null, + 'energyImportWh' => null, + 'energyExportWh' => null, + 'currentA' => [1 => null, 2 => null, 3 => null], + 'voltageV' => [1 => null, 2 => null, 3 => null], + 'powerPhaseW' => [1 => null, 2 => null, 3 => null], + 'soc' => null, + 'temperature' => null, + 'quality' => 'OCPP-MeterValue', + 'timestamp' => time() + ]; + + foreach ($this->extractSamples($payload) as $sample) { + $measurand = (string)($sample['measurand'] ?? 'Energy.Active.Import.Register'); + $value = $this->normalizeValue($sample); + $phase = $this->phaseIndex((string)($sample['phase'] ?? '')); + + switch ($measurand) { + case 'Power.Active.Import': + $this->writePower($result, 'powerImportW', $value, $phase); + break; + case 'Power.Active.Export': + $this->writePower($result, 'powerExportW', $value, $phase); + break; + case 'Energy.Active.Import.Register': + case 'Energy.Active.Import.Interval': + $result['energyImportWh'] = $value; + break; + case 'Energy.Active.Export.Register': + case 'Energy.Active.Export.Interval': + $result['energyExportWh'] = $value; + break; + case 'Current.Import': + if ($phase > 0) { + $result['currentA'][$phase] = $value; + } + break; + case 'Voltage': + if ($phase > 0) { + $result['voltageV'][$phase] = $value; + } + break; + case 'SoC': + $result['soc'] = $value; + break; + case 'Temperature': + $result['temperature'] = $value; + break; + } + + if (isset($sample['timestamp'])) { + $ts = strtotime((string)$sample['timestamp']); + if ($ts !== false) { + $result['timestamp'] = $ts; + } + } + } + + if ($result['powerImportW'] === null) { + $result['powerImportW'] = $this->sumPhases($result['powerPhaseW']); + } + + return $result; + } + + private function extractSamples(array $payload): array + { + $samples = []; + $containers = $payload['meterValue'] ?? $payload['meterValues'] ?? [$payload]; + if (!is_array($containers)) { + return []; + } + + foreach ($containers as $container) { + if (!is_array($container)) { + continue; + } + $timestamp = $container['timestamp'] ?? null; + $sampled = $container['sampledValue'] ?? $container['sampledValues'] ?? []; + if (!is_array($sampled)) { + continue; + } + foreach ($sampled as $sample) { + if (!is_array($sample)) { + continue; + } + if ($timestamp !== null && !isset($sample['timestamp'])) { + $sample['timestamp'] = $timestamp; + } + $samples[] = $sample; + } + } + + return $samples; + } + + private function normalizeValue(array $sample): float + { + $value = (float)($sample['value'] ?? 0); + $unit = $sample['unit'] ?? ($sample['unitOfMeasure']['unit'] ?? ''); + $unit = strtolower((string)$unit); + + if ($unit === 'kwh') { + return $value * 1000.0; + } + if ($unit === 'kw') { + return $value * 1000.0; + } + return $value; + } + + private function phaseIndex(string $phase): int + { + if (stripos($phase, 'L1') !== false) { + return 1; + } + if (stripos($phase, 'L2') !== false) { + return 2; + } + if (stripos($phase, 'L3') !== false) { + return 3; + } + return 0; + } + + private function writePower(array &$result, string $key, float $value, int $phase): void + { + if ($phase > 0) { + $result['powerPhaseW'][$phase] = $value; + return; + } + $result[$key] = $value; + } + + private function sumPhases(array $values): ?float + { + $sum = 0.0; + $found = false; + foreach ($values as $value) { + if ($value !== null) { + $sum += (float)$value; + $found = true; + } + } + return $found ? $sum : null; + } +} + +?> diff --git a/Ladestation_OCPP/libs/OCPP16Adapter.php b/Ladestation_OCPP/libs/OCPP16Adapter.php new file mode 100644 index 0000000..9b03678 --- /dev/null +++ b/Ladestation_OCPP/libs/OCPP16Adapter.php @@ -0,0 +1,31 @@ + '1.6', + 'action' => $message->action, + 'uniqueId' => $message->uniqueId, + 'payload' => $message->payload + ]; + } + + public function buildSetChargingProfile(array $profile): OCPPMessage + { + return OCPPMessage::call('SetChargingProfile', $profile, '1.6'); + } + + public function buildClearChargingProfile(int $connectorId): OCPPMessage + { + return OCPPMessage::call('ClearChargingProfile', ['connectorId' => $connectorId], '1.6'); + } + + public function buildReset(string $type = 'Soft'): OCPPMessage + { + return OCPPMessage::call('Reset', ['type' => $type], '1.6'); + } +} + +?> diff --git a/Ladestation_OCPP/libs/OCPP201Adapter.php b/Ladestation_OCPP/libs/OCPP201Adapter.php new file mode 100644 index 0000000..fec74bd --- /dev/null +++ b/Ladestation_OCPP/libs/OCPP201Adapter.php @@ -0,0 +1,31 @@ + '2.0.1', + 'action' => $message->action, + 'uniqueId' => $message->uniqueId, + 'payload' => $message->payload + ]; + } + + public function buildSetChargingProfile(array $profile): OCPPMessage + { + return OCPPMessage::call('SetChargingProfile', $profile, '2.0.1'); + } + + public function buildClearChargingProfile(int $evseId): OCPPMessage + { + return OCPPMessage::call('ClearChargingProfile', ['evseId' => $evseId], '2.0.1'); + } + + public function buildReset(string $type = 'Immediate'): OCPPMessage + { + return OCPPMessage::call('Reset', ['type' => $type], '2.0.1'); + } +} + +?> diff --git a/Ladestation_OCPP/libs/OCPP21Adapter.php b/Ladestation_OCPP/libs/OCPP21Adapter.php new file mode 100644 index 0000000..d3a2b63 --- /dev/null +++ b/Ladestation_OCPP/libs/OCPP21Adapter.php @@ -0,0 +1,18 @@ + diff --git a/Ladestation_OCPP/libs/OCPPMessage.php b/Ladestation_OCPP/libs/OCPPMessage.php new file mode 100644 index 0000000..571ae03 --- /dev/null +++ b/Ladestation_OCPP/libs/OCPPMessage.php @@ -0,0 +1,126 @@ +version = $version; + $this->direction = $direction; + $this->action = $action; + $this->uniqueId = $uniqueId; + $this->payload = $payload; + $this->timestamp = time(); + $this->chargePointId = $chargePointId; + $this->messageTypeId = $messageTypeId; + } + + public static function fromJson(string $json, string $version = 'auto', string $chargePointId = ''): ?self + { + $frame = json_decode($json, true); + if (!is_array($frame) || count($frame) < 3) { + return null; + } + + $type = (int)($frame[0] ?? 0); + $uniqueId = (string)($frame[1] ?? ''); + + if ($type === 2) { + return new self($version, 'in', (string)($frame[2] ?? ''), $uniqueId, (array)($frame[3] ?? []), $chargePointId, $type); + } + + if ($type === 3) { + return new self($version, 'in_result', 'CallResult', $uniqueId, (array)($frame[2] ?? []), $chargePointId, $type); + } + + if ($type === 4) { + return new self($version, 'in_error', (string)($frame[2] ?? 'CallError'), $uniqueId, [ + 'errorCode' => (string)($frame[2] ?? ''), + 'errorDescription' => (string)($frame[3] ?? ''), + 'errorDetails' => (array)($frame[4] ?? []) + ], $chargePointId, $type); + } + + return null; + } + + public static function call(string $action, array $payload, string $version = 'auto', string $chargePointId = ''): self + { + return new self($version, 'out', $action, self::newUniqueId(), $payload, $chargePointId, 2); + } + + public static function callResult(string $uniqueId, array $payload, string $version = 'auto', string $chargePointId = ''): self + { + return new self($version, 'out_result', 'CallResult', $uniqueId, $payload, $chargePointId, 3); + } + + public static function callError(string $uniqueId, string $code, string $description, array $details = [], string $version = 'auto', string $chargePointId = ''): self + { + return new self($version, 'out_error', $code, $uniqueId, [ + 'errorCode' => $code, + 'errorDescription' => $description, + 'errorDetails' => $details + ], $chargePointId, 4); + } + + public function toFrame(): array + { + if ($this->messageTypeId === 2) { + return [2, $this->uniqueId, $this->action, $this->payload]; + } + if ($this->messageTypeId === 3) { + return [3, $this->uniqueId, $this->payload]; + } + if ($this->messageTypeId === 4) { + return [ + 4, + $this->uniqueId, + (string)($this->payload['errorCode'] ?? $this->action), + (string)($this->payload['errorDescription'] ?? ''), + (array)($this->payload['errorDetails'] ?? []) + ]; + } + return [$this->messageTypeId, $this->uniqueId, $this->action, $this->payload]; + } + + public function toJson(): string + { + return json_encode($this->toFrame()); + } + + public function toArray(): array + { + return [ + 'version' => $this->version, + 'direction' => $this->direction, + 'action' => $this->action, + 'uniqueId' => $this->uniqueId, + 'payload' => $this->payload, + 'timestamp' => $this->timestamp, + 'chargePointId' => $this->chargePointId, + 'messageTypeId' => $this->messageTypeId + ]; + } + + private static function newUniqueId(): string + { + return str_replace('.', '', uniqid('ips-', true)); + } +} + +?> diff --git a/Ladestation_OCPP/libs/OCPPTransport.php b/Ladestation_OCPP/libs/OCPPTransport.php new file mode 100644 index 0000000..eb0fc50 --- /dev/null +++ b/Ladestation_OCPP/libs/OCPPTransport.php @@ -0,0 +1,32 @@ + $targetInstance, + 'ChargePointId' => $message->chargePointId, + 'Version' => $message->version, + 'Frame' => $message->toJson(), + 'Timestamp' => time() + ]; + } + + public static function decodeEnvelope(string $json): array + { + $data = json_decode($json, true); + if (!is_array($data)) { + return []; + } + return [ + 'TargetInstance' => (int)($data['TargetInstance'] ?? 0), + 'ChargePointId' => (string)($data['ChargePointId'] ?? ''), + 'Version' => (string)($data['Version'] ?? 'auto'), + 'Frame' => (string)($data['Frame'] ?? ''), + 'Timestamp' => (int)($data['Timestamp'] ?? time()) + ]; + } +} + +?> diff --git a/Ladestation_OCPP/libs/PhaseManager.php b/Ladestation_OCPP/libs/PhaseManager.php new file mode 100644 index 0000000..f53f40e --- /dev/null +++ b/Ladestation_OCPP/libs/PhaseManager.php @@ -0,0 +1,46 @@ + diff --git a/Ladestation_OCPP/libs/PowerStepCalculator.php b/Ladestation_OCPP/libs/PowerStepCalculator.php new file mode 100644 index 0000000..42c8be9 --- /dev/null +++ b/Ladestation_OCPP/libs/PowerStepCalculator.php @@ -0,0 +1,81 @@ + 0) { + $limitCurrent = min($limitCurrent, (float)$input['groupLimitA']); + } + if (($input['vnbLimitA'] ?? 0) > 0) { + $limitCurrent = min($limitCurrent, (float)$input['vnbLimitA']); + } + if (($input['vnbLimitW'] ?? 0) > 0) { + $limitCurrent = min($limitCurrent, PhaseManager::currentFromPower((float)$input['vnbLimitW'], $phaseCount, $voltage)); + } + + if ($limitCurrent < $minCurrent) { + return [0]; + } + + if ($mode === self::MODE_CONSTANT) { + $target = min(max($constantCurrent, $safeCurrent), $limitCurrent); + return $this->uniqueSorted([0, PhaseManager::wattsFromCurrent($target, $phaseCount, $voltage)]); + } + + $steps = [0]; + for ($current = (int)ceil($minCurrent); $current <= (int)floor($limitCurrent); $current++) { + $steps[] = PhaseManager::wattsFromCurrent((float)$current, $phaseCount, $voltage); + } + + return $this->uniqueSorted($steps); + } + + private function uniqueSorted(array $steps): array + { + $steps = array_map(static function ($value) { + return (int)round((float)$value); + }, $steps); + $steps = array_values(array_unique($steps)); + sort($steps, SORT_NUMERIC); + return $steps; + } +} + +?> diff --git a/Ladestation_OCPP/libs/TransactionStore.php b/Ladestation_OCPP/libs/TransactionStore.php new file mode 100644 index 0000000..b344923 --- /dev/null +++ b/Ladestation_OCPP/libs/TransactionStore.php @@ -0,0 +1,36 @@ + '', + 'transactionStartTime' => 0, + 'transactionStartEnergyImport' => 0.0, + 'transactionStartEnergyExport' => 0.0, + 'lastTransactionState' => '', + 'lastIdToken' => '', + 'sessionEnergyImport' => 0.0, + 'sessionEnergyExport' => 0.0, + 'sessionPeakPower' => 0.0, + 'sessionStopReason' => '' + ]; + } + + public static function fromJson(string $json): array + { + $data = json_decode($json, true); + if (!is_array($data)) { + return self::empty(); + } + return array_replace(self::empty(), $data); + } + + public static function toJson(array $state): string + { + return json_encode(array_replace(self::empty(), $state)); + } +} + +?> diff --git a/Ladestation_OCPP/module.json b/Ladestation_OCPP/module.json new file mode 100644 index 0000000..1c6381e --- /dev/null +++ b/Ladestation_OCPP/module.json @@ -0,0 +1,14 @@ +{ + "id": "{9C0DD018-2E06-4F03-8422-99FE85128F23}", + "name": "Ladestation_OCPP", + "type": 3, + "vendor": "Belevo AG", + "aliases": [ + "Ladestation OCPP" + ], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "GEF", + "url": "" +} diff --git a/Ladestation_OCPP/module.php b/Ladestation_OCPP/module.php new file mode 100644 index 0000000..7a8f0c0 --- /dev/null +++ b/Ladestation_OCPP/module.php @@ -0,0 +1,735 @@ +RegisterPropertyString('OCPPVersionMode', 'auto'); + $this->RegisterPropertyString('ChargePointId', ''); + $this->RegisterPropertyInteger('EVSEId', 1); + $this->RegisterPropertyInteger('ConnectorId', 1); + $this->RegisterPropertyInteger('OCPPServerInstance', 0); + $this->RegisterPropertyFloat('MaxCurrentAbs', 32.0); + $this->RegisterPropertyFloat('MinCurrent', 6.0); + $this->RegisterPropertyFloat('SafeCurrent', 6.0); + $this->RegisterPropertyString('SafeOffStrategy', 'SafeCurrent'); + $this->RegisterPropertyInteger('EMSWatchdogSeconds', 120); + $this->RegisterPropertyInteger('OCPPHeartbeatTimeoutSeconds', 90); + $this->RegisterPropertyInteger('CommandAckTimeoutSeconds', 30); + $this->RegisterPropertyInteger('MeterPowerIntervalSeconds', 1); + $this->RegisterPropertyInteger('MeterEnergyIntervalSeconds', 60); + $this->RegisterPropertyInteger('IdleCounterMax', 2); + $this->RegisterPropertyInteger('Interval', 1); + $this->RegisterPropertyInteger('PhaseSwitchHoldSeconds', 120); + $this->RegisterPropertyInteger('PhaseSwitchPauseSeconds', 30); + $this->RegisterPropertyBoolean('AllowAutomaticPhaseSwitch', false); + $this->RegisterPropertyBoolean('AllowDataTransfer', false); + $this->RegisterPropertyString('GroupId', ''); + $this->RegisterPropertyFloat('GroupLimitA', 0.0); + $this->RegisterPropertyFloat('GroupLimitW', 0.0); + $this->RegisterPropertyInteger('VNBInputMode', 0); + $this->RegisterPropertyInteger('DebugLevel', 0); + $this->RegisterPropertyFloat('SetpointTolerancePercent', 5.0); + $this->RegisterPropertyInteger('SetpointToleranceW', 300); + + $this->RegisterAttributeString(self::ATTR_TRANSACTION, TransactionStore::toJson(TransactionStore::empty())); + $this->RegisterAttributeString(self::ATTR_CAPABILITIES, json_encode(CapabilityModel::defaults())); + $this->RegisterAttributeInteger(self::ATTR_LAST_EMS_UPDATE, 0); + $this->RegisterAttributeInteger(self::ATTR_LAST_OCPP_HEARTBEAT, 0); + + $this->registerEmsVariables(); + $this->registerControlVariables(); + $this->registerStatusVariables(); + $this->registerMeterVariables(); + $this->registerDiagnosticVariables(); + + $this->RegisterTimer('Timer_Do_UserCalc', 1000, 'IPS_RequestAction(' . $this->InstanceID . ', "Do_UserCalc", "");'); + $this->RegisterTimer('Timer_StatusWatchdog', 5000, 'IPS_RequestAction(' . $this->InstanceID . ', "StatusWatchdog", "");'); + $this->RegisterTimer('Timer_EMSWatchdog', 5000, 'IPS_RequestAction(' . $this->InstanceID . ', "EMSWatchdog", "");'); + $this->RegisterTimer('Timer_OCPPWatchdog', 5000, 'IPS_RequestAction(' . $this->InstanceID . ', "OCPPWatchdog", "");'); + $this->RegisterTimer('Timer_MeterAggregation', 60000, 'IPS_RequestAction(' . $this->InstanceID . ', "MeterAggregation", "");'); + + $this->setInitialDefaults(); + } + + public function ApplyChanges() + { + parent::ApplyChanges(); + + $interval = max(1, $this->ReadPropertyInteger('Interval')); + $this->SetTimerInterval('Timer_Do_UserCalc', $interval * 1000); + $this->SetTimerInterval('Timer_StatusWatchdog', 5000); + $this->SetTimerInterval('Timer_EMSWatchdog', 5000); + $this->SetTimerInterval('Timer_OCPPWatchdog', 5000); + $this->SetTimerInterval('Timer_MeterAggregation', max(10, $this->ReadPropertyInteger('MeterEnergyIntervalSeconds')) * 1000); + + $this->EnableAction('Ladebereit'); + $this->EnableAction('Managementmodus'); + $this->EnableAction('Mindestladestrom'); + $this->EnableAction('Konstantstrom'); + $this->EnableAction('Max_Current_abs'); + $this->EnableAction('SafeCurrent'); + $this->EnableAction('AutomatischePhasenumschaltung'); + $this->EnableAction('ManuelleSperre'); + $this->EnableAction('VNB_Mode'); + $this->EnableAction('VNB_Limit_A'); + $this->EnableAction('VNB_Limit_W'); + $this->EnableAction('Sperre_Prio'); + $this->EnableAction('PV_Prio'); + + if ($this->GetValue('Max_Current_abs') <= 0) { + $this->SetValue('Max_Current_abs', $this->ReadPropertyFloat('MaxCurrentAbs')); + } + if ($this->GetValue('Mindestladestrom') <= 0) { + $this->SetValue('Mindestladestrom', $this->ReadPropertyFloat('MinCurrent')); + } + if ($this->GetValue('SafeCurrent') <= 0) { + $this->SetValue('SafeCurrent', $this->ReadPropertyFloat('SafeCurrent')); + } + + $chargePointId = trim($this->ReadPropertyString('ChargePointId')); + $this->SetValue('ChargePointId', $chargePointId); + $this->SetValue('EVSEId', $this->ReadPropertyInteger('EVSEId')); + $this->SetValue('ConnectorId', $this->ReadPropertyInteger('ConnectorId')); + + if ($chargePointId === '') { + $this->SetStatus(201); + $this->setDiagnostic('Warnung', 'Konfiguration', 'ChargePointId ist noch nicht gesetzt.'); + $this->SetSummary('OCPP Scaffold: ChargePointId fehlt'); + return; + } + + $this->SetStatus(102); + $this->SetSummary('OCPP Scaffold: ' . $chargePointId); + } + + public function RequestAction($Ident, $Value) + { + switch ($Ident) { + case 'SetAktuelle_Leistung': + $this->SetAktuelle_Leistung((float)$Value); + break; + + case 'GetCurrentData': + $this->SetValue('Is_Peak_Shaving', (bool)$Value); + $this->GetCurrentData((bool)$Value); + break; + + case 'Do_UserCalc': + $this->Do_UserCalc(); + break; + + case 'Ladebereit': + case 'AutomatischePhasenumschaltung': + case 'ManuelleSperre': + $this->SetValue($Ident, (bool)$Value); + $this->markPolicyChanged($Ident); + break; + + case 'Managementmodus': + case 'VNB_Mode': + case 'Sperre_Prio': + case 'PV_Prio': + $this->SetValue($Ident, (int)$Value); + $this->markPolicyChanged($Ident); + break; + + case 'Mindestladestrom': + case 'Konstantstrom': + case 'Max_Current_abs': + case 'SafeCurrent': + case 'VNB_Limit_A': + case 'VNB_Limit_W': + $this->SetValue($Ident, (float)$Value); + $this->markPolicyChanged($Ident); + break; + + case 'Reset': + $this->resetDiagnostics(); + break; + + case 'ClearChargingProfile': + $this->queueOcppCommand('ClearChargingProfile', $this->buildClearProfilePayload()); + break; + + case 'HandleInboundFrame': + $this->HandleInboundFrame((string)$Value); + break; + + case 'StatusWatchdog': + case 'EMSWatchdog': + case 'OCPPWatchdog': + $this->runWatchdogs(); + break; + + case 'MeterAggregation': + $this->aggregateEnergyFallback(); + break; + + default: + throw new Exception('Invalid Ident'); + } + } + + public function SetAktuelle_Leistung(float $power) + { + $lastPower = (float)$this->GetValue('Power'); + $this->SetValue('Power', $power); + $this->WriteAttributeInteger(self::ATTR_LAST_EMS_UPDATE, time()); + + if (abs($lastPower - $power) > 1) { + $this->SetValue('Idle', false); + $this->SetValue('IdleCounter', $this->ReadPropertyInteger('IdleCounterMax')); + } + + $this->setDiagnostic('Info', 'EMS', 'Neue EMS-Leistungsvorgabe gespeichert: ' . round($power) . ' W'); + } + + public function GetCurrentData(bool $Peak) + { + $this->SetValue('Is_Peak_Shaving', $Peak); + $calculator = new PowerStepCalculator(); + $steps = $calculator->calculate($this->buildPowerStepInput()); + + $this->SetValue('PowerSteps', json_encode($steps)); + $this->SetValue('Leistung_Delta', (float)$this->GetValue('Aktuelle_Leistung') - (float)$this->GetValue('Ladeleistung_Effektiv')); + $this->SetValue('SollIstAbweichung', (float)$this->GetValue('Leistung_Delta')); + + return $steps; + } + + public function Do_UserCalc() + { + $this->runWatchdogs(); + + $targetPower = $this->calculateEffectivePower(); + $oldPower = (float)$this->GetValue('Aktuelle_Leistung'); + $this->SetValue('Aktuelle_Leistung', $targetPower); + $this->SetValue('AktuellerEffektivSollwert', $targetPower); + $this->SetValue('Leistung_Delta', $targetPower - (float)$this->GetValue('Ladeleistung_Effektiv')); + $this->SetValue('SollIstAbweichung', $this->GetValue('Leistung_Delta')); + + if ($this->shouldSendChargingProfile($oldPower, $targetPower)) { + $setpoint = $this->buildEffectiveSetpoint($targetPower); + $profile = (new ChargingProfileBuilder())->build($setpoint, $this->effectiveOcppVersion()); + $this->queueOcppCommand('SetChargingProfile', $profile); + } + + $this->ProcessIdleCounter(); + $this->GetCurrentData($this->GetValue('Is_Peak_Shaving')); + } + + public function HandleInboundFrame(string $json) + { + $message = OCPPMessage::fromJson($json, $this->effectiveOcppVersion(), $this->ReadPropertyString('ChargePointId')); + if ($message === null) { + $this->setDiagnostic('Fehler', 'OCPP', 'Ungueltiger OCPP Frame empfangen.'); + return; + } + + $this->SetValue('LetzteMeldungZeit', time()); + $this->SetValue('LetzteMeldung', $message->action); + $this->SetValue('OCPP_Online', true); + $this->WriteAttributeInteger(self::ATTR_LAST_OCPP_HEARTBEAT, time()); + + switch ($message->action) { + case 'BootNotification': + $this->handleBootNotification($message); + break; + case 'Heartbeat': + $this->setDiagnostic('Info', 'OCPP', 'Heartbeat empfangen.'); + break; + case 'StatusNotification': + $this->handleStatusNotification($message->payload); + break; + case 'MeterValues': + $this->handleMeterValues($message->payload); + break; + case 'StartTransaction': + case 'StopTransaction': + case 'TransactionEvent': + $this->handleTransaction($message); + break; + case 'Authorize': + $this->SetValue('LetztesIdToken', $this->extractIdToken($message->payload)); + break; + case 'DataTransfer': + $this->handleDataTransfer($message->payload); + break; + default: + $this->setDiagnostic('Info', 'OCPP', 'OCPP Aktion empfangen: ' . $message->action); + break; + } + } + + private function registerEmsVariables(): void + { + $this->RegisterVariableInteger('Sperre_Prio', 'Sperre_Prio', '', 10); + $this->RegisterVariableInteger('PV_Prio', 'PV_Prio', '', 11); + $this->RegisterVariableBoolean('Idle', 'Idle', '', 12); + $this->RegisterVariableFloat('Aktuelle_Leistung', 'Aktuelle_Leistung', '', 13); + $this->RegisterVariableFloat('Bezogene_Energie', 'Bezogene_Energie', '', 14); + $this->RegisterVariableString('PowerSteps', 'PowerSteps', '', 15); + $this->RegisterVariableFloat('Power', 'Power', '', 16); + $this->RegisterVariableBoolean('Is_Peak_Shaving', 'Is_Peak_Shaving', '', 17); + $this->RegisterVariableFloat('Leistung_Delta', 'Leistung_Delta', '', 18); + $this->RegisterVariableInteger('IdleCounter', 'IdleCounter', '', 19); + } + + private function registerControlVariables(): void + { + $this->RegisterVariableBoolean('Ladebereit', 'Ladebereit', '~Switch', 30); + $this->RegisterVariableInteger('Managementmodus', 'Managementmodus', '', 31); + $this->RegisterVariableFloat('Mindestladestrom', 'Mindestladestrom', '', 32); + $this->RegisterVariableFloat('Konstantstrom', 'Konstantstrom', '', 33); + $this->RegisterVariableFloat('Max_Current_abs', 'Max_Current_abs', '', 34); + $this->RegisterVariableFloat('SafeCurrent', 'SafeCurrent', '', 35); + $this->RegisterVariableFloat('SafeOff', 'SafeOff', '', 36); + $this->RegisterVariableBoolean('AutomatischePhasenumschaltung', 'AutomatischePhasenumschaltung', '~Switch', 37); + $this->RegisterVariableBoolean('ManuelleSperre', 'ManuelleSperre', '~Switch', 38); + $this->RegisterVariableInteger('VNB_Mode', 'VNB_Mode', '', 39); + $this->RegisterVariableFloat('VNB_Limit_A', 'VNB_Limit_A', '', 40); + $this->RegisterVariableFloat('VNB_Limit_W', 'VNB_Limit_W', '', 41); + } + + private function registerStatusVariables(): void + { + $this->RegisterVariableBoolean('OCPP_Online', 'OCPP_Online', '~Switch', 60); + $this->RegisterVariableString('OCPP_Version', 'OCPP_Version', '', 61); + $this->RegisterVariableString('ChargePointId', 'ChargePointId', '', 62); + $this->RegisterVariableInteger('EVSEId', 'EVSEId', '', 63); + $this->RegisterVariableInteger('ConnectorId', 'ConnectorId', '', 64); + $this->RegisterVariableString('ConnectorStatus', 'ConnectorStatus', '', 65); + $this->RegisterVariableString('ChargingState', 'ChargingState', '', 66); + $this->RegisterVariableString('TransactionId', 'TransactionId', '', 67); + $this->RegisterVariableBoolean('Car_detected', 'Car_detected', '~Switch', 68); + $this->RegisterVariableBoolean('Car_is_full', 'Car_is_full', '~Switch', 69); + $this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 70); + $this->RegisterVariableBoolean('Is_1_ph', 'Is_1_ph', '~Switch', 71); + $this->RegisterVariableInteger('NumberPhases', 'NumberPhases', '', 72); + $this->RegisterVariableInteger('PhaseToUse', 'PhaseToUse', '', 73); + $this->RegisterVariableFloat('AktuellerEffektivSollwert', 'AktuellerEffektivSollwert', '', 74); + $this->RegisterVariableString('AktiveFreigabequelle', 'AktiveFreigabequelle', '', 75); + $this->RegisterVariableString('AktiverSperrgrund', 'AktiverSperrgrund', '', 76); + $this->RegisterVariableString('AktiverReduktionsgrund', 'AktiverReduktionsgrund', '', 77); + $this->RegisterVariableInteger('LetzterFreigabewechsel', 'LetzterFreigabewechsel', '', 78); + $this->RegisterVariableString('LetztesIdToken', 'LetztesIdToken', '', 79); + } + + private function registerMeterVariables(): void + { + $this->RegisterVariableFloat('Ladeleistung_Effektiv', 'Ladeleistung_Effektiv', '', 90); + $this->RegisterVariableFloat('Entladeleistung_Effektiv', 'Entladeleistung_Effektiv', '', 91); + $this->RegisterVariableFloat('Strom_L1', 'Strom_L1', '', 92); + $this->RegisterVariableFloat('Strom_L2', 'Strom_L2', '', 93); + $this->RegisterVariableFloat('Strom_L3', 'Strom_L3', '', 94); + $this->RegisterVariableFloat('Spannung_L1', 'Spannung_L1', '', 95); + $this->RegisterVariableFloat('Spannung_L2', 'Spannung_L2', '', 96); + $this->RegisterVariableFloat('Spannung_L3', 'Spannung_L3', '', 97); + $this->RegisterVariableFloat('Leistung_L1', 'Leistung_L1', '', 98); + $this->RegisterVariableFloat('Leistung_L2', 'Leistung_L2', '', 99); + $this->RegisterVariableFloat('Leistung_L3', 'Leistung_L3', '', 100); + $this->RegisterVariableFloat('Abgegebene_Energie', 'Abgegebene_Energie', '', 101); + $this->RegisterVariableFloat('SoC', 'SoC', '', 102); + $this->RegisterVariableFloat('Temperatur', 'Temperatur', '', 103); + $this->RegisterVariableString('MesswertQualitaet', 'MesswertQualitaet', '', 104); + $this->RegisterVariableInteger('LetzterMeterValueZeitpunkt', 'LetzterMeterValueZeitpunkt', '', 105); + } + + private function registerDiagnosticVariables(): void + { + $this->RegisterVariableString('Kommunikationsstatus', 'Kommunikationsstatus', '', 120); + $this->RegisterVariableString('LetzteMeldung', 'LetzteMeldung', '', 121); + $this->RegisterVariableInteger('LetzteMeldungZeit', 'LetzteMeldungZeit', '', 122); + $this->RegisterVariableString('Fehlerklasse', 'Fehlerklasse', '', 123); + $this->RegisterVariableString('AktuelleStoerung', 'AktuelleStoerung', '', 124); + $this->RegisterVariableBoolean('WarnungAktiv', 'WarnungAktiv', '~Switch', 125); + $this->RegisterVariableBoolean('FehlerAktiv', 'FehlerAktiv', '~Switch', 126); + $this->RegisterVariableBoolean('StoerungAktiv', 'StoerungAktiv', '~Switch', 127); + $this->RegisterVariableString('LetzterOCPPFehlercode', 'LetzterOCPPFehlercode', '', 128); + $this->RegisterVariableBoolean('LetztesChargingProfileAccepted', 'LetztesChargingProfileAccepted', '~Switch', 129); + $this->RegisterVariableInteger('LetzterCommandTimestamp', 'LetzterCommandTimestamp', '', 130); + $this->RegisterVariableString('LetzterCommandStatus', 'LetzterCommandStatus', '', 131); + $this->RegisterVariableFloat('SollIstAbweichung', 'SollIstAbweichung', '', 132); + } + + private function setInitialDefaults(): void + { + $defaults = [ + 'Idle' => true, + 'PowerSteps' => json_encode([0]), + 'Ladebereit' => true, + 'Managementmodus' => PowerStepCalculator::MODE_SOLAR, + 'Mindestladestrom' => 6.0, + 'Konstantstrom' => 6.0, + 'Max_Current_abs' => 32.0, + 'SafeCurrent' => 6.0, + 'SafeOff' => 0.0, + 'OCPP_Version' => 'auto', + 'ConnectorStatus' => 'Unknown', + 'ChargingState' => 'Unknown', + 'NumberPhases' => 3, + 'PhaseToUse' => 0, + 'AktiveFreigabequelle' => 'Symcon', + 'AktiverSperrgrund' => '', + 'AktiverReduktionsgrund' => '', + 'MesswertQualitaet' => 'unbekannt', + 'Kommunikationsstatus' => 'Scaffold', + 'LetzteMeldung' => 'Initialisiert', + 'Fehlerklasse' => '', + 'AktuelleStoerung' => '', + 'LetzterCommandStatus' => 'Noch kein OCPP-Transport verifiziert' + ]; + + foreach ($defaults as $ident => $value) { + $this->SetValue($ident, $value); + } + } + + private function buildPowerStepInput(): array + { + return [ + 'idle' => (bool)$this->GetValue('Idle'), + 'currentPowerW' => (float)$this->GetValue('Aktuelle_Leistung'), + 'ladebereit' => (bool)$this->GetValue('Ladebereit'), + 'manualLock' => (bool)$this->GetValue('ManuelleSperre'), + 'carDetected' => (bool)$this->GetValue('Car_detected'), + 'carFull' => (bool)$this->GetValue('Car_is_full'), + 'mode' => (int)$this->GetValue('Managementmodus'), + 'minCurrentA' => (float)$this->GetValue('Mindestladestrom'), + 'maxCurrentA' => (float)$this->GetValue('Max_Current_abs'), + 'safeCurrentA' => (float)$this->GetValue('SafeCurrent'), + 'constantCurrentA' => (float)$this->GetValue('Konstantstrom'), + 'numberPhases' => (int)$this->GetValue('NumberPhases'), + 'groupLimitA' => $this->ReadPropertyFloat('GroupLimitA'), + 'groupLimitW' => $this->ReadPropertyFloat('GroupLimitW'), + 'vnbLimitA' => (float)$this->GetValue('VNB_Limit_A'), + 'vnbLimitW' => (float)$this->GetValue('VNB_Limit_W'), + 'voltage' => $this->averageVoltage() + ]; + } + + private function calculateEffectivePower(): float + { + $steps = json_decode($this->GetValue('PowerSteps'), true); + if (!is_array($steps) || empty($steps)) { + $steps = [0]; + } + + $requested = (float)$this->GetValue('Power'); + $mode = (int)$this->GetValue('Managementmodus'); + + if ($mode === PowerStepCalculator::MODE_NEVER || !$this->GetValue('Ladebereit') || $this->GetValue('ManuelleSperre')) { + $this->SetValue('AktiverSperrgrund', 'Bedienung/Sperre'); + return 0.0; + } + + if ($mode === PowerStepCalculator::MODE_CONSTANT) { + $requested = PhaseManager::wattsFromCurrent((float)$this->GetValue('Konstantstrom'), (int)$this->GetValue('NumberPhases'), $this->averageVoltage()); + } + + $vnbMode = (int)$this->GetValue('VNB_Mode'); + if ($vnbMode === 1) { + $this->SetValue('AktiverSperrgrund', 'VNB harte Sperre'); + return 0.0; + } + + if ($vnbMode === 2) { + $this->SetValue('AktiverReduktionsgrund', 'VNB Reduktion'); + } else { + $this->SetValue('AktiverReduktionsgrund', ''); + } + + $best = 0.0; + foreach ($steps as $step) { + $step = (float)$step; + if ($step <= $requested && $step >= $best) { + $best = $step; + } + } + + return $best; + } + + private function buildEffectiveSetpoint(float $powerW): array + { + $phases = (int)$this->GetValue('NumberPhases'); + return [ + 'effectivePowerW' => $powerW, + 'effectiveCurrentA' => PhaseManager::currentFromPower($powerW, $phases, $this->averageVoltage()), + 'numberPhases' => $phases, + 'phaseToUse' => (int)$this->GetValue('PhaseToUse'), + 'evseId' => $this->ReadPropertyInteger('EVSEId'), + 'connectorId' => $this->ReadPropertyInteger('ConnectorId'), + 'reason' => $this->GetValue('AktiverReduktionsgrund'), + 'activeReleaseSource' => $this->GetValue('AktiveFreigabequelle'), + 'activeBlockingReason' => $this->GetValue('AktiverSperrgrund'), + 'activeReductionReason' => $this->GetValue('AktiverReduktionsgrund') + ]; + } + + private function buildClearProfilePayload(): array + { + if ($this->effectiveOcppVersion() === '1.6') { + return ['connectorId' => $this->ReadPropertyInteger('ConnectorId')]; + } + return ['evseId' => $this->ReadPropertyInteger('EVSEId')]; + } + + private function shouldSendChargingProfile(float $oldPower, float $newPower): bool + { + $delta = abs($oldPower - $newPower); + $toleranceW = max(1, $this->ReadPropertyInteger('SetpointToleranceW')); + $tolerancePercent = max(0.0, $this->ReadPropertyFloat('SetpointTolerancePercent')); + $percentLimit = max($oldPower, 1.0) * ($tolerancePercent / 100.0); + return $delta >= max($toleranceW, $percentLimit); + } + + private function queueOcppCommand(string $action, array $payload): void + { + $version = $this->effectiveOcppVersion(); + $message = OCPPMessage::call($action, $payload, $version, $this->ReadPropertyString('ChargePointId')); + $server = $this->ReadPropertyInteger('OCPPServerInstance'); + $this->SetValue('LetzterCommandTimestamp', time()); + $this->SetValue('LetzterCommandStatus', 'Queued scaffold: ' . $action); + + if ($server > 0 && IPS_InstanceExists($server)) { + IPS_RequestAction($server, 'QueueOutboundFrame', json_encode(OCPPTransport::buildEnvelope($message, $this->InstanceID))); + return; + } + + $this->setDiagnostic('Warnung', 'OCPP Transport', 'Kein OCPP_Server verbunden. Command nur lokal vorgemerkt: ' . $action); + } + + private function runWatchdogs(): void + { + $manager = new FailSafeManager(); + $result = $manager->evaluate([ + 'lastEmsUpdate' => $this->ReadAttributeInteger(self::ATTR_LAST_EMS_UPDATE), + 'emsWatchdogSeconds' => $this->ReadPropertyInteger('EMSWatchdogSeconds'), + 'lastOcppHeartbeat' => $this->ReadAttributeInteger(self::ATTR_LAST_OCPP_HEARTBEAT), + 'ocppHeartbeatTimeoutSeconds' => $this->ReadPropertyInteger('OCPPHeartbeatTimeoutSeconds'), + 'ocppOnline' => (bool)$this->GetValue('OCPP_Online'), + 'criticalFault' => (bool)$this->GetValue('StoerungAktiv'), + 'safeOffStrategy' => $this->ReadPropertyString('SafeOffStrategy'), + 'safeCurrentA' => (float)$this->GetValue('SafeCurrent'), + 'numberPhases' => (int)$this->GetValue('NumberPhases') + ]); + + if (!empty($result['warnings'])) { + $this->SetValue('WarnungAktiv', true); + $this->SetValue('Fehlerklasse', implode(', ', $result['warnings'])); + } + if ($result['blockingReason'] !== '') { + $this->SetValue('AktiverSperrgrund', $result['blockingReason']); + $this->SetValue('StoerungAktiv', true); + } + } + + private function ProcessIdleCounter(): void + { + $counter = (int)$this->GetValue('IdleCounter'); + if ($counter > 0) { + $this->SetValue('IdleCounter', $counter - 1); + $this->SetValue('Idle', false); + return; + } + $this->SetValue('Idle', true); + } + + private function handleBootNotification(OCPPMessage $message): void + { + $version = $this->effectiveOcppVersion(); + $this->SetValue('OCPP_Version', $version); + $this->WriteAttributeString(self::ATTR_CAPABILITIES, json_encode(CapabilityModel::detectFromBoot($version, $message->payload))); + $this->setDiagnostic('Info', 'OCPP', 'BootNotification verarbeitet.'); + } + + private function handleStatusNotification(array $payload): void + { + $status = (string)($payload['status'] ?? $payload['connectorStatus'] ?? 'Unknown'); + $this->SetValue('ConnectorStatus', $status); + $this->SetValue('ChargingState', (string)($payload['chargingState'] ?? $status)); + $this->SetValue('Car_detected', in_array($status, ['Preparing', 'Charging', 'SuspendedEV', 'SuspendedEVSE', 'Finishing', 'Occupied'], true)); + $this->SetValue('Car_is_full', in_array($status, ['Finishing', 'SuspendedEV'], true)); + } + + private function handleMeterValues(array $payload): void + { + $values = (new MeterValueNormalizer())->normalize($payload); + if ($values['powerImportW'] !== null) { + $this->SetValue('Ladeleistung_Effektiv', (float)$values['powerImportW']); + } + if ($values['powerExportW'] !== null) { + $this->SetValue('Entladeleistung_Effektiv', (float)$values['powerExportW']); + } + if ($values['energyImportWh'] !== null) { + $this->SetValue('Bezogene_Energie', (float)$values['energyImportWh']); + } + if ($values['energyExportWh'] !== null) { + $this->SetValue('Abgegebene_Energie', (float)$values['energyExportWh']); + } + foreach ([1, 2, 3] as $phase) { + if ($values['currentA'][$phase] !== null) { + $this->SetValue('Strom_L' . $phase, (float)$values['currentA'][$phase]); + } + if ($values['voltageV'][$phase] !== null) { + $this->SetValue('Spannung_L' . $phase, (float)$values['voltageV'][$phase]); + } + if ($values['powerPhaseW'][$phase] !== null) { + $this->SetValue('Leistung_L' . $phase, (float)$values['powerPhaseW'][$phase]); + } + } + if ($values['soc'] !== null) { + $this->SetValue('SoC', (float)$values['soc']); + } + if ($values['temperature'] !== null) { + $this->SetValue('Temperatur', (float)$values['temperature']); + } + $this->SetValue('MesswertQualitaet', $values['quality']); + $this->SetValue('LetzterMeterValueZeitpunkt', (int)$values['timestamp']); + } + + private function handleTransaction(OCPPMessage $message): void + { + $state = TransactionStore::fromJson($this->ReadAttributeString(self::ATTR_TRANSACTION)); + $payload = $message->payload; + $transactionId = (string)($payload['transactionId'] ?? ($payload['transactionInfo']['transactionId'] ?? $state['activeTransactionId'])); + if ($transactionId !== '') { + $state['activeTransactionId'] = $transactionId; + $this->SetValue('TransactionId', $transactionId); + } + $state['lastTransactionState'] = $message->action; + $state['lastIdToken'] = $this->extractIdToken($payload); + if ($state['transactionStartTime'] === 0 && ($message->action === 'StartTransaction' || $message->action === 'TransactionEvent')) { + $state['transactionStartTime'] = time(); + } + if ($message->action === 'StopTransaction') { + $state['sessionStopReason'] = (string)($payload['reason'] ?? 'Unknown'); + } + $this->WriteAttributeString(self::ATTR_TRANSACTION, TransactionStore::toJson($state)); + $this->SetValue('LetztesIdToken', $state['lastIdToken']); + } + + private function handleDataTransfer(array $payload): void + { + $registry = new DataTransferRegistry($this->ReadPropertyBoolean('AllowDataTransfer')); + $vendorId = (string)($payload['vendorId'] ?? ''); + $messageId = (string)($payload['messageId'] ?? ''); + if (!$registry->isAllowed($vendorId, $messageId)) { + $this->setDiagnostic('Warnung', 'DataTransfer', 'DataTransfer blockiert: ' . $vendorId . '/' . $messageId); + return; + } + $this->setDiagnostic('Info', 'DataTransfer', 'DataTransfer empfangen: ' . $vendorId . '/' . $messageId); + } + + private function aggregateEnergyFallback(): void + { + if ($this->GetValue('LetzterMeterValueZeitpunkt') > 0) { + return; + } + $energy = (float)$this->GetValue('Bezogene_Energie'); + $energy += (float)$this->GetValue('Ladeleistung_Effektiv') * (max(1, $this->ReadPropertyInteger('MeterEnergyIntervalSeconds')) / 3600); + $this->SetValue('Bezogene_Energie', $energy); + } + + private function extractIdToken(array $payload): string + { + if (isset($payload['idTag'])) { + return (string)$payload['idTag']; + } + if (isset($payload['idToken']['idToken'])) { + return (string)$payload['idToken']['idToken']; + } + return ''; + } + + private function averageVoltage(): float + { + $values = []; + foreach (['Spannung_L1', 'Spannung_L2', 'Spannung_L3'] as $ident) { + $value = (float)$this->GetValue($ident); + if ($value > 0) { + $values[] = $value; + } + } + if (empty($values)) { + return 230.0; + } + return array_sum($values) / count($values); + } + + private function effectiveOcppVersion(): string + { + $version = $this->ReadPropertyString('OCPPVersionMode'); + if ($version === 'auto') { + $current = $this->GetValue('OCPP_Version'); + return ($current === '' || $current === 'auto') ? '1.6' : $current; + } + return $version; + } + + private function markPolicyChanged(string $ident): void + { + $this->SetValue('LetzterFreigabewechsel', time()); + $this->setDiagnostic('Info', 'Bedienung', 'Policy geaendert: ' . $ident); + $this->GetCurrentData($this->GetValue('Is_Peak_Shaving')); + } + + private function resetDiagnostics(): void + { + $this->SetValue('WarnungAktiv', false); + $this->SetValue('FehlerAktiv', false); + $this->SetValue('StoerungAktiv', false); + $this->SetValue('Fehlerklasse', ''); + $this->SetValue('AktuelleStoerung', ''); + $this->SetValue('LetzterOCPPFehlercode', ''); + $this->setDiagnostic('Info', 'Diagnose', 'Diagnose zurueckgesetzt.'); + } + + private function setDiagnostic(string $severity, string $source, string $text, string $code = ''): void + { + $this->SetValue('LetzteMeldung', $text); + $this->SetValue('LetzteMeldungZeit', time()); + $this->SetValue('Kommunikationsstatus', Diagnostics::statusFromFlags((bool)$this->GetValue('OCPP_Online'), (bool)$this->GetValue('StoerungAktiv'))); + if ($severity === 'Warnung') { + $this->SetValue('WarnungAktiv', true); + } elseif ($severity === 'Fehler') { + $this->SetValue('FehlerAktiv', true); + $this->SetValue('Fehlerklasse', $source); + } elseif ($severity === 'Stoerung') { + $this->SetValue('StoerungAktiv', true); + $this->SetValue('AktuelleStoerung', $text); + } + if ($code !== '') { + $this->SetValue('LetzterOCPPFehlercode', $code); + } + if ($this->ReadPropertyInteger('DebugLevel') > 0) { + $this->SendDebug($source, $severity . ': ' . $text, 0); + } + } +} + +?> diff --git a/OCPP_Server/README.md b/OCPP_Server/README.md new file mode 100644 index 0000000..396c162 --- /dev/null +++ b/OCPP_Server/README.md @@ -0,0 +1,25 @@ +# OCPP_Server + +`OCPP_Server` ist ein Transport- und Routing-Scaffold fuer das neue `Ladestation_OCPP` Modul. + +## Aufgabe + +- Vorbereitung der CSMS-Transportrolle innerhalb von IP-Symcon. +- Routing von eingehenden OCPP-Frames nach `ChargePointId`, `EVSEId` und `ConnectorId`. +- Entgegennahme von ausgehenden Frames aus `Ladestation_OCPP`. +- Dokumentation des WebHook/WebSocket-Spikes. + +## Status + +Dieses Modul ist bewusst ein Scaffold. Symcon bietet einen WebSocket Client sowie WebHook Control mit WebSocket-Support und `ProcessHookData()` fuer PHP-Module. Ob diese Mechanik fuer dauerhafte OCPP-CSMS-Verbindungen mit realen Ladestationen robust genug ist, muss mit einer OCPP-Referenzstation oder einem Simulator getestet werden. + +## Konfiguration + +- `HookPath`: Standard `/hook/ocpp` +- `DefaultTargetInstance`: Zielinstanz, wenn kein spezifisches Routing gefunden wird +- `Ladepunkte`: optionale Routingliste je ChargePoint/EVSE/Connector +- `HeartbeatSeconds`: Watchdog-Basis + +## Zusammenspiel + +`Ladestation_OCPP` bleibt das fachliche Ladepunktmodul und setzt den EMS-Vertrag um. `OCPP_Server` bleibt Transport/Routing. Mehrere Ladepunkte koennen spaeter ueber denselben Transport angebunden werden. diff --git a/OCPP_Server/form.json b/OCPP_Server/form.json new file mode 100644 index 0000000..c1cdc53 --- /dev/null +++ b/OCPP_Server/form.json @@ -0,0 +1,84 @@ +{ + "elements": [ + { + "type": "Label", + "caption": "Transport-Scaffold fuer OCPP. WebHook/WebSocket wird als technischer Spike dokumentiert." + }, + { + "type": "CheckBox", + "name": "EnableWebhook", + "caption": "Webhook registrieren, falls Symcon-Version RegisterHook unterstuetzt" + }, + { + "type": "ValidationTextBox", + "name": "HookPath", + "caption": "Hook Pfad" + }, + { + "type": "SelectInstance", + "name": "DefaultTargetInstance", + "caption": "Default Ladestation_OCPP Instanz", + "test": true + }, + { + "type": "List", + "name": "Ladepunkte", + "caption": "Routing Ladepunkte", + "add": true, + "delete": true, + "columns": [ + { + "caption": "ChargePointId", + "name": "ChargePointId", + "width": "220px", + "add": "", + "edit": { "type": "ValidationTextBox" } + }, + { + "caption": "EVSEId", + "name": "EVSEId", + "width": "100px", + "add": 1, + "edit": { "type": "NumberSpinner" } + }, + { + "caption": "ConnectorId", + "name": "ConnectorId", + "width": "100px", + "add": 1, + "edit": { "type": "NumberSpinner" } + }, + { + "caption": "Zielinstanz", + "name": "TargetInstance", + "width": "220px", + "add": 0, + "edit": { "type": "SelectInstance" } + } + ] + }, + { + "type": "NumberSpinner", + "name": "HeartbeatSeconds", + "caption": "Watchdog Intervall", + "suffix": "Sekunden" + }, + { + "type": "NumberSpinner", + "name": "DebugLevel", + "caption": "Debug Level" + } + ], + "actions": [ + { + "type": "Button", + "caption": "Hook pruefen", + "onClick": "IPS_RequestAction($id, \"RegisterHook\", \"\");" + }, + { + "type": "Button", + "caption": "Puffer loeschen", + "onClick": "IPS_RequestAction($id, \"ClearBuffers\", \"\");" + } + ] +} diff --git a/OCPP_Server/libs/ConnectionRegistry.php b/OCPP_Server/libs/ConnectionRegistry.php new file mode 100644 index 0000000..7a03bae --- /dev/null +++ b/OCPP_Server/libs/ConnectionRegistry.php @@ -0,0 +1,40 @@ + [], + 'lastSeen' => [] + ]; + } + + public static function fromJson(string $json): array + { + $data = json_decode($json, true); + if (!is_array($data)) { + return self::empty(); + } + return array_replace(self::empty(), $data); + } + + public static function toJson(array $state): string + { + return json_encode(array_replace(self::empty(), $state)); + } + + public static function touch(array $state, string $chargePointId, string $remote = ''): array + { + $state = array_replace(self::empty(), $state); + $state['connections'][$chargePointId] = [ + 'chargePointId' => $chargePointId, + 'remote' => $remote, + 'timestamp' => time() + ]; + $state['lastSeen'][$chargePointId] = time(); + return $state; + } +} + +?> diff --git a/OCPP_Server/libs/OCPPFrameRouter.php b/OCPP_Server/libs/OCPPFrameRouter.php new file mode 100644 index 0000000..1c1b1f3 --- /dev/null +++ b/OCPP_Server/libs/OCPPFrameRouter.php @@ -0,0 +1,36 @@ + diff --git a/OCPP_Server/libs/WebSocketEndpoint.php b/OCPP_Server/libs/WebSocketEndpoint.php new file mode 100644 index 0000000..9678c78 --- /dev/null +++ b/OCPP_Server/libs/WebSocketEndpoint.php @@ -0,0 +1,29 @@ + 'Hook vorbereitet', + 'detail' => 'Symcon RegisterHook ist verfuegbar. WebSocket-Dauerbetrieb muss mit echter OCPP-Station verifiziert werden.', + 'hookPath' => $hookPath + ]; + } + + return [ + 'status' => 'Hook manuell/spaeter', + 'detail' => 'RegisterHook ist in dieser Symcon-Umgebung nicht als Modul-Methode verfuegbar. WebHook Control muss manuell oder nach Upgrade verbunden werden.', + 'hookPath' => $hookPath + ]; + } + + public static function readRawBody(): string + { + $data = @file_get_contents('php://input'); + return is_string($data) ? $data : ''; + } +} + +?> diff --git a/OCPP_Server/module.json b/OCPP_Server/module.json new file mode 100644 index 0000000..c308b00 --- /dev/null +++ b/OCPP_Server/module.json @@ -0,0 +1,14 @@ +{ + "id": "{E0A65257-3F98-4D4F-9BAB-52822D5B435F}", + "name": "OCPP_Server", + "type": 3, + "vendor": "Belevo AG", + "aliases": [ + "OCPP Server" + ], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "GEF", + "url": "" +} diff --git a/OCPP_Server/module.php b/OCPP_Server/module.php new file mode 100644 index 0000000..1ac7a17 --- /dev/null +++ b/OCPP_Server/module.php @@ -0,0 +1,220 @@ +RegisterPropertyBoolean('EnableWebhook', true); + $this->RegisterPropertyString('HookPath', '/hook/ocpp'); + $this->RegisterPropertyInteger('DefaultTargetInstance', 0); + $this->RegisterPropertyString('Ladepunkte', json_encode([])); + $this->RegisterPropertyInteger('HeartbeatSeconds', 30); + $this->RegisterPropertyInteger('DebugLevel', 0); + + $this->RegisterAttributeString(self::ATTR_CONNECTIONS, ConnectionRegistry::toJson(ConnectionRegistry::empty())); + + $this->RegisterVariableString('TransportStatus', 'TransportStatus', '', 10); + $this->RegisterVariableString('LastInboundFrame', 'LastInboundFrame', '', 20); + $this->RegisterVariableString('LastOutboundFrame', 'LastOutboundFrame', '', 21); + $this->RegisterVariableString('LastRouteResult', 'LastRouteResult', '', 22); + $this->RegisterVariableInteger('ConnectionCount', 'ConnectionCount', '', 30); + $this->RegisterVariableInteger('LastMessageTime', 'LastMessageTime', '', 31); + $this->RegisterVariableString('WebSocketSupportStatus', 'WebSocketSupportStatus', '', 40); + $this->RegisterVariableString('LetzteMeldung', 'LetzteMeldung', '', 41); + $this->RegisterVariableInteger('LetzteMeldungZeit', 'LetzteMeldungZeit', '', 42); + + $this->RegisterTimer('Timer_TransportWatchdog', 30000, 'IPS_RequestAction(' . $this->InstanceID . ', "TransportWatchdog", "");'); + + $this->SetValue('TransportStatus', 'Scaffold'); + $this->SetValue('WebSocketSupportStatus', 'Nicht geprueft'); + $this->SetValue('LetzteMeldung', 'OCPP Server Scaffold initialisiert'); + } + + public function ApplyChanges() + { + parent::ApplyChanges(); + + $this->SetTimerInterval('Timer_TransportWatchdog', max(5, $this->ReadPropertyInteger('HeartbeatSeconds')) * 1000); + $summary = WebSocketEndpoint::supportSummary(method_exists($this, 'RegisterHook'), $this->ReadPropertyString('HookPath')); + $this->SetValue('WebSocketSupportStatus', $summary['status'] . ': ' . $summary['detail']); + $this->SetSummary($summary['status']); + + if ($this->ReadPropertyBoolean('EnableWebhook')) { + $this->tryRegisterHook(); + } + + $this->SetStatus(102); + } + + public function RequestAction($Ident, $Value) + { + switch ($Ident) { + case 'RegisterHook': + $this->tryRegisterHook(); + break; + + case 'QueueOutboundFrame': + $this->QueueOutboundFrame((string)$Value); + break; + + case 'RouteInboundFrame': + $this->RouteInboundFrame((string)$Value); + break; + + case 'TransportWatchdog': + $this->TransportWatchdog(); + break; + + case 'ClearBuffers': + $this->SetValue('LastInboundFrame', ''); + $this->SetValue('LastOutboundFrame', ''); + $this->SetValue('LastRouteResult', ''); + $this->setMessage('Puffer geloescht'); + break; + + default: + throw new Exception('Invalid Ident'); + } + } + + protected function ProcessHookData($JSONString = '') + { + $raw = WebSocketEndpoint::readRawBody(); + if ($raw === '' && is_string($JSONString)) { + $raw = $JSONString; + } + + $path = $_SERVER['REQUEST_URI'] ?? $this->ReadPropertyString('HookPath'); + $chargePointId = (new OCPPFrameRouter())->extractChargePointId((string)$path); + + $this->RouteInboundFrame(json_encode([ + 'ChargePointId' => $chargePointId, + 'Frame' => $raw, + 'Remote' => ($_SERVER['REMOTE_ADDR'] ?? '') . ':' . ($_SERVER['REMOTE_PORT'] ?? '') + ])); + + header('Content-Type: application/json'); + echo json_encode([ + 'status' => 'accepted', + 'note' => 'OCPP transport scaffold. Produktiver WebSocket-Dauerbetrieb muss mit Station verifiziert werden.' + ]); + } + + public function QueueOutboundFrame(string $json): void + { + $this->SetValue('LastOutboundFrame', $json); + $this->SetValue('LastMessageTime', time()); + $this->setMessage('Outbound Frame vorgemerkt. Aktiver WebSocket-Sendekanal ist noch Scaffold.'); + } + + public function RouteInboundFrame(string $json): void + { + $data = json_decode($json, true); + if (!is_array($data)) { + $data = [ + 'ChargePointId' => '', + 'Frame' => $json, + 'Remote' => '' + ]; + } + + $chargePointId = (string)($data['ChargePointId'] ?? ''); + $frame = (string)($data['Frame'] ?? ''); + $remote = (string)($data['Remote'] ?? ''); + + $this->SetValue('LastInboundFrame', $frame); + $this->SetValue('LastMessageTime', time()); + + $connections = ConnectionRegistry::touch( + ConnectionRegistry::fromJson($this->ReadAttributeString(self::ATTR_CONNECTIONS)), + $chargePointId, + $remote + ); + $this->WriteAttributeString(self::ATTR_CONNECTIONS, ConnectionRegistry::toJson($connections)); + $this->SetValue('ConnectionCount', count($connections['connections'])); + + $routes = json_decode($this->ReadPropertyString('Ladepunkte'), true); + if (!is_array($routes)) { + $routes = []; + } + + $target = (new OCPPFrameRouter())->route( + $routes, + $chargePointId, + 1, + 1, + $this->ReadPropertyInteger('DefaultTargetInstance') + ); + + $this->SetValue('LastRouteResult', json_encode([ + 'chargePointId' => $chargePointId, + 'target' => $target, + 'timestamp' => time() + ])); + + if ($target > 0 && IPS_InstanceExists($target)) { + IPS_RequestAction($target, 'HandleInboundFrame', $frame); + $this->setMessage('Inbound Frame an Zielinstanz ' . $target . ' geroutet.'); + return; + } + + $this->setMessage('Inbound Frame empfangen, aber keine Zielinstanz gefunden.'); + } + + public function TransportWatchdog(): void + { + $last = (int)$this->GetValue('LastMessageTime'); + if ($last === 0) { + $this->SetValue('TransportStatus', 'Wartet auf OCPP Verbindung'); + return; + } + + $age = time() - $last; + if ($age > max(90, 3 * $this->ReadPropertyInteger('HeartbeatSeconds'))) { + $this->SetValue('TransportStatus', 'Timeout'); + $this->setMessage('Transport-Watchdog Timeout nach ' . $age . ' Sekunden.'); + return; + } + + $this->SetValue('TransportStatus', 'Aktiv/Scaffold'); + } + + private function tryRegisterHook(): void + { + $hook = $this->ReadPropertyString('HookPath'); + if (method_exists($this, 'RegisterHook')) { + try { + $this->RegisterHook($hook); + $this->SetValue('WebSocketSupportStatus', 'RegisterHook aufgerufen fuer ' . $hook . '. WebSocket-Dauerbetrieb noch testen.'); + $this->setMessage('Webhook registriert: ' . $hook); + return; + } catch (Throwable $e) { + $this->SetValue('WebSocketSupportStatus', 'RegisterHook Fehler: ' . $e->getMessage()); + $this->setMessage('Webhook konnte nicht registriert werden.'); + return; + } + } + + $this->SetValue('WebSocketSupportStatus', 'RegisterHook nicht verfuegbar. WebHook Control manuell pruefen.'); + $this->setMessage('RegisterHook nicht verfuegbar.'); + } + + private function setMessage(string $message): void + { + $this->SetValue('LetzteMeldung', $message); + $this->SetValue('LetzteMeldungZeit', time()); + if ($this->ReadPropertyInteger('DebugLevel') > 0) { + $this->SendDebug('OCPP_Server', $message, 0); + } + } +} + +?> diff --git a/docs/OCPP/README.md b/docs/OCPP/README.md new file mode 100644 index 0000000..94c7d85 --- /dev/null +++ b/docs/OCPP/README.md @@ -0,0 +1,262 @@ +# OCPP-Ladestationsmodul fuer Symcon + +Stand: 2026-05-10 + +Diese Dokumentation fasst die lokalen Anhaenge zum neuen Symcon-Ladestationsmodul zusammen und beschreibt die Zielarchitektur fuer `Ladestation_OCPP` und `OCPP_Server`. + +## Zielbild + +Das neue Ladestationsmodul wird konsequent OCPP-orientiert aufgebaut. Es soll die fachliche Wirkung von `Ladestation_v2` erreichen, aber die alte interne Struktur nicht kopieren. + +Verbindliche Grundsaetze: + +- Kommunikation zur Ladestation erfolgt OCPP-only. +- OCPP 1.6 wird fuer Phase 1 unterstuetzt. +- OCPP 2.0.1 wird strukturell vorbereitet. +- OCPP 2.1 und bidirektionales Laden werden als spaetere Erweiterung vorbereitet. +- Hersteller-Sonderfunktionen laufen nur ueber OCPP `DataTransfer`. +- Keine Hersteller-HTTP-APIs, keine Hersteller-Cloud-APIs, keine OAuth-/Cloud-Token im Ladestationsmodul. +- Der bestehende EMS-Vertrag zum `Manager` bleibt stabil. + +## Modulaufteilung + +`Ladestation_OCPP` ist die fachliche Ladepunkt-/Connector-Instanz. Sie bildet Status, Messwerte, Managementmodi, Diagnose und EMS-Schnittstelle ab. + +`OCPP_Server` ist ein separates Transport-Scaffold. Es bereitet WebHook/WebSocket, Routing und Frame-Puffer vor. Die Trennung ist wichtig, weil mehrere Ladepunkte an einem Transport haengen koennen. + +## EMS-Vertrag + +`Ladestation_OCPP` ist ein Verbraucher des bestehenden Energiemanagers. Die folgenden Idents sind Pflicht und duerfen nicht umbenannt werden: + +| Ident | Typ | Bedeutung | +| --- | --- | --- | +| `Sperre_Prio` | Integer | Prioritaet fuer Peak-Shaving | +| `PV_Prio` | Integer | Prioritaet fuer Solarladen | +| `Idle` | Boolean | Modul ist bereit fuer neue Leistungszuteilung | +| `Aktuelle_Leistung` | Float | aktuell wirksame Leistung in W | +| `Bezogene_Energie` | Float | Importenergie fuer Fairness/Bilanz | +| `PowerSteps` | String | JSON-Array erlaubter Leistungsstufen in W | +| `Power` | Float | vom Manager gesetzter Sollwert | +| `Is_Peak_Shaving` | Boolean | aktueller Manager-Modus | +| `Leistung_Delta` | Float | Soll/Ist-Abweichung in W | +| `IdleCounter` | Integer | Zaehler fuer Regelruhe | + +Pflicht-Actions: + +- `GetCurrentData(bool Peak)` +- `SetAktuelle_Leistung(power)` +- `Do_UserCalc()` + +Positive Werte bedeuten Laden/Verbrauch, `0` bedeutet aus/neutral. Negative Werte sind fuer spaeteres bidirektionales Laden reserviert und in Phase 1 nicht aktiv. + +## Phase 1 Umfang + +Phase 1 ist ein produktionsnaher Scaffold, aber noch kein fertig abgenommener OCPP-CSMS. + +Sichtbar und vorbereitet: + +- OCPP 1.6 Grundgeruest. +- OCPP 2.0.1/2.1 Adapterklassen als Struktur. +- Manager-Anbindung mit `PowerSteps`. +- Managementmodi: Nie laden, Immer laden, Konstanter Strom, Nur Solar. +- Status-, Messwert- und Diagnosevariablen. +- Messwertnormalisierung fuer Import/Export, Strom, Spannung, Leistung, SoC, Temperatur. +- Transaktionsspeicher als Symcon-Attribut. +- Fail-Safe Defaults. +- ChargingProfile-Erzeugung als OCPP-kompatibles Datenmodell. + +Noch nicht produktiv fertig: + +- echte dauerhafte WebSocket-CSMS-Verbindung mit realer Station. +- vollstaendige OCPP-1.6/2.0.1 State Machine. +- automatische 1p/3p-Umschaltung mit Hardwarefreigabe. +- bidirektionales Laden. +- Tarifoptimierung. + +## OCPP-Kommunikation + +OCPP-Nachrichten werden intern in `OCPPMessage` normalisiert: + +```text +version +direction +action +uniqueId +payload +timestamp +chargePointId +``` + +Eingehend vorbereitet: + +- `BootNotification` +- `Heartbeat` +- `StatusNotification` +- `Authorize` +- `StartTransaction` +- `StopTransaction` +- `TransactionEvent` +- `MeterValues` +- `DataTransfer` + +Ausgehend vorbereitet: + +- `SetChargingProfile` +- `ClearChargingProfile` +- `Reset` + +## Transport-Spike + +OCPP erwartet, dass die Ladestation eine WebSocket-Verbindung zum CSMS aufbaut. Symcon muss hier die CSMS-Rolle uebernehmen. + +Relevante Symcon-Optionen: + +- WebSocket Client: offizieller Symcon I/O fuer ausgehende WebSocket-Verbindungen. +- WebHook Control: unterstuetzt WebSockets und kann Daten an Skripte oder PHP-Module weiterleiten. +- `ProcessHookData()`: PHP-Modul-Einstieg fuer registrierte WebHooks. + +Bewertung fuer Phase 1: + +- `OCPP_Server` kapselt diese Frage, damit `Ladestation_OCPP` fachlich stabil bleibt. +- Der produktive Rueckkanal und Dauerbetrieb muessen mit Simulator oder OCPP-1.6-Referenzstation getestet werden. +- Ohne bestaetigten Transport wird kein externer Middleware-Dienst als Standard eingebaut. + +Quellen: + +- https://www.symcon.de/de/service/dokumentation/modulreferenz/io/websocketclient/ +- https://www.symcon.de/de/service/dokumentation/modulreferenz/webhook-control/ +- https://www.symcon.de/de/service/dokumentation/entwicklerbereich/sdk-tools/sdk-php/module/processhookdata/ + +## Variablenkatalog + +Bedienung: + +- `Ladebereit` +- `Managementmodus` +- `Mindestladestrom` +- `Konstantstrom` +- `Max_Current_abs` +- `SafeCurrent` +- `SafeOff` +- `AutomatischePhasenumschaltung` +- `ManuelleSperre` +- `VNB_Mode` +- `VNB_Limit_A` +- `VNB_Limit_W` + +Status: + +- `OCPP_Online` +- `OCPP_Version` +- `ChargePointId` +- `EVSEId` +- `ConnectorId` +- `ConnectorStatus` +- `ChargingState` +- `TransactionId` +- `Car_detected` +- `Car_is_full` +- `Fahrzeugstatus` +- `Is_1_ph` +- `NumberPhases` +- `PhaseToUse` +- `AktuellerEffektivSollwert` +- `AktiveFreigabequelle` +- `AktiverSperrgrund` +- `AktiverReduktionsgrund` +- `LetzterFreigabewechsel` +- `LetztesIdToken` + +Messwerte: + +- `Ladeleistung_Effektiv` +- `Entladeleistung_Effektiv` +- `Strom_L1`, `Strom_L2`, `Strom_L3` +- `Spannung_L1`, `Spannung_L2`, `Spannung_L3` +- `Leistung_L1`, `Leistung_L2`, `Leistung_L3` +- `Bezogene_Energie` +- `Abgegebene_Energie` +- `SoC` +- `Temperatur` +- `MesswertQualitaet` +- `LetzterMeterValueZeitpunkt` + +Diagnose: + +- `Kommunikationsstatus` +- `LetzteMeldung` +- `LetzteMeldungZeit` +- `Fehlerklasse` +- `AktuelleStoerung` +- `WarnungAktiv` +- `FehlerAktiv` +- `StoerungAktiv` +- `LetzterOCPPFehlercode` +- `LetztesChargingProfileAccepted` +- `LetzterCommandTimestamp` +- `LetzterCommandStatus` +- `SollIstAbweichung` + +## Messwertnormalisierung + +OCPP-Messwerte werden auf interne Symcon-Kernwerte abgebildet: + +| OCPP-Measurand | Symcon-Wert | +| --- | --- | +| `Power.Active.Import` | `Ladeleistung_Effektiv` | +| `Power.Active.Export` | `Entladeleistung_Effektiv` | +| `Energy.Active.Import.Register` | `Bezogene_Energie` | +| `Energy.Active.Export.Register` | `Abgegebene_Energie` | +| `Current.Import` mit Phase | `Strom_L1/L2/L3` | +| `Voltage` mit Phase | `Spannung_L1/L2/L3` | +| `Power.Active.Import` mit Phase | `Leistung_L1/L2/L3` | +| `SoC` | `SoC` | +| `Temperature` | `Temperatur` | + +Import und Export werden niemals saldiert. Fehlende Messwerte gelten als unbekannt, nicht als `0`. + +## Phasenlogik + +Standard-AC-Laden wird nicht als asymmetrische Einzelphasenregelung umgesetzt. + +Regeln: + +- Dreiphasiges Laden nutzt einen gemeinsamen Stromwert pro aktiver Phase. +- Einphasiges Laden wird mit `numberPhases = 1` abgebildet. +- `phaseToUse` wird nur verwendet, wenn Station und OCPP-Version es nachweislich unterstuetzen. +- Automatische Phasenumschaltung ist standardmaessig aus. +- Umschaltung unter Last ist nicht erlaubt, wenn die Station keine sichere Umschaltung garantiert. + +## Fail-Safe + +Defaultwerte: + +- `MinCurrent`: 6 A +- `SafeCurrent`: 6 A +- `SafeOff`: 0 A +- `EMSWatchdogSeconds`: 120 s +- `OCPPHeartbeatTimeoutSeconds`: mindestens 90 s +- `CommandAckTimeoutSeconds`: 30 s + +Bei EMS-Ausfall oder OCPP-Ausfall setzt das Modul Warnungen und blockiert keine Sicherheitsgrenzen. Kritische Stoerungen fuehren zu sicherer Reduktion bis 0 A. + +## Test- und Abnahmekriterien + +Vor produktiver Nutzung sind mindestens diese Tests noetig: + +- Modulinstallation in IP-Symcon. +- Manager erkennt `Ladestation_OCPP` als Verbraucher. +- `PowerSteps` reagieren auf Managementmodus, Ladefreigabe, Fahrzeugstatus und Peak. +- OCPP-1.6-Simulator sendet `BootNotification`, `Heartbeat`, `StatusNotification`, `MeterValues`. +- `SetChargingProfile` wird korrekt als OCPP-Frame erzeugt. +- EMS-Watchdog und OCPP-Watchdog setzen klare Warnungen. +- Kein Hersteller-HTTP oder Cloud-Token wird verwendet. + +## Meilensteine + +1. M1: Scaffold, EMS-Vertrag, OCPP 1.6-Grundstruktur, Transport-Spike. +2. M2: Phasenmessung, Worst-Phase-Logik, Gruppenlimit. +3. M3: OCPP 2.0.1 Device Model, `TransactionEvent`, `numberPhases`, `phaseToUse`. +4. M4: OCPP 2.1 und bidirektionales Laden. +5. M5: Robustheit, Watchdogs, Wiederanlauf, Testfaelle. +6. M6: Monitoring, Betrieb, Admin-Doku.