Alte Module, welche nicht mehr benötigt, entfernt.

Batteriemodul wurde mit Goodwe, Sig Energy und Solaredge klar im Modul abgegrenzt.
This commit is contained in:
mh
2026-03-03 11:36:57 +01:00
parent 4042614f1d
commit 0c75e36ce8
21 changed files with 277 additions and 2296 deletions
+26 -8
View File
@@ -17,15 +17,34 @@
"suffix": "Sekunden"
},
{
"type": "NumberSpinner",
"type":"Select",
"name":"Batterietyp",
"caption":"Batterietyp",
"options":[
{
"caption":"Goodwe",
"value":1
},
{
"caption":"Solaredge",
"value":2
},
{
"caption":"Sig Energy",
"value":3
}
]
},
{
"type": "SelectVariable",
"name": "MaxBatterieleistung",
"caption": "Maximale Batterieleistung",
"caption": "Maximale Ladeleistung",
"suffix": ""
},
{
"type": "NumberSpinner",
"type": "SelectVariable",
"name": "MaxNachladen",
"caption": "Maximum Nachladen",
"caption": "Maximum Nachladeleistung",
"suffix": ""
},
{
@@ -52,7 +71,7 @@
},
{
"caption":"Durch EMS Symcon",
"value":4
"value":2
}
]
},
@@ -68,9 +87,8 @@
"caption": "Variable mit dem zu regelnden Netzbezug"
},
{
"type": "SelectVariable",
"name": "Batterieleistung_Effektiv",
"caption": "Effektive, aktuelle Batterieleistung"
"type": "Label",
"caption": "Je nach Wr-Type bekommt Laden_Entlade andere Werte.\nGoodwe: Laden=11, Entladen=12,\nSolaredge: Laden=3, Entladen=4,\nSig Energy: Laden=3, Entladen=6, P in kW"
}
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"id": "{F6C6A4B2-C5BB-4D94-2629-01D8B0D4CAF5}",
"id": "{166B9E49-882B-ADED-F256-4DD4CC24DF6C}",
"name": "Batterie",
"type": 3,
"vendor": "Belevo AG",
+223 -41
View File
@@ -13,16 +13,15 @@ class Batterie extends IPSModule
$this->RegisterPropertyInteger("MinimumEntladen",0);
$this->RegisterPropertyInteger("Batterieladezustand",0);
$this->RegisterPropertyInteger("Batteriemanagement", 1);
$this->RegisterPropertyInteger("Batterietyp", 1);
$this->RegisterPropertyInteger("MaxNachladen",0);
$this->RegisterPropertyInteger("Netzbezug", 0); // Initialisierung mit 0
$this->RegisterPropertyInteger("Interval", 2); // Recheninterval
$this->RegisterPropertyInteger("Batterieleistung_Effektiv", 0); // Recheninterval
// Variabeln für Kommunkation mit Manager
$this->RegisterVariableFloat("Entladeleistung","Entladeleistung", "",0);
$this->RegisterVariableInteger("Batteriemanagement_Variabel","Batteriemanagement_Variabel", "",0);
$this->RegisterVariableInteger("Laden3_Entladen4","Laden3_Entladen4", "",3);
$this->RegisterVariableFloat("Ladeleistung","Ladeleistung", "",0);
$this->RegisterVariableInteger("Laden_Entladen","Laden_Entladen", "",3);
$this->RegisterVariableInteger("Aktuelle_Leistung", "Aktuelle_Leistung", "", 0);
$this->RegisterVariableString("PowerSteps", "PowerSteps");
$this->RegisterVariableBoolean("Idle", "Idle", "", 0);
@@ -58,49 +57,143 @@ class Batterie extends IPSModule
$batterieManagement = $this->ReadPropertyInteger("Batteriemanagement");
$this->SetValue("Batteriemanagement_Variabel", $batterieManagement);
$this->SetTimerInterval("Timer_Do_UserCalc_Battery",$this->ReadPropertyInteger("Interval")*1000);
$batterietyp = $this->ReadPropertyInteger("Batterietyp");
switch ($batterietyp) {
case 1: // Goodwe
$this->MaintainVariable("Goodwe_EntLadeleistung", "Goodwe_EntLadeleistung", VARIABLETYPE_FLOAT, "", 10, true);
$this->MaintainVariable("Ladeleistung", "Ladeleistung", VARIABLETYPE_FLOAT, "", 11, false);
$this->MaintainVariable("Entladeleistung", "Entladeleistung", VARIABLETYPE_FLOAT, "", 13, false);
$this->MaintainVariable("Laden_Entladen", "Laden_Entladen", VARIABLETYPE_INTEGER, "", 12, true);
break;
case 2: // Solaredge
$this->MaintainVariable("Goodwe_EntLadeleistung", "Goodwe_EntLadeleistung", VARIABLETYPE_FLOAT, "", 10, false);
$this->MaintainVariable("Ladeleistung", "Ladeleistung", VARIABLETYPE_FLOAT, "", 11, true);
$this->MaintainVariable("Entladeleistung", "Entladeleistung", VARIABLETYPE_FLOAT, "", 13, true);
$this->MaintainVariable("Laden_Entladen", "Laden_Entladen", VARIABLETYPE_INTEGER, "", 12, true);
break;
case 3: // SiG Energy
$this->MaintainVariable("Goodwe_EntLadeleistung", "Goodwe_EntLadeleistung", VARIABLETYPE_FLOAT, "", 10, false);
$this->MaintainVariable("Ladeleistung", "Ladeleistung", VARIABLETYPE_FLOAT, "", 11, true);
$this->MaintainVariable("Entladeleistung", "Entladeleistung", VARIABLETYPE_FLOAT, "", 13, true);
$this->MaintainVariable("Laden_Entladen", "Laden_Entladen", VARIABLETYPE_INTEGER, "", 12, true);
break;
default:
// Sicherheit: alles weg
$this->MaintainVariable("Goodwe_EntLadeleistung", "Goodwe_EntLadeleistung", VARIABLETYPE_FLOAT, "", 10, false);
$this->MaintainVariable("Ladeleistung", "Ladeleistung", VARIABLETYPE_FLOAT, "", 11, false);
$this->MaintainVariable("Laden_Entladen", "Laden_Entladen", VARIABLETYPE_INTEGER, "", 12, false);
$this->MaintainVariable("Entladeleistung", "Entladeleistung", VARIABLETYPE_FLOAT, "", 13, false);
break;
}
$maxBatVar = $this->ReadPropertyInteger("MaxBatterieleistung");
$maxNachVar = $this->ReadPropertyInteger("MaxNachladen");
if ($maxBatVar > 0) {
$this->RegisterMessage($maxBatVar, VM_UPDATE);
}
if ($maxNachVar > 0) {
$this->RegisterMessage($maxNachVar, VM_UPDATE);
}
}
public function MessageSink($TimeStamp, $SenderID, $Message, $Data)
{
if ($Message !== VM_UPDATE) {
return;
}
$maxBatVar = $this->ReadPropertyInteger("MaxBatterieleistung");
$maxNachVar = $this->ReadPropertyInteger("MaxNachladen");
if ($SenderID === $maxBatVar || $SenderID === $maxNachVar) {
// PowerSteps sofort neu berechnen (mit aktuellem Peak-Status)
$this->GetCurrentData($this->GetValue("Is_Peak_Shaving"));
}
}
private function GeneratePowerSteps($additionalValue)
{
$maxleistung = $this->ReadPropertyInteger("MaxBatterieleistung");
$stepSize = 250; // Schrittgröße
$stepSizeSmall = 50; // Kleine Schrittgröße
// Array direkt als Range erzeugen (schneller als Schleife)
$array_powersteps = range(-$maxleistung, $maxleistung, $stepSize);
$maxleistung_raw = GetValue($this->ReadPropertyInteger("MaxBatterieleistung"));
$nachladen_raw = GetValue($this->ReadPropertyInteger("MaxNachladen"));
// Nächstgelegenen Wert direkt bestimmen (rundet auf den nächsten Step)
$closestValue = round($additionalValue / $stepSize) * $stepSize;
$stepSize = 250; // Grobe Schrittgröße
$stepSizeSmall = 50; // Feine Schrittgröße
// Falls der Wert nicht im Bereich liegt, abbrechen
if (!in_array($closestValue, $array_powersteps)) {
return $array_powersteps;
// Grenzen auf 50er abrunden (floor)
$maxleistung = (int)(floor($maxleistung_raw / $stepSizeSmall) * $stepSizeSmall);
$minleistung = (int)(-floor($nachladen_raw / $stepSizeSmall) * $stepSizeSmall); // negativ!
// Sicherheitscheck: falls Werte komisch sind
if ($maxleistung < 0) $maxleistung = 0;
if ($minleistung > 0) $minleistung = 0;
// Grundarray: von min bis max in 250er Schritten
$neg = ($minleistung < 0) ? range($minleistung, 0, $stepSize) : [0];
$pos = range(0, $maxleistung, $stepSize);
$array_powersteps = array_values(array_unique(array_merge($neg, $pos)));
sort($array_powersteps, SORT_NUMERIC);
// Zusätzlichen Wert auf 50er abrunden (floor, nicht round!)
// (wichtig: floor bei negativen Zahlen geht "weiter runter", daher extra Logik)
$closestValue = (int)(floor($additionalValue / $stepSizeSmall) * $stepSizeSmall);
// Clamp in den Bereich
if ($closestValue < $minleistung) $closestValue = $minleistung;
if ($closestValue > $maxleistung) $closestValue = $maxleistung;
// Prüfen ob der Wert im Array existiert (bei 250er Raster oft NICHT)
$index = array_search($closestValue, $array_powersteps, true);
// Wenn nicht vorhanden: an der richtigen Stelle einsortieren
if ($index === false) {
$index = 0;
$count = count($array_powersteps);
while ($index < $count && $array_powersteps[$index] < $closestValue) {
$index++;
}
// $index ist jetzt Einfügeposition
}
// Index des gefundenen Werts suchen
$index = array_search($closestValue, $array_powersteps);
// Feine Werte um closestValue herum (±4 * 50)
$newValues = [];
for ($i = -4; $i <= 4; $i++) {
$v = $closestValue + ($i * $stepSizeSmall);
if ($v >= $minleistung && $v <= $maxleistung) {
$newValues[] = $v;
}
}
// Zusätzliche Werte berechnen und auf MaxLeistung begrenzen
$newValues = array_filter([
$closestValue - 4 * $stepSizeSmall,
$closestValue - 3 * $stepSizeSmall,
$closestValue - 2 * $stepSizeSmall,
$closestValue - $stepSizeSmall,
$closestValue,
$closestValue + $stepSizeSmall,
$closestValue + 2 * $stepSizeSmall,
$closestValue + 3 * $stepSizeSmall,
$closestValue + 4 * $stepSizeSmall,
], function ($value) use ($maxleistung) {
return $value >= -$maxleistung && $value <= $maxleistung;
});
// Duplikate vermeiden (falls schon Werte vorhanden sind)
$newValues = array_values(array_unique($newValues));
// Effizienteres Einfügen der Werte (direkt an der Stelle)
array_splice($array_powersteps, $index, 1, $newValues);
// Wenn closestValue exakt im Grundarray war: diesen einen ersetzen
// sonst: feinwerte einfach an der Einfügestelle einfügen
if (array_search($closestValue, $array_powersteps, true) !== false) {
$existingIndex = array_search($closestValue, $array_powersteps, true);
array_splice($array_powersteps, $existingIndex, 1, $newValues);
} else {
array_splice($array_powersteps, $index, 0, $newValues);
}
// Am Ende sortieren + Duplikate killen (sicher ist sicher)
$array_powersteps = array_values(array_unique($array_powersteps));
sort($array_powersteps, SORT_NUMERIC);
return $array_powersteps;
}
} // Ende Array Steps
@@ -132,38 +225,127 @@ public function RequestAction($Ident, $Value)
public function SetAktuelle_Leistung(int $power)
{
$batterietyp = $this->ReadPropertyInteger("Batterietyp");
$batterieManagement = $this->ReadPropertyInteger("Batteriemanagement");
// Wechselrichter steuert das Laden/Entladen der Batterie
if ($batterieManagement == 1) {
// Goodwe, Solaredge WR Modus
if ($batterieManagement == 1 && ($batterietyp == 1 || $batterietyp == 2)) {
$this->SetValue("Entladeleistung", 0);
$this->SetValue("Ladeleistung", 0);
$this->SetValue("Batteriemanagement_Variabel", 1);
return;
//Sig Energy WR Modus
} elseif ($batterieManagement == 1 && $batterietyp == 3) {
$this->SetValue("Entladeleistung", 0);
$this->SetValue("Ladeleistung", 0);
$this->SetValue("Batteriemanagement_Variabel", 0);
return;
// Sig Energy Symcon Modus
} elseif ($batterieManagement == 2 && $batterietyp == 3) {
$this->SetValue("Batteriemanagement_Variabel", 1);
//Solaredge Symcon Modus
}elseif ($batterieManagement == 2 && $batterietyp == 2) {
$this->SetValue("Batteriemanagement_Variabel", 4);
}
$batterietyp = $this->ReadPropertyInteger("Batterietyp");
if ($batterietyp == 1) {//Goodwe
$this->SetValue("Entladeleistung", 0);
$this->SetValue("Ladeleistung", 0);
//-----------------------Gooodwee-------------------------------------//
if($this->GetValue("Is_Peak_Shaving")==true){
if ($power >= 0) {
$this->SetValue("Goodwe_EntLadeleistung", abs($power));
$this->SetValue("Laden_Entladen", 11);
} else {
$this->SetValue("Goodwe_EntLadeleistung", abs($power));
$this->SetValue("Laden_Entladen", 12);
}
}else{
if ($power >= 0) {
$this->SetValue("Goodwe_EntLadeleistung", abs($power));
$this->SetValue("Laden_Entladen", 11);
} else {
$this->SetValue("Goodwe_EntLadeleistung", abs($power));
$this->SetValue("Laden_Entladen", 12);
}
}
}elseif ($batterietyp == 2) {//Solaredge
//-----------------------Solaredge-------------------------------------//
$this->SetValue("Goodwe_EntLadeleistung",0);
if($this->GetValue("Is_Peak_Shaving")==true){
if ($power >= 0) {
$this->SetValue("Ladeleistung", $power);
$this->SetValue("Entladeleistung", 0);
$this->SetValue("Laden3_Entladen4", 3);
$this->SetValue("Laden_Entladen", 3);
} else {
$this->SetValue("Entladeleistung", abs($power));
$this->SetValue("Ladeleistung", 0);
$this->SetValue("Laden3_Entladen4", 4);
$this->SetValue("Laden_Entladen", 4);
}
}else{
if ($power >= 0) {
$this->SetValue("Ladeleistung", $power);
$this->SetValue("Entladeleistung", 0);
$this->SetValue("Laden3_Entladen4", 3);
$this->SetValue("Laden_Entladen", 3);
} else {
$this->SetValue("Entladeleistung", abs($power));
$this->SetValue("Ladeleistung", 0);
$this->SetValue("Laden3_Entladen4", 4);
$this->SetValue("Laden_Entladen", 4);
}
}
} elseif ($batterietyp == 3) {//Sig Energy
//-----------------------Sig Energy-------------------------------------//
$this->SetValue("Goodwe_EntLadeleistung",0);
if($this->GetValue("Is_Peak_Shaving")==true){
if ($power >= 0) {
$this->SetValue("Ladeleistung", $power/1000);
$this->SetValue("Entladeleistung", 0);
$this->SetValue("Laden_Entladen", 3);
} else {
$this->SetValue("Entladeleistung", abs($power)/1000);
$this->SetValue("Ladeleistung", 0);
$this->SetValue("Laden_Entladen", 6);
}
}else{
if ($power >= 0) {
$this->SetValue("Ladeleistung", $power/1000);
$this->SetValue("Entladeleistung", 0);
$this->SetValue("Laden_Entladen", 3);
} else {
$this->SetValue("Entladeleistung", abs($power)/1000);
$this->SetValue("Ladeleistung", 0);
$this->SetValue("Laden_Entladen", 6);
}
}
}
// Prüfe auf Änderung der Leistung im Vergleich zur letzten Einstellung
$lastPower = GetValue($this->GetIDForIdent("Aktuelle_Leistung"));
@@ -193,7 +375,7 @@ public function RequestAction($Ident, $Value)
$array_powersteps = $this->GeneratePowerSteps($this->GetValue("Aktuelle_Leistung"));
$aufdasnachladen = $this->ReadPropertyInteger("AufdasNachladen");
$minimumentladen = $this->ReadPropertyInteger("MinimumEntladen");
$maxleistung = $this->ReadPropertyInteger("MaxBatterieleistung");
$maxleistung = GetValue($this->ReadPropertyInteger("MaxBatterieleistung"));
$dummy_array = [];
$batterieladezustand = GetValue($this->ReadPropertyInteger("Batterieladezustand"));
$filtered_powersteps_entladen = [];
@@ -277,7 +459,7 @@ public function RequestAction($Ident, $Value)
}elseif($batterieladezustand<$aufdasnachladen){
$dummy_array[] = $this->ReadPropertyInteger("MaxNachladen");
$dummy_array[] = GetValue($this->ReadPropertyInteger("MaxNachladen"));
$this->SetValue("PowerSteps", json_encode($dummy_array));
IPS_LogMessage("Batterie", "im 3");
View File
-87
View File
@@ -1,87 +0,0 @@
{
"elements": [
{
"type": "Label",
"caption": "Konfiguration der Batterie für Peakshaving"
},
{
"type": "NumberSpinner",
"name": "IdleCounterMax",
"caption": "Zyklen zwischen zwei Leistungsänderungen (Multipliziert sich mit Interval)",
"suffix": ""
},
{
"type": "NumberSpinner",
"name": "Interval",
"caption": "Intervall Neuberechnung der Werte",
"suffix": "Sekunden"
},
{
"type": "NumberSpinner",
"name": "MaxBatterieleistung",
"caption": "Maximale Batterieleistung",
"suffix": ""
},
{
"type": "NumberSpinner",
"name": "MaxNachladen",
"caption": "Maximum Nachladen",
"suffix": ""
},
{
"type": "NumberSpinner",
"name": "AufdasNachladen",
"caption": "Auf so viel % nachladen",
"suffix": ""
},
{
"type": "NumberSpinner",
"name": "MinimumEntladen",
"caption": "Minimal % des Batterieladezustand",
"suffix": ""
},
{
"type":"Select",
"name":"Batteriemanagement",
"caption":"Batteriemanagement",
"options":[
{
"caption":"Durch Wechselrichter",
"value":2
},
{
"caption":"Durch EMS Symcon",
"value":0
}
]
},
{
"type": "SelectVariable",
"name": "Batteriespannung",
"caption": "Batteriespannung",
"test": true
},
{
"type": "SelectVariable",
"name": "Batterie_Ladezustand",
"caption": "Batterie Ladeszutand %",
"test": true
},
{
"type": "SelectVariable",
"name": "Netzbezug",
"caption": "Variable mit dem zu regelnden Netzbezug"
},
{
"type": "SelectVariable",
"name": "Kotnrolle_TOU_Spannung",
"caption": "Kontrollvariabel Spannung TOU1"
},
{
"type": "SelectVariable",
"name": "Kotnrolle_Selling_CT",
"caption": "Kontrollvariabel Selling First/Zero Export to CT"
}
]
}
-12
View File
@@ -1,12 +0,0 @@
{
"id": "{76529CAE-191C-41BF-5FDE-EF4A4FE25651}",
"name": "Batterie_Deye",
"type": 3,
"vendor": "Belevo AG",
"aliases": [],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "GEF",
"url": ""
}
-385
View File
@@ -1,385 +0,0 @@
<?php
class Batterie_Deye extends IPSModule
{
public function Create()
{
parent::Create();
// Batterie spezifische Eigenschaften
$this->RegisterPropertyInteger("MaxBatterieleistung", 0);
$this->RegisterPropertyInteger("Batteriespannung", 50);
$this->RegisterPropertyInteger("Batterie_Ladezustand", 50);
$this->RegisterPropertyFloat("AufdasNachladen",0);
$this->RegisterPropertyFloat("MinimumEntladen",0);
$this->RegisterPropertyInteger("Batteriemanagement", 1);
$this->RegisterPropertyInteger("Kotnrolle_TOU_Spannung", 0);
$this->RegisterPropertyInteger("Kotnrolle_Selling_CT", 0);
$this->RegisterPropertyInteger("MaxNachladen",0);
$this->RegisterPropertyInteger("Netzbezug", 0); // Initialisierung mit 0
$this->RegisterPropertyInteger("Interval", 2); // Recheninterval
// Variabeln für Kommunkation mit Manager
$this->RegisterVariableInteger("Batteriemanagement_Variabel","Batteriemanagement_Variabel", "",0);
$this->RegisterVariableInteger("Ladestrom","Ladestrom", "",0);
$this->RegisterVariableInteger("Entladestrom","Entladestrom", "",0);
$this->RegisterVariableInteger("Batteriespannung_laden_entladen","Batteriespannung_laden_entladen","",0);
$this->RegisterVariableInteger("Aktuelle_Leistung", "Aktuelle_Leistung", "", 0);
$this->RegisterVariableString("PowerSteps", "PowerSteps");
$this->RegisterVariableBoolean("Idle", "Idle", "", 0);
$this->RegisterVariableInteger("Sperre_Prio", "Sperre_Prio");
$this->RegisterVariableInteger("PV_Prio", "PV_Prio");
$this->RegisterVariableInteger("Power", "Power");
$this->RegisterVariableBoolean("Is_Peak_Shaving", "Is_Peak_Shaving");
$this->RegisterVariableInteger("Leistung_Delta", "Leistung_Delta", "", 0);
$this->RegisterVariableBoolean("Hysterese", "Hysterese","",false);
$this->RegisterVariableBoolean("Schreibkontrolle", "Schreibkontrolle","",false);
$this->RegisterVariableFloat("Bezogene_Energie", "Bezogene_Energie", "", 0);
// Hilfsvariabeln für Idle zustand
$this->RegisterPropertyInteger("IdleCounterMax", 2);
$this->RegisterVariableInteger("IdleCounter", "IdleCounter", "", 0);
$this->SetValue("IdleCounter", 0);
// Initialisiere Idle
$this->SetValue("Idle", true);
$this->RegisterTimer("Timer_Do_UserCalc_Battery",$this->ReadPropertyInteger("Interval")*1000,"IPS_RequestAction(" .$this->InstanceID .', "Do_UserCalc", "");');
}
public function ApplyChanges()
{
parent::ApplyChanges();
$batterieManagement = $this->ReadPropertyInteger("Batteriemanagement");
$this->SetValue("Batteriemanagement_Variabel", $batterieManagement);
$this->SetTimerInterval("Timer_Do_UserCalc_Battery",$this->ReadPropertyInteger("Interval")*1000);
}
private function GeneratePowerSteps($additionalValue)
{
$maxleistung = $this->ReadPropertyInteger("MaxBatterieleistung");
$stepSize = 250; // Schrittgröße
$stepSizeSmall = 50; // Kleine Schrittgröße
// Array direkt als Range erzeugen (schneller als Schleife)
$array_powersteps = range(-$maxleistung, $maxleistung, $stepSize);
// Nächstgelegenen Wert direkt bestimmen (rundet auf den nächsten Step)
$closestValue = round($additionalValue / $stepSize) * $stepSize;
// Falls der Wert nicht im Bereich liegt, abbrechen
if (!in_array($closestValue, $array_powersteps)) {
return $array_powersteps;
}
// Index des gefundenen Werts suchen
$index = array_search($closestValue, $array_powersteps);
// Zusätzliche Werte berechnen und auf MaxLeistung begrenzen
$newValues = array_filter([
$closestValue - 4 * $stepSizeSmall,
$closestValue - 3 * $stepSizeSmall,
$closestValue - 2 * $stepSizeSmall,
$closestValue - $stepSizeSmall,
$closestValue,
$closestValue + $stepSizeSmall,
$closestValue + 2 * $stepSizeSmall,
$closestValue + 3 * $stepSizeSmall,
$closestValue + 4 * $stepSizeSmall,
], function ($value) use ($maxleistung) {
return $value >= -$maxleistung && $value <= $maxleistung;
});
// Effizienteres Einfügen der Werte (direkt an der Stelle)
array_splice($array_powersteps, $index, 1, $newValues);
return $array_powersteps;
}
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");
}
}
public function SetAktuelle_Leistung(int $power)
{
$y = (-1)*$power;
if ($y < 0) {
// Laden
$uVarID = $this->ReadPropertyInteger("Batteriespannung");
if ($uVarID > 0 && IPS_VariableExists($uVarID)) {
$U = GetValue($uVarID);
if ($U > 0) {
$lade_strom = abs($y) / $U;
} else {
$lade_strom = 0;
}
} else {
$lade_strom = 0;
}
$this->SetValue("Ladestrom", $lade_strom);
$this->SetValue("Entladestrom", 0);
$this->SetValue("Batteriespannung_laden_entladen", 100);
} elseif ($y > 0) {
// Entladen
$uVarID = $this->ReadPropertyInteger("Batteriespannung");
if ($uVarID > 0 && IPS_VariableExists($uVarID)) {
$U = GetValue($uVarID);
if ($U > 0) {
$entlade_strom = $y / $U;
} else {
$entlade_strom = 0;
}
} else {
$entlade_strom = 0;
}
$this->SetValue("Entladestrom", $entlade_strom);
$this->SetValue("Ladestrom", 0);
$this->SetValue("Batteriespannung_laden_entladen", 5);
} else {
// Kein Stromfluss (Leistung = 0)
$this->SetValue("Ladestrom", 0);
$this->SetValue("Entladestrom", 0);
}
$batterieManagement = $this->ReadPropertyInteger("Batteriemanagement");
// Wechselrichter steuert das Laden/Entladen der Batterie
if ($batterieManagement == 2) {
$this->SetValue("Ladestrom", 0);
$this->SetValue("Entladestrom", 0);
return;
}
// Prüfe auf Änderung der Leistung im Vergleich zur letzten Einstellung
$lastPower = GetValue($this->GetIDForIdent("Aktuelle_Leistung"));
if ($power != $lastPower) {
$this->SetValue("Idle", false);
$this->SetValue(
"IdleCounter",
$this->ReadPropertyInteger("IdleCounterMax")
);
}
// Setze die neue aktuelle Leistung
$this->SetValue("Aktuelle_Leistung", $power);
$this->SetValue("Bezogene_Energie", ($this->GetValue("Bezogene_Energie") + ($this->GetValue("Aktuelle_Leistung")*($this->ReadPropertyInteger("Interval")/3600))));
// IdleCounter verarbeiten
$this->ProcessIdleCounter();
}
public function GetCurrentData(bool $Peak)
{
$ct = GetValue($this->ReadPropertyInteger("Kotnrolle_Selling_CT"));
$sell_Ct = $this->GetValue("Batteriemanagement_Variabel");
if ($ct != $sell_Ct) {
$this->SetValue("Schreibkontrolle", false);
} else {
$this->SetValue("Schreibkontrolle", true);
}
//$a = 2.54 * pow($V, 2) - 252 * $V + 6255.4;
$ladestrom_1 = $this->GetValue("Ladestrom");
$entadestrom_1 = $this->GetValue("Entladedestrom");
/*
if ($ladestrom_1 > 0 ) {
$V = GetValue($this->ReadPropertyInteger("Batteriespannung")) - 1;
}elseif ($entadestrom_1 > 0) {
$V = GetValue($this->ReadPropertyInteger("Batteriespannung")) + 1;
} else {
$V = GetValue($this->ReadPropertyInteger("Batteriespannung"));
}
*/
IPS_LogMessage("Batterie", "Currentdata");
$array_powersteps = $this->GeneratePowerSteps($this->GetValue("Aktuelle_Leistung"));
$aufdasnachladen = $this->ReadPropertyFloat("AufdasNachladen");
$minimumentladen = $this->ReadPropertyFloat("MinimumEntladen");
$maxleistung = $this->ReadPropertyInteger("MaxBatterieleistung");
$dummy_array = [];
$batterieladezustand = GetValue($this->ReadPropertyInteger("Batterie_Ladezustand"));
$filtered_powersteps_entladen = [];
if ($this->ReadPropertyInteger("Batteriemanagement") == 2) {
$dummy_array[] = 0;
return $this->SetValue("PowerSteps", json_encode($dummy_array));
}
$netzbezug = GetValue($this->ReadPropertyInteger("Netzbezug"));
if (abs($netzbezug) > $maxleistung) {
$netzbezug = $maxleistung * (-1);
}
if($batterieladezustand>(5+$aufdasnachladen)){
$this->SetValue("Hysterese", false);
}elseif($batterieladezustand<=$aufdasnachladen){
$this->SetValue("Hysterese", true);
}
$hyst = $this->GetValue("Hysterese");
if($Peak){
IPS_LogMessage("Batterie", "Im if teil");
if($batterieladezustand>$aufdasnachladen && $hyst==false){
$dummy_array[] = $netzbezug;
$this->SetValue("PowerSteps", json_encode($dummy_array));
}elseif($batterieladezustand>$aufdasnachladen && $hyst==true){
$filtered_powersteps = array_filter($array_powersteps, function ($value) {
return $value <= 0;
});
$filtered_powersteps_laden = array_values($filtered_powersteps);
$this->SetValue("PowerSteps", json_encode($filtered_powersteps_laden));
}elseif($batterieladezustand>$minimumentladen){
$this->SetValue("PowerSteps", json_encode($array_powersteps));
}
else{
$filtered_powersteps = array_filter($array_powersteps, function ($value) {
return $value >= 0;
});
$filtered_powersteps_laden = array_values($filtered_powersteps);
$this->SetValue("PowerSteps", json_encode($filtered_powersteps_laden));
}
}else{
IPS_LogMessage("Batterie", "Im else teil");
if($batterieladezustand>199.9){
IPS_LogMessage("Batterie", "im 1");
$filtered_powersteps = array_filter($array_powersteps, function ($value) {
return $value <= 0;
});
$filtered_powersteps_laden = array_values($filtered_powersteps);
$this->SetValue("PowerSteps", json_encode($filtered_powersteps_laden));
}elseif($batterieladezustand>$aufdasnachladen && $hyst==false){
$this->SetValue("PowerSteps", json_encode($array_powersteps));
IPS_LogMessage("Batterie", "im 2");
}elseif($batterieladezustand>=$aufdasnachladen && $hyst==true){
$filtered_powersteps = array_filter($array_powersteps, function ($value) {
return $value >= 0;
});
$filtered_powersteps_laden = array_values($filtered_powersteps);
$this->SetValue("PowerSteps", json_encode($filtered_powersteps_laden));
}elseif($batterieladezustand<$aufdasnachladen){
$dummy_array[] = $this->ReadPropertyInteger("MaxNachladen");
$this->SetValue("PowerSteps", json_encode($dummy_array));
IPS_LogMessage("Batterie", "im 3");
}
}
}
private function CheckIdle($power)
{
$lastpower = GetValue("Aktuelle_Leistung");
if ($lastpower != GetValue("Aktuelle_Leistung")) {
$this->SetValue("Idle", false);
$this->SetValue(
"IdleCounter",
$this->ReadPropertyInteger("IdleCounterMax")
);
}
// IdleCounter auslesen und verarbeiten
$idleCounter = $this->GetValue("IdleCounter");
if ($idleCounter > 0) {
$this->SetValue("Idle", false);
$this->SetValue("IdleCounter", $idleCounter - 1);
} else {
$this->SetValue("Idle", true);
}
}
private function ProcessIdleCounter()
{
// IdleCounter auslesen und verarbeiten
$idleCounter = $this->GetValue("IdleCounter");
if ($idleCounter > 0) {
$this->SetValue("Idle", false);
$this->SetValue("IdleCounter", $idleCounter - 1);
} else {
$this->SetValue("Idle", true);
}
}
}
?>
-30
View File
@@ -1,30 +0,0 @@
{
"elements": [
{ "type": "ValidationTextBox", "name": "URL", "caption": "Solcast URL" },
{ "type": "SelectVariable", "name": "ActualVariableID", "caption": "Ist-Produktion Variable (Leistung)" },
{ "type": "CheckBox", "name": "ActualIsWatt", "caption": "Istwerte sind in Watt (in kW umrechnen)" },
{
"type": "Select",
"name": "RefreshMode",
"caption": "Forecast-Aktualisierung",
"options": [
{ "caption": "Alle X Minuten", "value": "interval" },
{ "caption": "Einmal täglich (Uhrzeit)", "value": "daily" }
]
},
{ "type": "NumberSpinner", "name": "RefreshMinutes", "caption": "Intervall (Minuten)", "minimum": 1, "maximum": 240 },
{ "type": "ValidationTextBox", "name": "RefreshTime", "caption": "Tägliche Uhrzeit (HH:MM, z.B. 00:01)" },
{ "type": "CheckBox", "name": "EnableDailySnapshots", "caption": "Tägliche Tages-Plots speichern" },
{ "type": "ValidationTextBox", "name": "SnapshotTime", "caption": "Snapshot-Uhrzeit (HH:MM, z.B. 23:59)" },
{ "type": "NumberSpinner", "name": "KeepDays", "caption": "Aufbewahrung (Tage)", "minimum": 1, "maximum": 365 }
],
"actions": [
{
"type": "Button",
"caption": "Forecast jetzt aktualisieren",
"onClick": "IPS_RequestAction($id, \"UpdateForecast\", 0);"
}
]
}
-186
View File
@@ -1,186 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>PV_Forecast_plotmemory</title>
<script src="https://code.highcharts.com/highcharts.js"></script>
<style>
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
margin: 0;
padding: 0;
background: transparent;
}
/* Kopfzeile */
.header {
display: flex;
align-items: center;
padding: 10px 14px;
background: #1f2937;
border-bottom: 1px solid #374151;
}
.meta {
color: #e5e7eb;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Chart */
#chart {
height: 520px;
width: 100%;
padding: 10px;
box-sizing: border-box;
background: #ffffff;
}
</style>
</head>
<body>
<div class="header">
<div class="meta" id="meta">Lade…</div>
</div>
<div id="chart"></div>
<script>
const metaEl = document.getElementById("meta");
window.onerror = (msg, url, line, col) => {
metaEl.textContent = `JS-Fehler: ${msg} (${line}:${col})`;
};
const instanceId = Number("{{INSTANCE_ID}}");
if (!Number.isFinite(instanceId) || instanceId <= 0) {
metaEl.textContent = "Fehler: INSTANCE_ID nicht korrekt.";
throw new Error("Invalid INSTANCE_ID");
}
const endpoint =
`${location.protocol}//${location.host}` +
`/hook/solcastcompare_plotmemory?instance=${instanceId}&action=data`;
let chart;
async function loadData() {
metaEl.textContent = "⏳ Daten werden geladen…";
const r = await fetch(endpoint, { cache: "no-store" });
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`HTTP ${r.status}${text ? " - " + text : ""}`);
}
return await r.json();
}
function render(data) {
if (typeof Highcharts === "undefined") {
metaEl.textContent = "Fehler: Highcharts nicht geladen.";
return;
}
const forecast = data?.series?.forecast ?? [];
const actual = data?.series?.actual ?? [];
const cachedAt = data?.meta?.forecast_cached_at
? new Date(data.meta.forecast_cached_at * 1000).toLocaleString()
: "unbekannt";
const nowMs = data?.meta?.now ?? Date.now();
metaEl.textContent =
`OK | Cache: ${cachedAt} | Forecast: ${forecast.length} | Ist: ${actual.length}`;
const options = {
title: {
text: "PV: Erwartung vs. Tatsächlich (heute)",
style: { fontWeight: "700" }
},
chart: {
animation: false,
spacingTop: 18
},
xAxis: {
type: "datetime",
},
yAxis: {
title: {
text: "Leistung (kW)",
style: { fontWeight: "700" }
},
gridLineColor: "#e5e7eb"
},
legend: { enabled: true },
tooltip: {
shared: true,
formatter: function () {
let s = `<b>${Highcharts.dateFormat('%d.%m.%Y %H:%M', this.x)}</b><br/>`;
this.points.forEach(p => {
const val = (p.y === null || typeof p.y === "undefined")
? "–"
: `${p.y.toFixed(1)} kW`;
s += `<span style="color:${p.color}">●</span> ${p.series.name}: <b>${val}</b><br/>`;
});
return s;
}
},
plotOptions: {
series: {
animation: false,
turboThreshold: 0,
marker: { enabled: true }
}
},
series: [
{
name: "Erwartet (Solcast)",
data: forecast,
color: "#38bdf8",
lineWidth: 4,
marker: { radius: 4 }
},
{
name: "Tatsächlich (Archiv)",
data: actual,
color: "#7c3aed",
lineWidth: 4,
marker: { radius: 5, symbol: "diamond" }
}
],
credits: { enabled: false }
};
if (!chart) {
chart = Highcharts.chart("chart", options);
} else {
chart.series[0].setData(forecast, false);
chart.series[1].setData(actual, false);
chart.redraw();
}
}
(async () => {
try {
const data = await loadData();
render(data);
} catch (e) {
metaEl.textContent = "Fehler beim Laden: " + (e?.message ?? e);
}
})();
</script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
{
"id": "{DF3B17A3-C2F7-0B57-F632-34668D9206E6}",
"name": "PV_Forecast_plotmemory ",
"type": 3,
"vendor": "Belevo AG",
"aliases": [],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "PVF",
"url": ""
}
-589
View File
@@ -1,589 +0,0 @@
<?php
class PV_Forecast_plotmemory extends IPSModule
{
private const ARCHIVE_GUID = "{43192F0B-135B-4CE7-A0A7-1475603F3060}";
private const WEBHOOK_GUID = "{015A6EB8-D6E5-4B93-B496-0D3FAD4D0E6E}";
public function Create()
{
parent::Create();
// Core
$this->RegisterPropertyString("URL", "");
$this->RegisterPropertyInteger("ActualVariableID", 0);
$this->RegisterPropertyBoolean("ActualIsWatt", true);
// Forecast fetch scheduler
$this->RegisterPropertyString("RefreshMode", "interval"); // interval | daily
$this->RegisterPropertyInteger("RefreshMinutes", 5);
$this->RegisterPropertyString("RefreshTime", "06:00"); // HH:MM
// Daily snapshot
$this->RegisterPropertyBoolean("EnableDailySnapshots", true);
$this->RegisterPropertyString("SnapshotTime", "23:59"); // HH:MM
$this->RegisterPropertyInteger("KeepDays", 30);
// Timer
$this->RegisterTimer("UpdateForecastTimer", 0, 'IPS_RequestAction($_IPS["TARGET"], "UpdateForecast", 0);');
$this->RegisterTimer("SnapshotTimer", 0, 'IPS_RequestAction($_IPS["TARGET"], "SnapshotTick", 0);');
// WebHook endpoint
$this->RegisterHook("/hook/solcastcompare_plotmemory");
}
public function ApplyChanges()
{
parent::ApplyChanges();
// Tile Visualization aktivieren
$this->SetVisualizationType(1);
// Forecast timer
$mode = $this->ReadPropertyString("RefreshMode");
if ($mode === "interval") {
$mins = max(1, (int)$this->ReadPropertyInteger("RefreshMinutes"));
$this->SetTimerInterval("UpdateForecastTimer", $mins * 60 * 1000);
} else {
// daily check each minute
$this->SetTimerInterval("UpdateForecastTimer", 60 * 1000);
}
// Snapshot timer
if ($this->ReadPropertyBoolean("EnableDailySnapshots")) {
$this->SetTimerInterval("SnapshotTimer", 60 * 1000); // every minute
} else {
$this->SetTimerInterval("SnapshotTimer", 0);
}
$this->RegisterHook("/hook/solcastcompare");
}
public function RequestAction($Ident, $Value)
{
switch ($Ident) {
case "UpdateForecast":
$this->HandleForecastSchedule();
return;
case "SnapshotTick":
$this->HandleSnapshotSchedule();
return;
default:
throw new Exception("Unknown Ident: " . $Ident);
}
}
// ----------------- Forecast scheduling -----------------
private function HandleForecastSchedule(): void
{
$mode = $this->ReadPropertyString("RefreshMode");
if ($mode === "interval") {
$this->UpdateForecast();
return;
}
// daily
$timeStr = trim($this->ReadPropertyString("RefreshTime")); // HH:MM
$targetSec = $this->ParseHHMMToSeconds($timeStr);
if ($targetSec === null) {
$this->SendDebug("Scheduler", "Ungueltige RefreshTime: " . $timeStr, 0);
return;
}
$now = time();
$nowSec = ((int)date("H", $now) * 3600) + ((int)date("i", $now) * 60);
$lastRun = (int)$this->GetBuffer("LastDailyRunForecast");
$today0 = strtotime("today");
if ($nowSec >= $targetSec && $lastRun < $today0) {
$this->SendDebug("Scheduler", "Taegliches Forecast-Update (" . $timeStr . ")", 0);
$this->UpdateForecast();
$this->SetBuffer("LastDailyRunForecast", (string)$now);
}
}
// ----------------- Snapshot scheduling -----------------
private function HandleSnapshotSchedule(): void
{
if (!$this->ReadPropertyBoolean("EnableDailySnapshots")) {
return;
}
$timeStr = trim($this->ReadPropertyString("SnapshotTime"));
$targetSec = $this->ParseHHMMToSeconds($timeStr);
if ($targetSec === null) {
$this->SendDebug("Snapshot", "Ungueltige SnapshotTime: " . $timeStr, 0);
return;
}
$now = time();
$nowSec = ((int)date("H", $now) * 3600) + ((int)date("i", $now) * 60);
$lastRun = (int)$this->GetBuffer("LastDailySnapshotRun");
$today0 = strtotime("today");
if ($nowSec >= $targetSec && $lastRun < $today0) {
$this->SendDebug("Snapshot", "Tages-Snapshot wird erstellt (" . $timeStr . ")", 0);
// Safety: ensure we have a forecast cached (if not, fetch once)
if ($this->GetBuffer("ForecastRaw") === "") {
$this->UpdateForecast();
}
$this->CreateDailySnapshotForDate(new DateTime('today', new DateTimeZone(date_default_timezone_get())));
$this->CleanupOldSnapshots();
$this->SetBuffer("LastDailySnapshotRun", (string)$now);
}
}
private function ParseHHMMToSeconds(string $hhmm): ?int
{
if (!preg_match('/^(\d{1,2}):(\d{2})$/', $hhmm, $m)) {
return null;
}
$h = (int)$m[1];
$min = (int)$m[2];
if ($h < 0 || $h > 23 || $min < 0 || $min > 59) {
return null;
}
return $h * 3600 + $min * 60;
}
// ----------------- Forecast Fetch -----------------
private function UpdateForecast(): void
{
$url = trim($this->ReadPropertyString("URL"));
if ($url === "") {
$this->SendDebug("UpdateForecast", "URL ist leer", 0);
return;
}
$json = @Sys_GetURLContent($url);
if ($json === false || $json === "") {
$this->SendDebug("UpdateForecast", "Leere Antwort von URL", 0);
return;
}
$data = json_decode($json, true);
if (!is_array($data)) {
$this->SendDebug("UpdateForecast", "JSON decode fehlgeschlagen", 0);
return;
}
$this->SetBuffer("ForecastRaw", $json);
$this->SetBuffer("ForecastTS", (string)time());
$this->SendDebug("UpdateForecast", "Forecast aktualisiert", 0);
}
// ----------------- Tile Visualization -----------------
public function GetVisualizationTile(): string
{
if ($this->GetBuffer("ForecastRaw") === "") {
$this->UpdateForecast();
}
$html = file_get_contents(__DIR__ . "/module.html");
$html = str_replace("{{INSTANCE_ID}}", (string)$this->InstanceID, $html);
return $html;
}
// ----------------- WebHook: data + download -----------------
protected function ProcessHookData()
{
$instance = isset($_GET["instance"]) ? (int)$_GET["instance"] : 0;
$action = isset($_GET["action"]) ? (string)$_GET["action"] : "data";
if ($instance !== $this->InstanceID) {
http_response_code(404);
echo "Wrong instance";
return;
}
if ($action === "data") {
$this->HandleHookData();
return;
}
if ($action === "download") {
$days = isset($_GET["days"]) ? max(1, min(365, (int)$_GET["days"])) : 7;
$this->HandleHookDownloadZip($days);
return;
}
http_response_code(400);
echo "Unknown action";
}
private function HandleHookData(): void
{
$tzLocal = new DateTimeZone(date_default_timezone_get());
$tzUtc = new DateTimeZone('UTC');
// Lokaler Tag
$startLocal = new DateTime('today', $tzLocal);
$endLocal = new DateTime('tomorrow', $tzLocal);
$startLocalTs = $startLocal->getTimestamp();
$endLocalTs = $endLocal->getTimestamp();
// Lokaler Tagesbereich als UTC-Grenzen (Solcast period_end ist UTC "Z")
$startUtcTs = (clone $startLocal)->setTimezone($tzUtc)->getTimestamp();
$endUtcTs = (clone $endLocal)->setTimezone($tzUtc)->getTimestamp();
$nowTs = time();
$forecast = $this->GetForecastSeriesFilteredUtc($startUtcTs, $endUtcTs);
$actual = $this->GetActualSeriesFromArchive($startLocalTs, $endLocalTs, 1800, $nowTs, true);
$out = [
"meta" => [
"forecast_cached_at" => (int)$this->GetBuffer("ForecastTS"),
"bucket_seconds" => 1800,
"actual_is_watt" => (bool)$this->ReadPropertyBoolean("ActualIsWatt"),
"start_local" => $startLocalTs * 1000,
"end_local" => $endLocalTs * 1000,
"now" => $nowTs * 1000
],
"series" => [
"forecast" => $forecast,
"actual" => $actual
]
];
header("Content-Type: application/json; charset=utf-8");
echo json_encode($out);
}
// ----------------- Forecast series (Solcast) -----------------
private function GetForecastSeriesFilteredUtc(int $startUtcTs, int $endUtcTs): array
{
$raw = $this->GetBuffer("ForecastRaw");
if ($raw === "") return [];
$data = json_decode($raw, true);
if (!is_array($data)) return [];
// forecast endpoint: forecasts; live endpoint: estimated_actuals
if (isset($data["forecasts"]) && is_array($data["forecasts"])) {
$rows = $data["forecasts"];
} elseif (isset($data["estimated_actuals"]) && is_array($data["estimated_actuals"])) {
$rows = $data["estimated_actuals"];
} else {
return [];
}
$series = [];
foreach ($rows as $row) {
if (!isset($row["period_end"], $row["pv_power_rooftop"])) continue;
$ts = strtotime($row["period_end"]); // UTC wegen Z
if ($ts === false) continue;
if ($ts < $startUtcTs || $ts >= $endUtcTs) continue;
// Solcast: kW
$series[] = [$ts * 1000, (float)$row["pv_power_rooftop"]];
}
usort($series, fn($a, $b) => $a[0] <=> $b[0]);
return $series;
}
// ----------------- Actual series (Archive) -----------------
// $cutAfterNow=true -> future buckets = null (live view)
// $cutAfterNow=false -> keep as much as possible (snapshot)
private function GetActualSeriesFromArchive(int $startTs, int $endTs, int $bucketSeconds, int $nowTs, bool $cutAfterNow): array
{
$varId = (int)$this->ReadPropertyInteger("ActualVariableID");
if ($varId <= 0 || !IPS_VariableExists($varId)) return [];
$archiveId = $this->GetArchiveInstanceID();
if ($archiveId === 0) return [];
if (!AC_GetLoggingStatus($archiveId, $varId)) {
$this->SendDebug("Actual", "Variable wird nicht geloggt im Archiv", 0);
return [];
}
$logged = AC_GetLoggedValues($archiveId, $varId, $startTs, $endTs, 0);
if (!is_array($logged) || count($logged) === 0) {
return $this->BuildNullRaster($startTs, $endTs, $bucketSeconds);
}
// bucketEnd -> [sum, count]
$buckets = [];
foreach ($logged as $row) {
if (!isset($row["TimeStamp"], $row["Value"])) continue;
$ts = (int)$row["TimeStamp"];
$bucketStart = intdiv($ts, $bucketSeconds) * $bucketSeconds;
$bucketEnd = $bucketStart + $bucketSeconds;
if ($bucketEnd < $startTs || $bucketEnd > $endTs) continue;
if (!isset($buckets[$bucketEnd])) $buckets[$bucketEnd] = [0.0, 0];
$buckets[$bucketEnd][0] += (float)$row["Value"];
$buckets[$bucketEnd][1] += 1;
}
$series = [];
$isWatt = (bool)$this->ReadPropertyBoolean("ActualIsWatt");
for ($t = $startTs + $bucketSeconds; $t <= $endTs; $t += $bucketSeconds) {
if ($cutAfterNow && $t > $nowTs + $bucketSeconds) {
$series[] = [$t * 1000, null];
continue;
}
if (!isset($buckets[$t]) || $buckets[$t][1] <= 0) {
$series[] = [$t * 1000, null];
continue;
}
$avg = $buckets[$t][0] / $buckets[$t][1];
// W -> kW
if ($isWatt) $avg = $avg / 1000.0;
$series[] = [$t * 1000, $avg];
}
return $series;
}
private function BuildNullRaster(int $startTs, int $endTs, int $bucketSeconds): array
{
$series = [];
for ($t = $startTs + $bucketSeconds; $t <= $endTs; $t += $bucketSeconds) {
$series[] = [$t * 1000, null];
}
return $series;
}
private function GetArchiveInstanceID(): int
{
$list = IPS_GetInstanceListByModuleID(self::ARCHIVE_GUID);
return (is_array($list) && count($list) > 0) ? (int)$list[0] : 0;
}
// ----------------- Daily snapshot creation -----------------
private function GetStorageDir(): string
{
$dir = __DIR__ . "/storage";
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
return $dir;
}
private function CreateDailySnapshotForDate(DateTime $dayLocal): void
{
$tzLocal = new DateTimeZone(date_default_timezone_get());
$tzUtc = new DateTimeZone('UTC');
// dayLocal should be "today" in local TZ
$dayLocal->setTimezone($tzLocal);
$startLocal = (clone $dayLocal);
$startLocal->setTime(0, 0, 0);
$endLocal = (clone $startLocal);
$endLocal->modify('+1 day');
$startLocalTs = $startLocal->getTimestamp();
$endLocalTs = $endLocal->getTimestamp();
$startUtcTs = (clone $startLocal)->setTimezone($tzUtc)->getTimestamp();
$endUtcTs = (clone $endLocal)->setTimezone($tzUtc)->getTimestamp();
$nowTs = time(); // snapshot time (typically 23:59)
$forecast = $this->GetForecastSeriesFilteredUtc($startUtcTs, $endUtcTs);
$actual = $this->GetActualSeriesFromArchive($startLocalTs, $endLocalTs, 1800, $nowTs, false);
$dateStr = $startLocal->format('Y-m-d');
$payload = [
"meta" => [
"date" => $dateStr,
"created_at" => time(),
"forecast_cached_at" => (int)$this->GetBuffer("ForecastTS"),
"bucket_seconds" => 1800,
"actual_is_watt" => (bool)$this->ReadPropertyBoolean("ActualIsWatt"),
"timezone" => $tzLocal->getName()
],
"series" => [
"forecast" => $forecast,
"actual" => $actual
]
];
$dir = $this->GetStorageDir();
// Save JSON
$jsonPath = $dir . "/" . $dateStr . ".json";
@file_put_contents($jsonPath, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
// Save HTML plot
$htmlPath = $dir . "/" . $dateStr . ".html";
@file_put_contents($htmlPath, $this->BuildSnapshotHtml($payload));
$this->SendDebug("Snapshot", "Gespeichert: " . basename($jsonPath) . " / " . basename($htmlPath), 0);
}
private function BuildSnapshotHtml(array $payload): string
{
$date = $payload["meta"]["date"] ?? "unbekannt";
$forecastJson = json_encode($payload["series"]["forecast"] ?? []);
$actualJson = json_encode($payload["series"]["actual"] ?? []);
// Hinweis: nutzt Highcharts CDN. (Wenn du offline öffnen willst, sag Bescheid, dann liefern wir highcharts.js lokal mit.)
return '<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>PV Snapshot ' . htmlspecialchars($date) . '</title>
<script src="https://code.highcharts.com/highcharts.js"></script>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;margin:0;padding:16px;background:#fff;}
h2{margin:0 0 12px 0;}
#chart{height:560px;width:100%;}
.meta{color:#374151;font-size:13px;margin-bottom:10px;}
</style>
</head>
<body>
<h2>PV: Erwartung vs. Tatsächlich (' . htmlspecialchars($date) . ')</h2>
<div class="meta">Gespeichert: ' . date('d.m.Y H:i:s', (int)($payload["meta"]["created_at"] ?? time())) . '</div>
<div id="chart"></div>
<script>
const forecast = ' . $forecastJson . ';
const actual = ' . $actualJson . ';
Highcharts.chart("chart", {
title: { text: null },
xAxis: { type: "datetime" },
yAxis: { title: { text: "Leistung (kW)" } },
tooltip: { shared: true, xDateFormat: "%d.%m.%Y %H:%M" },
legend: { enabled: true },
plotOptions: { series: { turboThreshold: 0 } },
series: [
{ name: "Erwartet (Solcast)", data: forecast, lineWidth: 3 },
{ name: "Tatsächlich (Archiv)", data: actual, lineWidth: 3 }
],
credits: { enabled: false }
});
</script>
</body>
</html>';
}
private function CleanupOldSnapshots(): void
{
$keep = max(1, (int)$this->ReadPropertyInteger("KeepDays"));
$dir = $this->GetStorageDir();
$cutoff = strtotime("today") - ($keep * 86400);
foreach (glob($dir . "/*.json") as $file) {
$base = basename($file, ".json"); // YYYY-MM-DD
$ts = strtotime($base);
if ($ts !== false && $ts < $cutoff) {
@unlink($file);
$html = $dir . "/" . $base . ".html";
if (is_file($html)) @unlink($html);
}
}
}
// ----------------- ZIP download -----------------
private function HandleHookDownloadZip(int $days): void
{
$dir = $this->GetStorageDir();
$dates = [];
for ($i = 0; $i < $days; $i++) {
$d = new DateTime('today', new DateTimeZone(date_default_timezone_get()));
$d->modify("-{$i} day");
$dates[] = $d->format("Y-m-d");
}
$zipPath = sys_get_temp_dir() . "/pv_forecast_" . $this->InstanceID . "_" . time() . ".zip";
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE) !== true) {
http_response_code(500);
echo "Could not create zip";
return;
}
$added = 0;
foreach (array_reverse($dates) as $dateStr) {
$jsonFile = $dir . "/" . $dateStr . ".json";
$htmlFile = $dir . "/" . $dateStr . ".html";
if (is_file($htmlFile)) {
$zip->addFile($htmlFile, $dateStr . ".html");
$added++;
}
if (is_file($jsonFile)) {
$zip->addFile($jsonFile, $dateStr . ".json");
$added++;
}
}
$zip->close();
if ($added === 0) {
@unlink($zipPath);
http_response_code(404);
echo "No snapshots found";
return;
}
header("Content-Type: application/zip");
header('Content-Disposition: attachment; filename="pv_plots_' . $this->InstanceID . '_' . $days . 'days.zip"');
header("Content-Length: " . filesize($zipPath));
readfile($zipPath);
@unlink($zipPath);
}
// ----------------- Hook registration -----------------
private function RegisterHook(string $Hook)
{
$ids = IPS_GetInstanceListByModuleID(self::WEBHOOK_GUID);
if (count($ids) === 0) return;
$hookID = (int)$ids[0];
$hooks = json_decode(IPS_GetProperty($hookID, "Hooks"), true);
if (!is_array($hooks)) $hooks = [];
foreach ($hooks as $h) {
if (isset($h["Hook"]) && $h["Hook"] === ltrim($Hook, "/")) {
return;
}
if (isset($h["Hook"]) && $h["Hook"] === $Hook) {
return;
}
}
$hooks[] = ["Hook" => $Hook, "TargetID" => $this->InstanceID];
IPS_SetProperty($hookID, "Hooks", json_encode($hooks));
IPS_ApplyChanges($hookID);
}
}
-59
View File
@@ -1,59 +0,0 @@
# PV_Visu
Visualisierung des Eigenverbrauchs: Tages-Quoten für PV-Produktion vs. Einspeisung und Verbrauch vs. Netz-Bezug.
## Inhaltsverzeichnis
1. [Funktionsumfang](#funktionsumfang)
2. [Voraussetzungen](#voraussetzungen)
3. [Installation](#installation)
4. [Instanz einrichten](#instanz-einrichten)
5. [WebFront](#webfront)
6. [PHP-Befehlsreferenz](#php-befehlsreferenz)
## Funktionsumfang
- Anzeige von Tages-Quoten (%)
- Produktion: Eigenverbrauch vs. Einspeisung
- Verbrauch: PV-Anteil vs. Netz-Anteil
- Zwei Balkendiagramme
- Absolute Tages-Summen (kWh)
## Voraussetzungen
- IP-Symcon ≥ 7.1
- Archiv-Modul aktiviert
- Vier kWh-Zähler-Variablen
## Installation
1. **Module Store** → Suche nach „PV_Visu“ und installieren
2. **Alternativ**: Unter Module → Repositories folgende URL hinzufügen:
```
https://github.com/DeinRepo/PV_Visu.git
```
und Modul neu einlesen.
## Instanz einrichten
- **Instanz hinzufügen** → Filter: „PV_Visu“
- Variablen zuweisen:
| Property | Beschreibung |
| -------------- | -------------------------- |
| VarProduction | PV-Produktionszähler (kWh) |
| VarConsumption | Gesamtverbrauch (kWh) |
| VarFeedIn | Einspeisung (kWh) |
| VarGrid | Netz-Bezug (kWh) |
## WebFront
- **Tile-Typ:** PV_Visu
- Balken 1 (Grün): Produktion
- Balken 2 (Orange/Rot): Verbrauch
## PHP-Befehlsreferenz
```php
IPS_RequestAction($InstanceID, 'update', true);
```
-9
View File
@@ -1,9 +0,0 @@
{
"elements": [
{ "type": "SelectVariable", "name": "VarProduction", "caption": "Produktion (kWh)" },
{ "type": "SelectVariable", "name": "VarConsumption", "caption": "Verbrauch (kWh)" },
{ "type": "SelectVariable", "name": "VarFeedIn", "caption": "Einspeisung (kWh)" },
{ "type": "SelectVariable", "name": "VarGrid", "caption": "Bezug Netz (kWh)" }
],
"actions": []
}
-78
View File
@@ -1,78 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body { margin: 0; padding: 8px; background: transparent; font-family: sans-serif; color: #ffffff; }
.bar-block { margin-bottom: 20px; }
.bar-title { font-size: 1.2em; font-weight: bold; margin-bottom: 6px; }
.bar-container { width: 100%; background: #ddd; border-radius: 4px; overflow: hidden; height: 24px; position: relative; }
.bar { height: 100%; float: left; position: relative; }
.bar span { position: absolute; width: 100%; text-align: center; line-height: 24px; font-size: 0.8em; color: #fff; }
.bar-cons { background: #4CAF50; }
.bar-feed { background: #8BC34A; }
.bar-pv { background: #FF9800; }
.bar-grid { background: #FF5722; }
.value-text { font-size: 0.95em; margin-top: 4px; }
</style>
</head>
<body>
<div id="pv_visu">
<div class="bar-block">
<div class="bar-title">Produktion (Eigenverbrauch / Einspeisung)</div>
<div class="bar-container">
<div class="bar bar-cons" id="barCons"><span id="barConsText"></span></div>
<div class="bar bar-feed" id="barFeed"><span id="barFeedText"></span></div>
</div>
<div class="value-text" id="prodValues"></div>
</div>
<div class="bar-block">
<div class="bar-title">Verbrauch (PV / Netz)</div>
<div class="bar-container">
<div class="bar bar-pv" id="barPV"><span id="barPVText"></span></div>
<div class="bar bar-grid" id="barGrid"><span id="barGridText"></span></div>
</div>
<div class="value-text" id="consValues"></div>
</div>
</div>
<script>
function Apply(data) {
document.getElementById('barCons').style.width = data.prodCons + '%';
document.getElementById('barFeed').style.width = data.prodFeed + '%';
document.getElementById('barPV').style.width = data.consPV + '%';
document.getElementById('barGrid').style.width = data.consGrid + '%';
document.getElementById('barConsText').innerText = data.prodCons + '%';
document.getElementById('barFeedText').innerText = data.prodFeed + '%';
document.getElementById('barPVText').innerText = data.consPV + '%';
document.getElementById('barGridText').innerText = data.consGrid + '%';
document.getElementById('prodValues').innerText =
'Gesamt: ' + data.value.prod + ' kWh, Eigenverbrauch: ' + (data.consPV/100*data.value.cons).toFixed(2) + ' kWh, Einspeisung: ' + data.value.feed + ' kWh';
document.getElementById('consValues').innerText =
'Gesamt: ' + data.value.cons + ' kWh, PV-Anteil: ' + (data.consPV/100*data.value.cons).toFixed(2) + ' kWh, Netz: ' + data.value.grid + ' kWh';
}
function handleMessage(msg) {
try {
const data = typeof msg === 'string' ? JSON.parse(msg) : msg;
Apply(data);
} catch (e) {
console.error('Fehler beim Verarbeiten der Daten:', e, msg);
}
}
if (typeof registerMessageHandler === 'function') {
registerMessageHandler(handleMessage);
}
// Live-Aktualisierung alle 30 Sekunden
function pollData() {
if (typeof IPS !== 'undefined') {
IPS.RequestAction('update', '');
}
}
setInterval(pollData, 30000);
</script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
{
"id": "{DDE89CBE-4411-5FF4-4931-14204E05CAD0}",
"name": "PV_Visu",
"type": 3,
"vendor": "Belevo AG",
"aliases": [],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "",
"url": ""
}
-99
View File
@@ -1,99 +0,0 @@
<?php
class PV_Visu extends IPSModule
{
public function Create()
{
parent::Create();
$this->RegisterPropertyInteger('VarProduction', 0);
$this->RegisterPropertyInteger('VarConsumption', 0);
$this->RegisterPropertyInteger('VarFeedIn', 0);
$this->RegisterPropertyInteger('VarGrid', 0);
$this->RegisterVariableString('JSONData', 'Visualisierungsdaten', '', 0);
IPS_SetHidden($this->GetIDForIdent('JSONData'), true);
$this->SetVisualizationType(1); // HTML SDK Tile
}
public function ApplyChanges()
{
parent::ApplyChanges();
foreach (['VarProduction', 'VarConsumption', 'VarFeedIn', 'VarGrid'] as $prop) {
$vid = $this->ReadPropertyInteger($prop);
if ($vid > 0) {
$this->RegisterMessage($vid, VM_UPDATE);
}
}
$this->UpdateData(); // Initial
}
public function MessageSink($TimeStamp, $SenderID, $Message, $Data)
{
if ($Message === VM_UPDATE) {
$this->UpdateData();
}
}
public function GetVisualizationTile()
{
$initialData = '<script>handleMessage(' . json_encode($this->UpdateData()) . ');</script>';
$html = file_get_contents(__DIR__ . '/module.html');
return $html . $initialData;
}
public function RequestAction($Ident, $Value)
{
if ($Ident === 'update') {
return $this->UpdateData(); // Rückgabe für Visualisierung
}
throw new \Exception("Unknown Ident: $Ident");
}
public function UpdateData()
{
$start = strtotime('today 00:00');
$end = time();
$prod = $this->GetDailyTotal($this->ReadPropertyInteger('VarProduction'), $start, $end);
$cons = $this->GetDailyTotal($this->ReadPropertyInteger('VarConsumption'), $start, $end);
$feed = $this->GetDailyTotal($this->ReadPropertyInteger('VarFeedIn'), $start, $end);
$grid = $this->GetDailyTotal($this->ReadPropertyInteger('VarGrid'), $start, $end);
$prodCons = $prod > 0 ? (($cons - $grid) / $prod) * 100 : 0;
$prodFeed = $prod > 0 ? 100 - $prodCons : 0;
$consPV = $cons > 0 ? min($prod, ($cons - $grid)) / $cons * 100 : 0;
$consGrid = $cons > 0 ? 100 - $consPV : 0;
$data = [
'prodCons' => round($prodCons, 1),
'prodFeed' => round($prodFeed, 1),
'consPV' => round($consPV, 1),
'consGrid' => round($consGrid, 1),
'value' => [
'prod' => round($prod, 2),
'cons' => round($cons, 2),
'feed' => round($feed, 2),
'grid' => round($grid, 2),
],
];
$json = json_encode($data);
SetValueString($this->GetIDForIdent('JSONData'), $json);
return $data;
}
private function GetDailyTotal(int $varID, int $start, int $end)
{
if ($varID <= 0) return 0.0;
$archiveID = @IPS_GetInstanceListByModuleID('{43192F0B-135B-4CE7-A0A7-1475603F3060}')[0];
if (!$archiveID) return 0.0;
$values = @AC_GetAggregatedValues($archiveID, $varID, 1, $start, $end, 1);
return isset($values[0]['Avg']) ? (float)$values[0]['Avg'] : 0.0;
}
}
-59
View File
@@ -1,59 +0,0 @@
# PV_Visu
Visualisierung des Eigenverbrauchs: Tages-Quoten für PV-Produktion vs. Einspeisung und Verbrauch vs. Netz-Bezug.
## Inhaltsverzeichnis
1. [Funktionsumfang](#funktionsumfang)
2. [Voraussetzungen](#voraussetzungen)
3. [Installation](#installation)
4. [Instanz einrichten](#instanz-einrichten)
5. [WebFront](#webfront)
6. [PHP-Befehlsreferenz](#php-befehlsreferenz)
## Funktionsumfang
- Anzeige von Tages-Quoten (%)
- Produktion: Eigenverbrauch vs. Einspeisung
- Verbrauch: PV-Anteil vs. Netz-Anteil
- Zwei Balkendiagramme
- Absolute Tages-Summen (kWh)
## Voraussetzungen
- IP-Symcon ≥ 7.1
- Archiv-Modul aktiviert
- Vier kWh-Zähler-Variablen
## Installation
1. **Module Store** → Suche nach „PV_Visu“ und installieren
2. **Alternativ**: Unter Module → Repositories folgende URL hinzufügen:
```
https://github.com/DeinRepo/PV_Visu.git
```
und Modul neu einlesen.
## Instanz einrichten
- **Instanz hinzufügen** → Filter: „PV_Visu“
- Variablen zuweisen:
| Property | Beschreibung |
| -------------- | -------------------------- |
| VarProduction | PV-Produktionszähler (kWh) |
| VarConsumption | Gesamtverbrauch (kWh) |
| VarFeedIn | Einspeisung (kWh) |
| VarGrid | Netz-Bezug (kWh) |
## WebFront
- **Tile-Typ:** PV_Visu
- Balken 1 (Grün): Produktion
- Balken 2 (Orange/Rot): Verbrauch
## PHP-Befehlsreferenz
```php
IPS_RequestAction($InstanceID, 'update', true);
```
-105
View File
@@ -1,105 +0,0 @@
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**
| 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.*
-106
View File
@@ -1,106 +0,0 @@
{
"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" }
]
}
},
{
"caption": "Bit-Länge",
"name": "BitLength",
"width": "80px",
"add": "16",
"edit": {
"type": "Select",
"options": [
{ "caption": "16 Bit", "value": "16" },
{ "caption": "32 Bit", "value": "32" },
{ "caption": "64 Bit", "value": "64" }
]
}
},
{
"caption": "Signed/Unsigned",
"name": "Signedness",
"width": "100px",
"add": "Unsigned",
"edit": {
"type": "Select",
"options": [
{ "caption": "Unsigned", "value": "Unsigned" },
{ "caption": "Signed", "value": "Signed" }
]
}
}
]
}
],
"actions": []
}
-12
View File
@@ -1,12 +0,0 @@
{
"id": "{C26E97C8-BA00-0563-6B14-B807C5ACE17F}",
"name": "SofarWechselrichter",
"type": 3,
"vendor": "Belevo AG",
"aliases": [],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "GEF",
"url": ""
}
-379
View File
@@ -1,379 +0,0 @@
<?php
declare(strict_types=1);
/**
* Sofar Wechselrichter Modul (IP-Symcon)
*
* - LoggerNumber als String (kein Integer-Overflow).
* - Negative Skalierungen erlaubt.
* - Bit-Länge pro Register (16, 32, 64) auswählbar.
* - Signed/Unsigned pro Register wählbar.
* - Liest 32/64-Bit-Werte registerweise einzeln und setzt anschließend zusammen.
* - Gelöschte Register-Variablen werden entfernt.
* - Debug-Logs zeigen Raw-Response, um Slice-Position zu prüfen.
*/
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 Register)
$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 Register entfernt wurden)
$validIdents = [];
foreach ($registers as $entry) {
$validIdents[] = 'Reg' . ((int)$entry['RegisterNumber']);
}
$children = IPS_GetChildrenIDs($this->InstanceID);
foreach ($children as $childID) {
$obj = IPS_GetObject($childID);
if ($obj['ObjectType'] !== 2) {
continue;
}
$ident = $obj['ObjectIdent'];
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 den 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
{
$this->LogMessage('Query invoked', KL_MESSAGE);
$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: einzeln auslesen, zusammensetzen, skalieren, speichern
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']));
$bitLength = (int) $entry['BitLength']; // 16, 32 oder 64
$signedness = trim((string)$entry['Signedness']); // "Signed" oder "Unsigned"
$ident = 'Reg' . $regNo;
if ($regNo < 0 || $label === '' || !in_array($bitLength, [16, 32, 64])) {
continue;
}
try {
$numRegs = $bitLength / 16; // 1, 2 oder 4
// Bytes registerweise einzeln abfragen und zusammenfügen:
$dataBytes = '';
for ($i = 0; $i < $numRegs; $i++) {
$chunk = $this->readSingleRegister($ip, $loggerNumberStr, $regNo + $i);
$dataBytes .= $chunk;
}
// Debug: raw combined response hex
$combinedHex = strtoupper(bin2hex($dataBytes));
$this->LogMessage("Raw data for Reg {$regNo} ({$bitLength}bit): {$combinedHex}", KL_MESSAGE);
// Endian-Handling: falls LE, kehre gesamte Byte-Reihenfolge um
if ($endian === 'LE') {
$combinedHex = $this->reverseByteOrder($combinedHex);
$this->LogMessage("After LE reverse: {$combinedHex}", KL_MESSAGE);
}
// Konvertiere Hex in Dezimal-String
$rawDec = $this->hexToDecimal($combinedHex);
// Bei "Signed" → Zwei-Komplement-Umrechnung
if ($signedness === 'Signed') {
$half = bcpow('2', (string)($bitLength - 1), 0); // 2^(bitLength-1)
$fullRange = bcpow('2', (string)$bitLength, 0); // 2^bitLength
if (bccomp($rawDec, $half) >= 0) {
$rawDec = bcsub($rawDec, $fullRange, 0);
}
$this->LogMessage("Signed rawDec for Reg {$regNo}: {$rawDec}", KL_MESSAGE);
}
// Skaliere (bc*-Multiplikation, 4 Nachkommastellen)
$valueStr = bcmul($rawDec, $scale, 4);
SetValueFloat($this->GetIDForIdent($ident), (float)$valueStr);
$this->LogMessage("Final value for Reg {$regNo}: {$valueStr}", KL_MESSAGE);
} catch (Exception $e) {
$this->LogMessage(
"Fehler Lesen Reg {$regNo} ({$bitLength}bit, {$signedness}): " . $e->getMessage(),
KL_WARNING
);
}
}
}
/**
* Liest genau ein Register (16 Bit) 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-String
* @throws Exception Bei Kommunikationsfehlern
*/
private function readSingleRegister(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);
$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 (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 (alle 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.");
}
// Debug: log raw response hex
$respHex = strtoupper(bin2hex($response));
$this->LogMessage("Raw response for single reg {$reg}: {$respHex}", KL_MESSAGE);
// 6) Slice-Logik: l = 2*1 + 4 = 6, slice(-6, 2) → 2 Bytes
$lModbus = 2 * 1 + 4; // = 6
$numBytes = 2;
if (strlen($response) < $lModbus) {
throw new Exception("Unerwartet kurze Antwort (< {$lModbus} Bytes).");
}
$dataBytes = substr($response, -$lModbus, $numBytes);
if (strlen($dataBytes) < $numBytes) {
throw new Exception("Data-Segment enthält weniger als {$numBytes} Bytes.");
}
$dataHex = strtoupper(bin2hex($dataBytes));
$this->LogMessage("Sliced data for single reg {$reg}: {$dataHex}", KL_MESSAGE);
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);
}
/**
* Kehrt die Byte-Reihenfolge eines Hex-Strings um (2 Hex-Zeichen = 1 Byte).
*
* @param string $hex Hex-Repr. (z.B. "A1B2C3D4")
* @return string Umgekehrte Byte-Reihenfolge (z.B. "D4C3B2A1")
*/
private function reverseByteOrder(string $hex): string
{
$bytes = str_split($hex, 2);
$bytes = array_reverse($bytes);
return implode('', $bytes);
}
/**
* Konvertiert einen Hex-String in einen Dezimal-String (BCMath).
*
* @param string $hex Uppercase-Hex ohne Präfix (z.B. "00FF10A3")
* @return string Dezimal-String (z.B. "16737763")
*/
private function hexToDecimal(string $hex): string
{
$hex = ltrim($hex, '0');
if ($hex === '') {
return '0';
}
$len = strlen($hex);
$dec = '0';
$power16 = '1';
for ($i = $len - 1; $i >= 0; $i--) {
$digit = hexdec($hex[$i]);
$term = bcmul((string)$digit, $power16, 0);
$dec = bcadd($dec, $term, 0);
$power16 = bcmul($power16, '16', 0);
}
return $dec;
}
/**
* 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));
}
}