From fa3232c736ab6b1cd5d99406a87a96a354874f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=A4fliger?= Date: Wed, 4 Jun 2025 11:42:10 +0200 Subject: [PATCH] Auf Stand V2.001 gebracht, Sofarmodul ist jetzt integriert. --- HauptManager/module.php | 27 ++- Ladestation_v2/form.json | 26 ++- Ladestation_v2/module.php | 89 ++++++++- Manager/module.php | 54 ++++-- SofarWechselrichter/README.md | 105 +++++++++++ SofarWechselrichter/form.json | 79 ++++++++ SofarWechselrichter/module.json | 12 ++ SofarWechselrichter/module.php | 311 ++++++++++++++++++++++++++++++++ Verbraucher_extern/module.php | 45 ++++- library.json | 2 +- 10 files changed, 706 insertions(+), 44 deletions(-) create mode 100644 SofarWechselrichter/README.md create mode 100644 SofarWechselrichter/form.json create mode 100644 SofarWechselrichter/module.json create mode 100644 SofarWechselrichter/module.php diff --git a/HauptManager/module.php b/HauptManager/module.php index 9f25774..8e5c20c 100644 --- a/HauptManager/module.php +++ b/HauptManager/module.php @@ -14,6 +14,7 @@ class HauptManager extends IPSModule $this->RegisterPropertyInteger("Interval", 3); // Recheninterval $this->RegisterVariableInteger("Gesamtnetzbezug", "Gesamtnetzbezug", "", 0); + $this->RegisterVariableBoolean("Is_Peak_Shaving", "Peakshaving", "", false); // Timer registrieren $this->RegisterTimer("Timer_DistributeEnergy",$this->ReadPropertyInteger("Interval")*1000,"IPS_RequestAction(" .$this->InstanceID .', "DistributeEnergy", "");'); @@ -53,9 +54,9 @@ class HauptManager extends IPSModule $decodedUser = json_decode(GetValue($user["User_Up"]), true); - if (isset($decodedUser["Timestamp"]) && (($currentTime - $decodedUser["Timestamp"])) < 30) { + if (isset($decodedUser["Timestamp"]) && ((($currentTime - $decodedUser["Timestamp"])) < 30) && array_key_exists('User', $decodedUser)) { - foreach ($decodedUser["Users"] as $subuser) { + foreach ($decodedUser["User"] as $subuser) { $subuser['Writeback'] = $user["User_Down"]; $Verbraucher_Liste_Korr[0]["User"][] = $subuser; @@ -64,7 +65,7 @@ class HauptManager extends IPSModule $Netzbezug += $decodedUser["Netzbezug"]; }else{ - RequestAction($user["User_Down"],'{"timestamp":'.time().',"Is_Peak_Shaving":'.true.',"User":[]}'); + SetValue($user["User_Down"],'{"Timestamp":'.time().',"Is_Peak_Shaving":'.true.',"User":[]}'); } } @@ -76,8 +77,10 @@ class HauptManager extends IPSModule $Peakleistung = $this->ReadPropertyInteger("Peakleistung"); $Ueberschussleistung = $this->ReadPropertyInteger("Ueberschussleistung"); $Is_Peak_Shaving = false; + + // Fallunterscheidung ob auf Solarladen oder Peakshaving gerregelt wird. - if ($Netzbezug < ($Peakleistung - $Ueberschussleistung) / 2) { + if ($Netzbezug < ($Peakleistung + $Ueberschussleistung) / 2) { $remainingPower = -1 * (-1 * $Ueberschussleistung + $Netzbezug); $Is_Peak_Shaving = false; } else { @@ -90,7 +93,6 @@ class HauptManager extends IPSModule if (empty($Verbraucher_Liste_Korr[0]["User"])) { // Liste ist leer, daher nichts zu tun IPS_LogMessage("Manager", "aufgerufen leere liste"); - return; } @@ -120,7 +122,7 @@ class HauptManager extends IPSModule $remainingPower += $totalAktuelle_Leistung; // Wenn nicht alle Benutzer Idle = true sind, rufe SetAktuelle_Leistung mit Aktuelle_Leistung Werten auf, (alle Verbraucher behalten die aktuelle Leistung) - if (!$allIdle) { + if (!$allIdle || ($Is_Peak_Shaving != $this->GetValue("Is_Peak_Shaving"))) { // Schritt 1: Benutzer nach Writeback-Wert aufteilen $writebackArrays = []; foreach ($Verbraucher_Liste_Korr[0]["User"] as $user) { @@ -154,8 +156,11 @@ class HauptManager extends IPSModule // Schritt 4: RequestAction aufrufen RequestAction($writeback, $resultString); } + $this->SetValue("Is_Peak_Shaving", $Is_Peak_Shaving); + return; } + $this->SetValue("Is_Peak_Shaving", $Is_Peak_Shaving); // Sortiere die Verbruacher nach Priorität entweder der PV_Prio oder der Peak Prio usort($Verbraucher_Liste_Korr[0]["User"], function ($a, $b) use ( @@ -269,14 +274,15 @@ class HauptManager extends IPSModule $aktleistung = array_filter($userEnergyProv, function($entry2) use ($user, $manager) { - return $entry2["user"] == $user && $entry2["Writeback"] == $manager; }); + IPS_LogMessage("Aktuelle LEistung vor foreahc", print_r($aktleistung)); foreach($aktleistung as $entry){ $aktleistung = $entry; } - + + IPS_LogMessage("Aktuelle LEistung Mnaagear", print_r($aktleistung)); // Überprüfe, ob noch genügend verbleibende Energie für den nächsten Schritt vorhanden ist if ($remainingPower >= $powerstep - $aktleistung['Set_Leistung']) { // Aktualisiere die verbleibende Energie und die bereitgestellte Energie für den Benutzer @@ -359,6 +365,7 @@ class HauptManager extends IPSModule return $entry2["user"] == $user && $entry2["Writeback"] == $manager; }); + foreach($aktleistung as $entry){ $aktleistung = $entry; } @@ -426,7 +433,7 @@ class HauptManager extends IPSModule // Schritt 2: Foreach-Schleife pro Writeback-Array foreach ($writebackArrays as $writeback => $users) { $resultArray = [ - 'timestamp' => time(), + 'Timestamp' => time(), 'Is_Peak_Shaving' => $Is_Peak_Shaving, 'User' => [] ]; @@ -442,7 +449,7 @@ class HauptManager extends IPSModule $resultString = json_encode($resultArray); // Schritt 4: RequestAction aufrufen - RequestAction($writeback, $resultString); + SetValue($writeback, $resultString); } diff --git a/Ladestation_v2/form.json b/Ladestation_v2/form.json index e3eefbd..4244213 100644 --- a/Ladestation_v2/form.json +++ b/Ladestation_v2/form.json @@ -22,7 +22,7 @@ "value": 4 }, { - "caption": "Easee", + "caption": "Easee - Nur Solarladen", "value": 5 } ] @@ -48,7 +48,7 @@ { "type": "NumberSpinner", "name": "Zeit_Zwischen_Zustandswechseln", - "caption": "Mindestlaufzeit des Verbrauchers bei Lastschaltung", + "caption": "Mindestlaufzeit des Verbrauchers bei Lastschaltung (Für Easee Solarladen zwingend 1 Minute einstellen)", "suffix": "" }, { @@ -58,26 +58,38 @@ }, { "type": "ValidationTextBox", - "name": "ID", + "name": "Geräte-ID Smart-Me / ECarUp / Easee", "caption": "ID" }, { "type": "ValidationTextBox", - "name": "Seriennummer", + "name": "Seriennummer Smart-Me / ECarUp / Easee", "caption": "Seriennummer" }, { "type": "ValidationTextBox", - "name": "Username", + "name": "Username / Benutzernahme Solarladen", "caption": "Username" }, { "type": "PasswordTextBox", - "name": "Password", + "name": "Password -> Bei Anwendung mit Pico-Stationen", "caption": "Passwort" + }, + { + "type": "SelectVariable", + "name": "Token_Easee", + "caption": "Variable API-Token für Easee", + "suffix": "" + }, + { + "type": "SelectVariable", + "name": "Token_ECarUp", + "caption": "Variable API-Token für ECarUp", + "suffix": "" } - + ] } \ No newline at end of file diff --git a/Ladestation_v2/module.php b/Ladestation_v2/module.php index b6f1de4..a39b19b 100644 --- a/Ladestation_v2/module.php +++ b/Ladestation_v2/module.php @@ -17,6 +17,8 @@ class Ladestation_v2 extends IPSModule $this->RegisterPropertyInteger("Interval", 5); // Recheninterval $this->RegisterPropertyInteger("Max_Current_abs", 32); // Recheninterval $this->RegisterPropertyInteger("Zeit_Zwischen_Zustandswechseln", 1); + $this->RegisterPropertyInteger("Token_Easee", 0); // Recheninterval + $this->RegisterPropertyInteger("Token_ECarUp", 0); // Recheninterval // Ladestationspezifische Variabeln @@ -156,12 +158,18 @@ class Ladestation_v2 extends IPSModule $this->SetValue("Car_detected", true); if($this->GetValue("Max_Current")<6){ $this->SetValue("Car_is_full", true); + } }else{ $this->SetValue("Car_detected", false); + $this->SetValue("Leistung_Delta", 0); $this->SetValue("Car_is_full", false); - + $this->SetValue("IsTimerActive", false); + $this->SetValue("Is_1_ph", false); + $this->SetValue("Aktuelle_Leistung", 0); + $this->SetValue("IdleCounter", 0); + $this->SetValue("Idle", true); } } else{ @@ -197,6 +205,7 @@ class Ladestation_v2 extends IPSModule }else{ $this->SetValue("Car_detected", false); + $this->SetValue("Leistung_Delta", 0); $this->sendPowerToStation($this->ReadPropertyInteger("Max_Current_abs")); } } @@ -317,16 +326,53 @@ class Ladestation_v2 extends IPSModule break; case 4: - $this->SetValue("Car_detected", true); + $car_on_station = true; break; case 5: - echo "Fall 5"; + + // Benutzer eCarUp abfragen + + $authToken = GetValue($this->ReadPropertyInteger("Token_ECarUp")); + $ID = $this->ReadPropertyString("ID"); + $apiUrl = 'https://public-api.ecarup.com/v1/station/' . $ID . '/conn'.'ectors/'. $ID . '/active-charging'; + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $apiUrl); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'accept: application/json', + 'Authorization: Bearer ' . $authToken + ]); + + $response = curl_exec($ch); + curl_close($ch); + + if ($response === false) { + IPS_LogMessage("Ladestation", "Fehler beim Abrufen der eCarUp API-Daten"); + return; + } + + $data = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE) { + IPS_LogMessage("Ladestation", "Fehler beim Dekodieren der JSON-Antwort"); + return; + } + + if (isset($data['driverIdentifier']) && ($data['driverIdentifier']==$this->ReadPropertyInteger("Username"))) { + $this->SetValue("Ladeleistung_Effektiv", $this->GetValue("Power")); + $this->SetValue("Fahrzeugstatus", 2); + $car_on_station = true; + + } else { + $car_on_station = false; + $this->SetValue("Fahrzeugstatus", 1); + $this->SetValue("Ladeleistung_Effektiv", 0); + } break; default: - $this->SetValue("Car_detected", false); - $this->SetValue("Fahrzeugstatus", -1); + $this->SetValue("Car_detected", false); + $this->SetValue("Fahrzeugstatus", -1); break; } @@ -522,6 +568,9 @@ class Ladestation_v2 extends IPSModule case 4: // Nichts zu tun für Dummy station return; + case 5: + // Keine base Url nötig + break; } $ch = curl_init(); @@ -545,6 +594,21 @@ class Ladestation_v2 extends IPSModule case 4: // Nichts zu tun für Dummy station return; + case 5: + $url = "https://api.easee.com/api/chargers/".$this->ReadPropertyString("Seriennummer")."/commands/set_dynamic_charger_current"; + curl_setopt_array($ch, [ + CURLOPT_ENCODING => "", + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_CUSTOMREQUEST => "POST", + CURLOPT_POSTFIELDS => "{\"amps\":". $value .",\"minutes\":1}", + CURLOPT_HTTPHEADER => [ + "Authorization: Bearer ".GetValue($this->ReadPropertyInteger("Token_Easee"))."", + "content-type: application/*+json" + ], + ]); + default: return "Invalid station type."; } @@ -572,6 +636,21 @@ class Ladestation_v2 extends IPSModule case 4: // Nichts zu tun für Dummy station return; + case 5: + $url2 = "https://api.easee.com/api/chargers/".$this->ReadPropertyString("Seriennummer")."/commands/set_dynamic_charger_current"; + curl_setopt_array($ch, [ + CURLOPT_ENCODING => "", + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_CUSTOMREQUEST => "POST", + CURLOPT_POSTFIELDS => "{\"amps\":". $value .",\"minutes\":1}", + CURLOPT_HTTPHEADER => [ + "Authorization: Bearer ".GetValue($this->ReadPropertyInteger("Token_Easee"))."", + "content-type: application/*+json" + ], + ]); + break; default: return "Invalid station type."; } diff --git a/Manager/module.php b/Manager/module.php index 5b79bb3..8422eff 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -17,6 +17,8 @@ class Manager extends IPSModule $this->RegisterPropertyInteger("DatenZuruck", 0); // Initialisierung mit 0 $this->RegisterPropertyInteger("Interval", 2); // Recheninterval + $this->RegisterVariableBoolean("Is_Peak_Shaving", false); + // Timer registrieren $this->RegisterTimer("Timer_DistributeEnergy",$this->ReadPropertyInteger("Interval")*1000,"IPS_RequestAction(" .$this->InstanceID .', "DistributeEnergy", "");'); @@ -38,18 +40,44 @@ class Manager extends IPSModule $data = json_decode(GetValue($this->ReadPropertyInteger("DatenZuruck")), true); IPS_LogMessage("Manager", print_r($data)); - IPS_LogMessage("Manager", $data["timestamp"]); + IPS_LogMessage("Manager", $data["Timestamp"]); - if (isset($data["timestamp"])) { - $timestamp = $data["timestamp"]; + if (isset($data["Timestamp"])) { + $timestamp = $data["Timestamp"]; $currentTime = time(); IPS_LogMessage("Manager", ($currentTime - $timestamp)); + IPS_LogMessage("Manager", "im here ist so halb gut"); if (($currentTime - $timestamp) < 3600) { $this->DistributeEnergy_Extern(); - } - } else { + IPS_LogMessage("Manager", "im here ist gut"); + + } else { + + $sendarray = [ + "Netzbezug" => GetValue($this->ReadPropertyInteger("Netzbezug")), + "Timestamp" => time() + ]; + + SetValue($this->ReadPropertyInteger("DatenHoch"), json_encode($sendarray)); + $this->DistributeEnergy(); + IPS_LogMessage("Manager", "im here ist schlecht"); + + } + + } else { + + $sendarray = [ + "Netzbezug" => GetValue($this->ReadPropertyInteger("Netzbezug")), + "Timestamp" => time() + ]; + + SetValue($this->ReadPropertyInteger("DatenHoch"), json_encode($sendarray)); + + $this->DistributeEnergy(); + IPS_LogMessage("Manager", "im here ist schlecht"); + } }else{ @@ -73,7 +101,7 @@ class Manager extends IPSModule $Ueberschussleistung = $this->ReadPropertyInteger("Ueberschussleistung"); // Fallunterscheidung ob auf Solarladen oder Peakshaving gerregelt wird. - if ($Netzbezug < ($Peakleistung - $Ueberschussleistung) / 2) { + if ($Netzbezug < ($Peakleistung + $Ueberschussleistung) / 2) { $remainingPower = -1 * (-1 * $Ueberschussleistung + $Netzbezug); $Is_Peak_Shaving = false; } else { @@ -141,7 +169,7 @@ class Manager extends IPSModule IPS_LogMessage("Manager", "nciht idle"); } - if(in_array(0, $powerSteps, true)){ + if(in_array(0, $powerSteps, true)){ // Addiere die aktuell bereits verwendete Leistung auf, um sie bei der verteilung zu berücksichtigen $totalAktuelle_Leistung += ($Aktuelle_Leistung-$delta); @@ -158,16 +186,18 @@ class Manager extends IPSModule return; } - // Wenn nicht alle Benutzer Idle = true sind, rufe SetAktuelle_Leistung mit Aktuelle_Leistung Werten auf, (alle Verbraucher behalten die aktuelle Leistung) - if (!$allIdle) { + // Wenn nicht alle Benutzer Idle = true sind, oder sich der zustand von Is_Peak_shaving gerade verändert hat rufe SetAktuelle_Leistung mit Aktuelle_Leistung Werten auf, (alle Verbraucher behalten die aktuelle Leistung) + if (!$allIdle || ($Is_Peak_Shaving != $this->GetValue("Is_Peak_Shaving"))) { foreach ($filteredVerbraucher as $user) { IPS_RequestAction($user["InstanceID"],"SetAktuelle_Leistung",$user["Aktuelle_Leistung"]); IPS_LogMessage("Manager", "aufgerufen nicht alle idle"); } + $this->SetValue("Is_Peak_Shaving", $Is_Peak_Shaving); + return; } - + $this->SetValue("Is_Peak_Shaving", $Is_Peak_Shaving); // Sortiere die Verbruacher nach Priorität entweder der PV_Prio oder der Peak Prio usort($filteredVerbraucher, function ($a, $b) use ( $Is_Peak_Shaving @@ -418,13 +448,13 @@ class Manager extends IPSModule $sendarray = []; $sendarray = [ - "Users" => $filteredVerbraucher, + "User" => $filteredVerbraucher, "Netzbezug" => $Netzbezug, "Timestamp" => time() ]; - RequestAction($this->ReadPropertyInteger("DatenHoch"), json_encode($sendarray)); + SetValue($this->ReadPropertyInteger("DatenHoch"), json_encode($sendarray)); $answerArray = json_decode(GetValue($this->ReadPropertyInteger("DatenZuruck")), true); diff --git a/SofarWechselrichter/README.md b/SofarWechselrichter/README.md new file mode 100644 index 0000000..4fa144c --- /dev/null +++ b/SofarWechselrichter/README.md @@ -0,0 +1,105 @@ +2. Starte IP-Symcon neu bzw. klicke in der Konsole auf „Module aktualisieren“. + +Anschließend erscheint in der Instanzliste der neue Gerätetyp **Sofar Wechselrichter**. + +--- + +## Konfiguration + +1. **Instanz anlegen** +- Gehe in der IPS-Konsole auf „Instanzen hinzufügen“ → Hersteller: (falls sichtbar) → Modul: **Sofar Wechselrichter**. +- Vergib einen sinnvollen Namen und eine Beschreibung. + +2. **Einstellungen in den Eigenschaften** +- **Logger-Seriennummer**: + Gib hier die „Logger-Nummer“ deines Sofar-Wechselrichters ein (Dezimal). +- **Abfragezyklus (Sekunden)**: + Intervall in Sekunden, in dem zyklisch alle eingetragenen Register abgefragt werden. + Ein Wert von `0` deaktiviert den Timer (keine zyklischen Abfragen). +- **Register-Tabelle**: + Hier definierst du beliebig viele Zeilen, jeweils mit: + 1. **Register-Nummer** (dezimal) + 2. **Bezeichnung** (z. B. „Gesamtproduktion“ oder „Spannung Phase L1“) + 3. **Skalierungs-faktor** (z. B. `0.1`, `1`, `10` usw.) + + Wird beispielsweise für Register `1476` als Bezeichnung „Gesamtproduktion“ mit Skalierungsfaktor `1` eingetragen, + so liest das Modul alle 60 Sekunden (oder den von dir gewählten Zyklus) Register 1476, + multipliziert das rohe UINT16-Ergebnis mit `1` und legt den Wert in einer IPS-Variable an. + +3. **Speichern/Übernehmen** +Klicke auf „Übernehmen“, um die Änderungen zu übernehmen. +Das Modul legt automatisch untergeordnete IPS-Variablen an: + +- **Vorheriger Wert (Register 1160)** + → INT16BE, unverändert (analog zur alten Node-RED-Logik). + Diese Variable heißt intern `Vorheriger Wert` und wird automatisch gepflegt. +- **Alle weiteren Einträge aus der Register-Tabelle** + → Für jede Zeile wird eine Float-Variable mit der von dir angegebenen „Bezeichnung“ angelegt. + Der Variablen-Identifier lautet automatisch `Reg` (z. B. `Reg1476`). + +--- + +## Funktionsweise + +1. **Initialisierung (ApplyChanges)** +- Liest den Abfragezyklus aus den Moduleigenschaften und initialisiert den Timer. +- Legt eine Integer-Variable `Vorheriger Wert` (Reg 1160) an. +- Legt für jede Zeile in der Register-Tabelle eine Float-Variable an (Ident `Reg`). + +2. **Zyklische Abfrage (Timer-Callback)** +- **Register 1160 (INT16BE)** + → Wird als „Vorheriger Wert“ in die Variable geschrieben (signed interpretiert). +- **Alle weiteren Register aus der Tabelle** + → Jedes Register wird per Modbus-ähnlichem TCP-Paketaustausch abgefragt, als UINT16 ausgelesen, + mit dem angegebenen Skalierungsfaktor multipliziert und in der zugehörigen Float-Variable gespeichert. + +3. **Kommunikation** +- TCP-Verbindung zu `192.168.0.0:8899` (feste IP im Code). +- Der Aufruf von `readRegister()` baut ein „Out_Frame“ wie in Node-RED, + rechnet CRC16-Modbus über die letzten 6 Bytes, hängt eine Summen-Checksum + 0x15 an, + sendet das Paket, liest die Antwort, schneidet exakt 2 Daten-Bytes heraus und liefert sie zurück. + +--- + +## Beispiel: Register 1476 („Gesamtproduktion“) + +- **Register-Tabelle** +| Register‐Nummer | Bezeichnung | Skalierungsfaktor | +| --------------: | :------------------ | ----------------: | +| 1476 | Gesamtproduktion | 1 | + +- **Ergebnis** +- Eine Float-Variable mit der Bezeichnung „Gesamtproduktion“ wird angelegt. +- Wenn der PollInterval auf `60` Sekunden steht, liest das Modul alle 60 Sekunden das Register 1476, + skaliert mit 1 und schreibt den numerischen Wert in `Reg1476`. + +--- + +## Fehlersuche + +- Falls die Variable „Vorheriger Wert“ immer denselben Wert liefert oder ein Lesefehler auftritt, prüfe bitte: +1. **Logger-Nummer**: Ist sie korrekt (Dezimal)? +2. **Netzwerk/Firewall**: Kann Symcon die Adresse `192.168.0.100:8899` erreichen? +3. **Debug-Ausgaben**: + – Öffne in der Konsole „Kernel-Log“ → Filter „SofarWechselrichter“. + – Dort werden WARNs und ERRs protokolliert, falls z. B. keine Antwort kommt oder das Datenpaket inkorrekt ist. + +- Falls du andere Datentypen brauchst (z. B. INT16 für Register außerhalb 1160), definiere sie analog als separate Zeile: +– Trage die `Register‐Nummer` ein, gib als Skalierungsfaktor `1` (oder `0.1` etc.) an. +– Der absolute Rohwert wird stets als UINT16 interpretiert (0–65535). +– Solltest du negative INT16 benötigen, kannst du nachträglich einfach die Variable „Vorheriger Wert“ (Reg 1160) + als Beispiel nehmen und in einem Script umrechnen (Werte über 32767 → –32768 + Rest). + +--- + +## Versionshistorie + +- **1.0** +- Erstveröffentlichung: + • Zyklische Abfrage beliebiger Register in einer Matrix konfigurieren + • Automatische Anlage von Variablen für jeden Eintrag + • Spezieller „Vorheriger Wert“ (Register 1160 als INT16) + +--- + +*Ende der Dokumentation.* diff --git a/SofarWechselrichter/form.json b/SofarWechselrichter/form.json new file mode 100644 index 0000000..7294a43 --- /dev/null +++ b/SofarWechselrichter/form.json @@ -0,0 +1,79 @@ +{ + "elements": [ + { + "type": "Label", + "caption": "Sofar Wechselrichter Konfiguration" + }, + { + "type": "ValidationTextBox", + "name": "IPAddress", + "caption": "Inverter IP-Adresse" + }, + { + "type": "ValidationTextBox", + "name": "LoggerNumber", + "caption": "Logger-Seriennummer" + }, + { + "type": "NumberSpinner", + "name": "PollInterval", + "caption": "Abfragezyklus (Sekunden)", + "minimum": 1, + "suffix": "s" + }, + { + "type": "List", + "name": "Registers", + "caption": "Register-Tabelle", + "add": "Neues Register hinzufügen", + "delete": "Lösche Eintrag", + "columns": [ + { + "caption": "Register-Nummer", + "name": "RegisterNumber", + "width": "200px", + "add": 0, + "edit": { + "type": "NumberSpinner", + "minimum": 0 + } + }, + { + "caption": "Bezeichnung", + "name": "Label", + "width": "300px", + "add": "", + "edit": { + "type": "ValidationTextBox" + } + }, + { + "caption": "Skalierungs-faktor", + "name": "ScalingFactor", + "width": "200px", + "add": 1, + "edit": { + "type": "NumberSpinner", + "digits": 4, + "minimum": -999999, + "maximum": 999999 + } + }, + { + "caption": "Endian", + "name": "Endian", + "width": "80px", + "add": "BE", + "edit": { + "type": "Select", + "options": [ + { "caption": "BE", "value": "BE" }, + { "caption": "LE", "value": "LE" } + ] + } + } + ] + } + ], + "actions": [] +} diff --git a/SofarWechselrichter/module.json b/SofarWechselrichter/module.json new file mode 100644 index 0000000..1d594c2 --- /dev/null +++ b/SofarWechselrichter/module.json @@ -0,0 +1,12 @@ +{ + "id": "{C26E97C8-BA00-0563-6B14-B807C5ACE17F}", + "name": "SofarWechselrichter", + "type": 3, + "vendor": "Belevo AG", + "aliases": [], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "GEF", + "url": "" +} diff --git a/SofarWechselrichter/module.php b/SofarWechselrichter/module.php new file mode 100644 index 0000000..08eeeae --- /dev/null +++ b/SofarWechselrichter/module.php @@ -0,0 +1,311 @@ +RegisterPropertyString('IPAddress', ''); + $this->RegisterPropertyString('LoggerNumber', '0'); // als String + $this->RegisterPropertyInteger('PollInterval', 60); + $this->RegisterPropertyString('Registers', '[]'); // JSON-String + + // Timer für zyklische Abfragen (per RequestAction("Query")) + $script = 'IPS_RequestAction(' . $this->InstanceID . ', "Query", "");'; + $this->RegisterTimer('QueryTimer', 0, $script); + } + + public function ApplyChanges() + { + parent::ApplyChanges(); + // Timer-Intervall (ms) + $intervalSec = $this->ReadPropertyInteger('PollInterval'); + $intervalMs = ($intervalSec > 0) ? $intervalSec * 1000 : 0; + $this->SetTimerInterval('QueryTimer', $intervalMs); + + // Aktuelle Registerliste + $registers = json_decode($this->ReadPropertyString('Registers'), true); + if (!is_array($registers)) { + $registers = []; + } + + // 1) Variablen anlegen (neu hinzugekommene) + $position = 10; + foreach ($registers as $entry) { + $regNo = (int) $entry['RegisterNumber']; + $label = trim($entry['Label']); + if ($regNo < 0 || $label === '') { + continue; + } + $ident = 'Reg' . $regNo; + if (!$this->VariableExists($ident)) { + $this->RegisterVariableFloat($ident, $label, '', $position); + } + $position += 10; + } + + // 2) Variablen löschen (falls entfernt) + // Alle Kinder-IDs durchlaufen, Variablen mit Ident "Reg" entfernen, + // wenn sie nicht mehr in $registers stehen. + $validIdents = []; + foreach ($registers as $entry) { + $validIdents[] = 'Reg' . ((int)$entry['RegisterNumber']); + } + + $children = IPS_GetChildrenIDs($this->InstanceID); + foreach ($children as $childID) { + // Nur Variablen berücksichtigen + $obj = IPS_GetObject($childID); + if ($obj['ObjectType'] !== 2) { + continue; + } + $ident = $obj['ObjectIdent']; // VariableIdent + if (substr($ident, 0, 3) === 'Reg') { + if (!in_array($ident, $validIdents)) { + IPS_DeleteVariable($childID); + } + } + } + } + + public function Destroy() + { + parent::Destroy(); + } + + /** + * Wird aufgerufen durch IPS_RequestAction über Timer + */ + public function RequestAction($Ident, $Value) + { + switch ($Ident) { + case 'Query': + $this->Query(); + break; + + default: + throw new Exception('Ungültiger Ident: ' . $Ident); + } + } + + /** + * Zyklische Abfrage aller definierten Register + */ + private function Query(): void + { + $ip = trim($this->ReadPropertyString('IPAddress')); + $loggerNumberStr = trim($this->ReadPropertyString('LoggerNumber')); + + // 1) Validierung IP + if (!filter_var($ip, FILTER_VALIDATE_IP)) { + $this->LogMessage('Abbruch: Ungültige IP = "' . $ip . '"', KL_WARNING); + return; + } + // 2) Validierung LoggerNumber (Dezimal-String, > 0) + if ($loggerNumberStr === '' || !ctype_digit($loggerNumberStr) || bccomp($loggerNumberStr, '1') < 0) { + $this->LogMessage('Abbruch: Ungültige LoggerNumber = "' . $loggerNumberStr . '"', KL_WARNING); + return; + } + + // 3) Register-Liste einlesen + $registers = json_decode($this->ReadPropertyString('Registers'), true); + if (!is_array($registers) || count($registers) === 0) { + // Keine Register definiert + return; + } + + // 4) Für jedes Register: auslesen, skalieren, in Variable schreiben + foreach ($registers as $entry) { + $regNo = (int) $entry['RegisterNumber']; + $label = trim((string)$entry['Label']); + $scale = (string) $entry['ScalingFactor']; // kann negativ sein + $endian = strtoupper(trim((string)$entry['Endian'])); + $ident = 'Reg' . $regNo; + + if ($regNo < 0 || $label === '') { + continue; + } + + try { + $bytes = $this->readRegister($ip, $loggerNumberStr, $regNo); + // Wert als UINT16 (BE oder LE) + if ($endian === 'LE') { + $arr = unpack('vvalue', $bytes); // Little-Endian + } else { + $arr = unpack('nvalue', $bytes); // Big-Endian + } + $valueRaw = $arr['value']; + // bc* für sichere Multiplikation auch bei großen Zahlen / negativer Skalierung + $value = bcmul((string)$valueRaw, (string)$scale, 4); + SetValueFloat($this->GetIDForIdent($ident), (float)$value); + } catch (Exception $e) { + $this->LogMessage("Fehler Lesen Reg {$regNo}: " . $e->getMessage(), KL_WARNING); + } + } + } + + /** + * Liest ein einzelnes Register per Modbus-ähnlichem TCP (2 Bytes zurück) + * + * @param string $ip Inverter-IP + * @param string $serial_nr_str Logger-Seriennummer als Dezimal-String + * @param int $reg Register-Adresse + * @return string 2 Byte binär + * @throws Exception Bei Kommunikationsfehlern + */ + private function readRegister( + string $ip, + string $serial_nr_str, + int $reg + ): string + { + // 1) Out_Frame ohne CRC aufbauen + $oFrame = 'a5170010450000'; + + // Dezimal-String → 8-stellige Hex + $hexSN8 = $this->decStringToHex8($serial_nr_str); + // Byte-Little-Endian: jeweils 2 Zeichen umkehren + $hexSNbytes = [ + substr($hexSN8, 6, 2), + substr($hexSN8, 4, 2), + substr($hexSN8, 2, 2), + substr($hexSN8, 0, 2), + ]; + $oFrame .= implode('', $hexSNbytes); + + // Data-Field (16 Hex-Zeichen konstant) + $oFrame .= '020000000000000000000000000000'; + + // Business-Field: 01 03 + Start-Register + Anzahl Register (1) + $startHex = str_pad(dechex($reg), 4, '0', STR_PAD_LEFT); + $numHex = str_pad(dechex(1), 4, '0', STR_PAD_LEFT); + $oFrame .= '0103' . $startHex . $numHex; + + // 2) CRC16-Modbus über letzte 6 Bytes + $crcInputHex = substr($oFrame, -12); + $crcInputBin = hex2bin($crcInputHex); + if ($crcInputBin === false) { + throw new Exception("Ungültiges Hex in CRC-Input: {$crcInputHex}"); + } + $crcValue = $this->calculateCRC16Modbus($crcInputBin); + $crcHex = strtoupper(str_pad(dechex($crcValue), 4, '0', STR_PAD_LEFT)); + $crcSwapped = substr($crcHex, 2, 2) . substr($crcHex, 0, 2); + $oFrameWithCRC = $oFrame . strtolower($crcSwapped); + + // 3) Summen-Checksum (Bytes ab Index 1) + 0x15 + $l = strlen($oFrameWithCRC) / 2; + $bArr = []; + for ($i = 0; $i < $l; $i++) { + $byteHex = substr($oFrameWithCRC, 2 * $i, 2); + $bArr[$i] = hexdec($byteHex); + } + $crcSum = 0; + for ($i = 1; $i < $l; $i++) { + $crcSum += $bArr[$i]; + $crcSum &= 0xFF; + } + $bArr[$l] = $crcSum; + $bArr[$l+1] = 0x15; + + $frameBin = ''; + foreach ($bArr as $b) { + $frameBin .= chr($b); + } + + // 4) TCP-Verbindung öffnen & Paket senden (Port fest 8899) + $port = 8899; + $fp = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 5); + if (!$fp) { + throw new Exception("Verbindung zu {$ip}:{$port} fehlgeschlagen ({$errno}: {$errstr})"); + } + fwrite($fp, $frameBin); + stream_set_timeout($fp, 2); + + // 5) Antwort einlesen + $response = ''; + while (!feof($fp)) { + $chunk = fread($fp, 1024); + if ($chunk === false || $chunk === '') { + break; + } + $response .= $chunk; + } + fclose($fp); + if ($response === '') { + throw new Exception("Keine Antwort vom Inverter erhalten."); + } + + // 6) Slice-Logik: l = 6 ⇒ 2 Bytes Daten + $lModbus = 6; + $numBytes = 2; + if (strlen($response) < $lModbus) { + throw new Exception("Unerwartet kurze Antwort (< {$lModbus} Bytes)."); + } + $dataBytes = substr($response, -$lModbus, $numBytes); + if (strlen($dataBytes) < 2) { + throw new Exception("Data-Segment enthält weniger als 2 Bytes."); + } + return $dataBytes; + } + + /** + * Wandelt einen Dezimal-String in einen 8-stelligen Hex-String um. + * + * @param string $decString + * @return string 8-stellige Hex (uppercase) + */ + private function decStringToHex8(string $decString): string + { + $num = ltrim($decString, '0'); + if ($num === '') { + return '00000000'; + } + $hex = ''; + while (bccomp($num, '0') > 0) { + $mod = bcmod($num, '16'); + $digit = dechex((int)$mod); + $hex = strtoupper($digit) . $hex; + $num = bcdiv($num, '16', 0); + } + return str_pad($hex, 8, '0', STR_PAD_LEFT); + } + + /** + * Berechnet CRC16-Modbus (Init=0xFFFF, Poly=0xA001) über Binärdaten. + */ + private function calculateCRC16Modbus(string $binaryData): int + { + $crc = 0xFFFF; + $len = strlen($binaryData); + for ($pos = 0; $pos < $len; $pos++) { + $crc ^= ord($binaryData[$pos]); + for ($i = 0; $i < 8; $i++) { + if (($crc & 0x0001) !== 0) { + $crc >>= 1; + $crc ^= 0xA001; + } else { + $crc >>= 1; + } + } + } + return $crc; + } + + /** + * Prüft, ob eine Variable mit Ident existiert. + */ + private function VariableExists(string $ident): bool + { + $vid = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); + return ($vid !== false && IPS_VariableExists($vid)); + } +} diff --git a/Verbraucher_extern/module.php b/Verbraucher_extern/module.php index 718597f..f8fc380 100644 --- a/Verbraucher_extern/module.php +++ b/Verbraucher_extern/module.php @@ -68,10 +68,10 @@ class Verbraucher_extern extends IPSModule private function berechneKombinationen(array $verbraucherListe){ - $kombinationen = []; + $kombinationen = [0]; foreach ($verbraucherListe as $verbraucher) { - if (GetValue($verbraucher['Read_Var']) == 1) { + if (GetValue($verbraucher['Read_Var']) == true) { $tempListe = []; if(empty($kombinationen)){ $kombinationen[] = $verbraucher['P_Nenn']; @@ -87,6 +87,31 @@ class Verbraucher_extern extends IPSModule return array_values(array_unique($kombinationen)); } + private function findCombinationRecursive($power, $values, $currentCombination, $startIndex, &$result) { + if ($power == 0) { + $result = $currentCombination; + return true; + } + if ($power < 0) { + return false; + } + for ($i = $startIndex; $i < count($values); $i++) { + $currentCombination[] = $values[$i]; + if ($this->findCombinationRecursive($power - $values[$i], $values, $currentCombination, $i + 1, $result)) { + return true; + } + array_pop($currentCombination); + } + return false; + } + + + private function findCombination($power, $values) { + $result = []; + $found = $this->findCombinationRecursive($power, $values, [], 0, $result); + return $found ? $result : null; + } + private function find($target, $values, $current, &$result) { if ($target == 0) { @@ -106,19 +131,21 @@ class Verbraucher_extern extends IPSModule { $values = json_decode($this->GetValue("PowerSteps")); $result = []; - - $this->find($this->GetValue("Power"), $values, [], $result); + + $firstCombination = $this->findCombination($this->GetValue("Power"), json_decode($this->GetValue("PowerSteps"))); + + //$this->find($this->GetValue("Power"), $values, [], $result); $verbraucherListe = json_decode($this->ReadPropertyString("Verbraucher_Liste"), true); - $firstCombination = $result[0]; + //$firstCombination = $result[0]; - foreach ($verbraucherListe as &$verbraucher) { + foreach ($verbraucherListe as $verbraucher) { if (in_array($verbraucher['P_Nenn'], $firstCombination)) { - SetValue($verbraucher['Write_Var'], 1); + RequestAction($verbraucher['Write_Var'], true); } else { - SetValue($verbraucher['Write_Var'], 0); + RequestAction($verbraucher['Write_Var'], false); } } @@ -141,7 +168,7 @@ class Verbraucher_extern extends IPSModule public function GetCurrentData(bool $Peak) { $this->SetValue("Is_Peak_Shaving", $Peak); - SetValue($this->ReadPropertyInteger("Is_Peak"), $Peak); + RequestAction($this->ReadPropertyInteger("Is_Peak"), $Peak); $verbraucherListe = json_decode($this->ReadPropertyString("Verbraucher_Liste"), true); $kombinationen = $this->berechneKombinationen($verbraucherListe); diff --git a/library.json b/library.json index 8670923..e7b8a9a 100644 --- a/library.json +++ b/library.json @@ -6,7 +6,7 @@ "compatibility": { "version": "8.0" }, - "version": "2.000", + "version": "2.001", "build": 0, "date": 0 } \ No newline at end of file