Auf Stand V2.001 gebracht, Sofarmodul ist jetzt integriert.

This commit is contained in:
2025-06-04 11:42:10 +02:00
parent b7bb55b7fa
commit fa3232c736
10 changed files with 706 additions and 44 deletions

View File

@@ -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);
}

View File

@@ -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,25 +58,37 @@
},
{
"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": ""
}
]

View File

@@ -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.";
}

View File

@@ -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);

View File

@@ -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<Registernummer>` (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<Nummer>`).
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**
| RegisterNummer | 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 `RegisterNummer` ein, gib als Skalierungsfaktor `1` (oder `0.1` etc.) an.
Der absolute Rohwert wird stets als UINT16 interpretiert (065535).
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.*

View File

@@ -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": []
}

View File

@@ -0,0 +1,12 @@
{
"id": "{C26E97C8-BA00-0563-6B14-B807C5ACE17F}",
"name": "SofarWechselrichter",
"type": 3,
"vendor": "Belevo AG",
"aliases": [],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "GEF",
"url": ""
}

View File

@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
/**
* Sofar Wechselrichter Modul (IP-Symcon)
*
* - LoggerNumber wird als String gehalten, um Overflow zu vermeiden.
* - Negative Skalierungen sind erlaubt.
* - Beim Löschen eines Registers wird die zugehörige Variable automatisch entfernt.
*/
class SofarWechselrichter extends IPSModule
{
public function Create()
{
parent::Create();
// Moduleigenschaften
$this->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<Zahl>" 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));
}
}

View File

@@ -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) {
@@ -107,18 +132,20 @@ 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);

View File

@@ -6,7 +6,7 @@
"compatibility": {
"version": "8.0"
},
"version": "2.000",
"version": "2.001",
"build": 0,
"date": 0
}