Neues Ladestationsmodul mit ocpp Modul erstellt.
This commit is contained in:
@@ -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.
|
||||
```
|
||||
@@ -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`
|
||||
@@ -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\", \"\");"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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())
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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.
|
||||
@@ -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\", \"\");"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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 : '';
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user