Neues Ladestationsmodul mit ocpp Modul erstellt.

This commit is contained in:
2026-05-10 10:56:34 +02:00
parent 51b27d568a
commit bba6494c59
27 changed files with 3290 additions and 0 deletions
+865
View File
@@ -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:
- `<device>/online`
- `<device>/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 `<device>/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.
```
+65
View File
@@ -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`
+125
View File
@@ -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\", \"\");"
}
]
}
+62
View File
@@ -0,0 +1,62 @@
<?php
class CapabilityModel
{
public static function defaults(): array
{
return [
'smartCharging' => 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));
}
}
?>
@@ -0,0 +1,51 @@
<?php
class ChargingProfileBuilder
{
public function build(array $setpoint, string $version): array
{
$currentA = max(0.0, (float)($setpoint['effectiveCurrentA'] ?? 0));
$validTo = gmdate('Y-m-d\TH:i:s\Z', time() + 120);
if ($version === '1.6') {
return [
'connectorId' => (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]
]
]
]
]
];
}
}
?>
@@ -0,0 +1,31 @@
<?php
class DataTransferRegistry
{
private bool $allowed;
private array $entries;
public function __construct(bool $allowed, array $entries = [])
{
$this->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;
}
}
?>
+25
View File
@@ -0,0 +1,25 @@
<?php
class Diagnostics
{
public static function message(string $severity, string $source, string $text, string $code = ''): array
{
return [
'timestamp' => 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';
}
}
?>
+49
View File
@@ -0,0 +1,49 @@
<?php
class FailSafeManager
{
public function evaluate(array $state): array
{
$now = time();
$warnings = [];
$blockingReason = '';
$safePowerW = null;
$emsAge = $now - (int)($state['lastEmsUpdate'] ?? 0);
if (($state['lastEmsUpdate'] ?? 0) > 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);
}
}
?>
@@ -0,0 +1,157 @@
<?php
class MeterValueNormalizer
{
public function normalize(array $payload): array
{
$result = [
'powerImportW' => 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;
}
}
?>
+31
View File
@@ -0,0 +1,31 @@
<?php
class OCPP16Adapter
{
public function normalizeInbound(OCPPMessage $message): array
{
return [
'version' => '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');
}
}
?>
+31
View File
@@ -0,0 +1,31 @@
<?php
class OCPP201Adapter
{
public function normalizeInbound(OCPPMessage $message): array
{
return [
'version' => '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');
}
}
?>
+18
View File
@@ -0,0 +1,18 @@
<?php
class OCPP21Adapter extends OCPP201Adapter
{
public function normalizeInbound(OCPPMessage $message): array
{
$data = parent::normalizeInbound($message);
$data['version'] = '2.1';
return $data;
}
public function supportsBidirectionalPreparation(): bool
{
return true;
}
}
?>
+126
View File
@@ -0,0 +1,126 @@
<?php
class OCPPMessage
{
public string $version;
public string $direction;
public string $action;
public string $uniqueId;
public array $payload;
public int $timestamp;
public string $chargePointId;
public int $messageTypeId;
public function __construct(
string $version,
string $direction,
string $action,
string $uniqueId,
array $payload,
string $chargePointId = '',
int $messageTypeId = 0
) {
$this->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));
}
}
?>
+32
View File
@@ -0,0 +1,32 @@
<?php
class OCPPTransport
{
public static function buildEnvelope(OCPPMessage $message, int $targetInstance = 0): array
{
return [
'TargetInstance' => $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())
];
}
}
?>
+46
View File
@@ -0,0 +1,46 @@
<?php
class PhaseManager
{
public static function normalizePhaseCount(int $phases): int
{
if ($phases <= 1) {
return 1;
}
return 3;
}
public static function wattsFromCurrent(float $currentA, int $phases, float $voltage = 230.0): int
{
$phaseCount = self::normalizePhaseCount($phases);
return (int)round($currentA * $voltage * $phaseCount);
}
public static function currentFromPower(float $powerW, int $phases, float $voltage = 230.0): float
{
$phaseCount = self::normalizePhaseCount($phases);
if ($voltage <= 0 || $phaseCount <= 0) {
return 0.0;
}
return round($powerW / ($voltage * $phaseCount), 2);
}
public static function choosePhaseToUse(array $phaseCurrents): int
{
$bestPhase = 1;
$bestCurrent = null;
foreach ([1, 2, 3] as $phase) {
$value = isset($phaseCurrents[$phase]) ? (float)$phaseCurrents[$phase] : null;
if ($value === null) {
continue;
}
if ($bestCurrent === null || $value < $bestCurrent) {
$bestCurrent = $value;
$bestPhase = $phase;
}
}
return $bestPhase;
}
}
?>
@@ -0,0 +1,81 @@
<?php
class PowerStepCalculator
{
public const MODE_NEVER = 0;
public const MODE_ALWAYS = 1;
public const MODE_CONSTANT = 2;
public const MODE_SOLAR = 3;
public const MODE_MINIMAL_SOLAR = 4;
public const MODE_SOLAR_TARIFF = 5;
public const MODE_MINIMUM_ENERGY = 6;
public const MODE_TARGET_SOC = 7;
public const MODE_BIDIRECTIONAL = 8;
public function calculate(array $input): array
{
$currentPower = (int)round((float)($input['currentPowerW'] ?? 0));
if (!($input['idle'] ?? true)) {
return [$currentPower];
}
if (!($input['ladebereit'] ?? false) || ($input['manualLock'] ?? false)) {
return [0];
}
if (!($input['carDetected'] ?? false) || ($input['carFull'] ?? false)) {
return [0];
}
$mode = (int)($input['mode'] ?? self::MODE_NEVER);
if ($mode === self::MODE_NEVER) {
return [0];
}
$minCurrent = max(0.0, (float)($input['minCurrentA'] ?? 6.0));
$maxCurrent = max($minCurrent, (float)($input['maxCurrentA'] ?? 6.0));
$safeCurrent = max(0.0, (float)($input['safeCurrentA'] ?? 6.0));
$constantCurrent = max(0.0, (float)($input['constantCurrentA'] ?? $minCurrent));
$phaseCount = PhaseManager::normalizePhaseCount((int)($input['numberPhases'] ?? 3));
$voltage = (float)($input['voltage'] ?? 230.0);
$limitCurrent = $maxCurrent;
if (($input['groupLimitA'] ?? 0) > 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;
}
}
?>
@@ -0,0 +1,36 @@
<?php
class TransactionStore
{
public static function empty(): array
{
return [
'activeTransactionId' => '',
'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));
}
}
?>
+14
View File
@@ -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": ""
}
+735
View File
@@ -0,0 +1,735 @@
<?php
require_once __DIR__ . '/libs/OCPPMessage.php';
require_once __DIR__ . '/libs/OCPPTransport.php';
require_once __DIR__ . '/libs/OCPP16Adapter.php';
require_once __DIR__ . '/libs/OCPP201Adapter.php';
require_once __DIR__ . '/libs/OCPP21Adapter.php';
require_once __DIR__ . '/libs/CapabilityModel.php';
require_once __DIR__ . '/libs/MeterValueNormalizer.php';
require_once __DIR__ . '/libs/TransactionStore.php';
require_once __DIR__ . '/libs/ChargingProfileBuilder.php';
require_once __DIR__ . '/libs/PowerStepCalculator.php';
require_once __DIR__ . '/libs/PhaseManager.php';
require_once __DIR__ . '/libs/FailSafeManager.php';
require_once __DIR__ . '/libs/DataTransferRegistry.php';
require_once __DIR__ . '/libs/Diagnostics.php';
class Ladestation_OCPP extends IPSModule
{
private const ATTR_TRANSACTION = 'TransactionState';
private const ATTR_CAPABILITIES = 'Capabilities';
private const ATTR_LAST_EMS_UPDATE = 'LastEmsUpdate';
private const ATTR_LAST_OCPP_HEARTBEAT = 'LastOcppHeartbeat';
public function Create()
{
parent::Create();
$this->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);
}
}
}
?>
+25
View File
@@ -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.
+84
View File
@@ -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\", \"\");"
}
]
}
+40
View File
@@ -0,0 +1,40 @@
<?php
class ConnectionRegistry
{
public static function empty(): array
{
return [
'connections' => [],
'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;
}
}
?>
+36
View File
@@ -0,0 +1,36 @@
<?php
class OCPPFrameRouter
{
public function route(array $routes, string $chargePointId, int $evseId = 1, int $connectorId = 1, int $defaultTarget = 0): int
{
foreach ($routes as $route) {
if (!is_array($route)) {
continue;
}
if ((string)($route['ChargePointId'] ?? '') !== $chargePointId) {
continue;
}
$routeEvse = (int)($route['EVSEId'] ?? 1);
$routeConnector = (int)($route['ConnectorId'] ?? 1);
if ($routeEvse === $evseId && $routeConnector === $connectorId) {
return (int)($route['TargetInstance'] ?? 0);
}
}
return $defaultTarget;
}
public function extractChargePointId(string $path, string $fallback = ''): string
{
$path = trim($path, '/');
if ($path === '') {
return $fallback;
}
$parts = explode('/', $path);
return (string)end($parts);
}
}
?>
+29
View File
@@ -0,0 +1,29 @@
<?php
class WebSocketEndpoint
{
public static function supportSummary(bool $registerHookAvailable, string $hookPath): array
{
if ($registerHookAvailable) {
return [
'status' => '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 : '';
}
}
?>
+14
View File
@@ -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": ""
}
+220
View File
@@ -0,0 +1,220 @@
<?php
require_once __DIR__ . '/libs/WebSocketEndpoint.php';
require_once __DIR__ . '/libs/ConnectionRegistry.php';
require_once __DIR__ . '/libs/OCPPFrameRouter.php';
class OCPP_Server extends IPSModule
{
private const ATTR_CONNECTIONS = 'Connections';
public function Create()
{
parent::Create();
$this->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);
}
}
}
?>
+262
View File
@@ -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.