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:
@@ -17,15 +17,34 @@
|
|||||||
"suffix": "Sekunden"
|
"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",
|
"name": "MaxBatterieleistung",
|
||||||
"caption": "Maximale Batterieleistung",
|
"caption": "Maximale Ladeleistung",
|
||||||
"suffix": ""
|
"suffix": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "NumberSpinner",
|
"type": "SelectVariable",
|
||||||
"name": "MaxNachladen",
|
"name": "MaxNachladen",
|
||||||
"caption": "Maximum Nachladen",
|
"caption": "Maximum Nachladeleistung",
|
||||||
"suffix": ""
|
"suffix": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -52,7 +71,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"caption":"Durch EMS Symcon",
|
"caption":"Durch EMS Symcon",
|
||||||
"value":4
|
"value":2
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -67,10 +86,9 @@
|
|||||||
"name": "Netzbezug",
|
"name": "Netzbezug",
|
||||||
"caption": "Variable mit dem zu regelnden Netzbezug"
|
"caption": "Variable mit dem zu regelnden Netzbezug"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "SelectVariable",
|
"type": "Label",
|
||||||
"name": "Batterieleistung_Effektiv",
|
"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"
|
||||||
"caption": "Effektive, aktuelle Batterieleistung"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"id": "{F6C6A4B2-C5BB-4D94-2629-01D8B0D4CAF5}",
|
"id": "{166B9E49-882B-ADED-F256-4DD4CC24DF6C}",
|
||||||
"name": "Batterie",
|
"name": "Batterie",
|
||||||
"type": 3,
|
"type": 3,
|
||||||
"vendor": "Belevo AG",
|
"vendor": "Belevo AG",
|
||||||
|
|||||||
@@ -12,17 +12,16 @@ class Batterie extends IPSModule
|
|||||||
$this->RegisterPropertyInteger("AufdasNachladen",0);
|
$this->RegisterPropertyInteger("AufdasNachladen",0);
|
||||||
$this->RegisterPropertyInteger("MinimumEntladen",0);
|
$this->RegisterPropertyInteger("MinimumEntladen",0);
|
||||||
$this->RegisterPropertyInteger("Batterieladezustand",0);
|
$this->RegisterPropertyInteger("Batterieladezustand",0);
|
||||||
$this->RegisterPropertyInteger("Batteriemanagement", 1);
|
$this->RegisterPropertyInteger("Batteriemanagement", 1);
|
||||||
|
$this->RegisterPropertyInteger("Batterietyp", 1);
|
||||||
$this->RegisterPropertyInteger("MaxNachladen",0);
|
$this->RegisterPropertyInteger("MaxNachladen",0);
|
||||||
$this->RegisterPropertyInteger("Netzbezug", 0); // Initialisierung mit 0
|
$this->RegisterPropertyInteger("Netzbezug", 0); // Initialisierung mit 0
|
||||||
$this->RegisterPropertyInteger("Interval", 2); // Recheninterval
|
$this->RegisterPropertyInteger("Interval", 2); // Recheninterval
|
||||||
$this->RegisterPropertyInteger("Batterieleistung_Effektiv", 0); // Recheninterval
|
|
||||||
|
|
||||||
// Variabeln für Kommunkation mit Manager
|
// Variabeln für Kommunkation mit Manager
|
||||||
$this->RegisterVariableFloat("Entladeleistung","Entladeleistung", "",0);
|
|
||||||
$this->RegisterVariableInteger("Batteriemanagement_Variabel","Batteriemanagement_Variabel", "",0);
|
$this->RegisterVariableInteger("Batteriemanagement_Variabel","Batteriemanagement_Variabel", "",0);
|
||||||
$this->RegisterVariableInteger("Laden3_Entladen4","Laden3_Entladen4", "",3);
|
$this->RegisterVariableInteger("Laden_Entladen","Laden_Entladen", "",3);
|
||||||
$this->RegisterVariableFloat("Ladeleistung","Ladeleistung", "",0);
|
|
||||||
$this->RegisterVariableInteger("Aktuelle_Leistung", "Aktuelle_Leistung", "", 0);
|
$this->RegisterVariableInteger("Aktuelle_Leistung", "Aktuelle_Leistung", "", 0);
|
||||||
$this->RegisterVariableString("PowerSteps", "PowerSteps");
|
$this->RegisterVariableString("PowerSteps", "PowerSteps");
|
||||||
$this->RegisterVariableBoolean("Idle", "Idle", "", 0);
|
$this->RegisterVariableBoolean("Idle", "Idle", "", 0);
|
||||||
@@ -58,49 +57,143 @@ class Batterie extends IPSModule
|
|||||||
$batterieManagement = $this->ReadPropertyInteger("Batteriemanagement");
|
$batterieManagement = $this->ReadPropertyInteger("Batteriemanagement");
|
||||||
$this->SetValue("Batteriemanagement_Variabel", $batterieManagement);
|
$this->SetValue("Batteriemanagement_Variabel", $batterieManagement);
|
||||||
$this->SetTimerInterval("Timer_Do_UserCalc_Battery",$this->ReadPropertyInteger("Interval")*1000);
|
$this->SetTimerInterval("Timer_Do_UserCalc_Battery",$this->ReadPropertyInteger("Interval")*1000);
|
||||||
}
|
|
||||||
|
|
||||||
|
$batterietyp = $this->ReadPropertyInteger("Batterietyp");
|
||||||
|
|
||||||
private function GeneratePowerSteps($additionalValue)
|
switch ($batterietyp) {
|
||||||
{
|
case 1: // Goodwe
|
||||||
$maxleistung = $this->ReadPropertyInteger("MaxBatterieleistung");
|
$this->MaintainVariable("Goodwe_EntLadeleistung", "Goodwe_EntLadeleistung", VARIABLETYPE_FLOAT, "", 10, true);
|
||||||
$stepSize = 250; // Schrittgröße
|
$this->MaintainVariable("Ladeleistung", "Ladeleistung", VARIABLETYPE_FLOAT, "", 11, false);
|
||||||
$stepSizeSmall = 50; // Kleine Schrittgröße
|
$this->MaintainVariable("Entladeleistung", "Entladeleistung", VARIABLETYPE_FLOAT, "", 13, false);
|
||||||
|
$this->MaintainVariable("Laden_Entladen", "Laden_Entladen", VARIABLETYPE_INTEGER, "", 12, true);
|
||||||
// Array direkt als Range erzeugen (schneller als Schleife)
|
break;
|
||||||
$array_powersteps = range(-$maxleistung, $maxleistung, $stepSize);
|
|
||||||
|
case 2: // Solaredge
|
||||||
// Nächstgelegenen Wert direkt bestimmen (rundet auf den nächsten Step)
|
$this->MaintainVariable("Goodwe_EntLadeleistung", "Goodwe_EntLadeleistung", VARIABLETYPE_FLOAT, "", 10, false);
|
||||||
$closestValue = round($additionalValue / $stepSize) * $stepSize;
|
$this->MaintainVariable("Ladeleistung", "Ladeleistung", VARIABLETYPE_FLOAT, "", 11, true);
|
||||||
|
$this->MaintainVariable("Entladeleistung", "Entladeleistung", VARIABLETYPE_FLOAT, "", 13, true);
|
||||||
// Falls der Wert nicht im Bereich liegt, abbrechen
|
$this->MaintainVariable("Laden_Entladen", "Laden_Entladen", VARIABLETYPE_INTEGER, "", 12, true);
|
||||||
if (!in_array($closestValue, $array_powersteps)) {
|
break;
|
||||||
return $array_powersteps;
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index des gefundenen Werts suchen
|
$maxBatVar = $this->ReadPropertyInteger("MaxBatterieleistung");
|
||||||
$index = array_search($closestValue, $array_powersteps);
|
$maxNachVar = $this->ReadPropertyInteger("MaxNachladen");
|
||||||
|
|
||||||
// Zusätzliche Werte berechnen und auf MaxLeistung begrenzen
|
if ($maxBatVar > 0) {
|
||||||
$newValues = array_filter([
|
$this->RegisterMessage($maxBatVar, VM_UPDATE);
|
||||||
$closestValue - 4 * $stepSizeSmall,
|
}
|
||||||
$closestValue - 3 * $stepSizeSmall,
|
if ($maxNachVar > 0) {
|
||||||
$closestValue - 2 * $stepSizeSmall,
|
$this->RegisterMessage($maxNachVar, VM_UPDATE);
|
||||||
$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 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_raw = GetValue($this->ReadPropertyInteger("MaxBatterieleistung"));
|
||||||
|
$nachladen_raw = GetValue($this->ReadPropertyInteger("MaxNachladen"));
|
||||||
|
|
||||||
|
$stepSize = 250; // Grobe Schrittgröße
|
||||||
|
$stepSizeSmall = 50; // Feine Schrittgröße
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplikate vermeiden (falls schon Werte vorhanden sind)
|
||||||
|
$newValues = array_values(array_unique($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,39 +225,128 @@ public function RequestAction($Ident, $Value)
|
|||||||
public function SetAktuelle_Leistung(int $power)
|
public function SetAktuelle_Leistung(int $power)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
|
$batterietyp = $this->ReadPropertyInteger("Batterietyp");
|
||||||
$batterieManagement = $this->ReadPropertyInteger("Batteriemanagement");
|
$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("Entladeleistung", 0);
|
||||||
$this->SetValue("Ladeleistung", 0);
|
$this->SetValue("Ladeleistung", 0);
|
||||||
|
$this->SetValue("Batteriemanagement_Variabel", 1);
|
||||||
return;
|
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($this->GetValue("Is_Peak_Shaving")==true){
|
||||||
if ($power >= 0) {
|
|
||||||
$this->SetValue("Ladeleistung", $power);
|
if ($power >= 0) {
|
||||||
$this->SetValue("Entladeleistung", 0);
|
$this->SetValue("Goodwe_EntLadeleistung", abs($power));
|
||||||
$this->SetValue("Laden3_Entladen4", 3);
|
$this->SetValue("Laden_Entladen", 11);
|
||||||
} else {
|
} else {
|
||||||
$this->SetValue("Entladeleistung", abs($power));
|
$this->SetValue("Goodwe_EntLadeleistung", abs($power));
|
||||||
$this->SetValue("Ladeleistung", 0);
|
$this->SetValue("Laden_Entladen", 12);
|
||||||
$this->SetValue("Laden3_Entladen4", 4);
|
}
|
||||||
|
|
||||||
|
}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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}else{
|
}elseif ($batterietyp == 2) {//Solaredge
|
||||||
if ($power >= 0) {
|
|
||||||
$this->SetValue("Ladeleistung", $power);
|
|
||||||
$this->SetValue("Entladeleistung", 0);
|
|
||||||
$this->SetValue("Laden3_Entladen4", 3);
|
|
||||||
} else {
|
|
||||||
$this->SetValue("Entladeleistung", abs($power));
|
|
||||||
$this->SetValue("Ladeleistung", 0);
|
|
||||||
$this->SetValue("Laden3_Entladen4", 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
//-----------------------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("Laden_Entladen", 3);
|
||||||
|
} else {
|
||||||
|
$this->SetValue("Entladeleistung", abs($power));
|
||||||
|
$this->SetValue("Ladeleistung", 0);
|
||||||
|
$this->SetValue("Laden_Entladen", 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
}else{
|
||||||
|
if ($power >= 0) {
|
||||||
|
$this->SetValue("Ladeleistung", $power);
|
||||||
|
$this->SetValue("Entladeleistung", 0);
|
||||||
|
$this->SetValue("Laden_Entladen", 3);
|
||||||
|
} else {
|
||||||
|
$this->SetValue("Entladeleistung", abs($power));
|
||||||
|
$this->SetValue("Ladeleistung", 0);
|
||||||
|
$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
|
// Prüfe auf Änderung der Leistung im Vergleich zur letzten Einstellung
|
||||||
$lastPower = GetValue($this->GetIDForIdent("Aktuelle_Leistung"));
|
$lastPower = GetValue($this->GetIDForIdent("Aktuelle_Leistung"));
|
||||||
if ($power != $lastPower) {
|
if ($power != $lastPower) {
|
||||||
@@ -193,7 +375,7 @@ public function RequestAction($Ident, $Value)
|
|||||||
$array_powersteps = $this->GeneratePowerSteps($this->GetValue("Aktuelle_Leistung"));
|
$array_powersteps = $this->GeneratePowerSteps($this->GetValue("Aktuelle_Leistung"));
|
||||||
$aufdasnachladen = $this->ReadPropertyInteger("AufdasNachladen");
|
$aufdasnachladen = $this->ReadPropertyInteger("AufdasNachladen");
|
||||||
$minimumentladen = $this->ReadPropertyInteger("MinimumEntladen");
|
$minimumentladen = $this->ReadPropertyInteger("MinimumEntladen");
|
||||||
$maxleistung = $this->ReadPropertyInteger("MaxBatterieleistung");
|
$maxleistung = GetValue($this->ReadPropertyInteger("MaxBatterieleistung"));
|
||||||
$dummy_array = [];
|
$dummy_array = [];
|
||||||
$batterieladezustand = GetValue($this->ReadPropertyInteger("Batterieladezustand"));
|
$batterieladezustand = GetValue($this->ReadPropertyInteger("Batterieladezustand"));
|
||||||
$filtered_powersteps_entladen = [];
|
$filtered_powersteps_entladen = [];
|
||||||
@@ -277,7 +459,7 @@ public function RequestAction($Ident, $Value)
|
|||||||
|
|
||||||
}elseif($batterieladezustand<$aufdasnachladen){
|
}elseif($batterieladezustand<$aufdasnachladen){
|
||||||
|
|
||||||
$dummy_array[] = $this->ReadPropertyInteger("MaxNachladen");
|
$dummy_array[] = GetValue($this->ReadPropertyInteger("MaxNachladen"));
|
||||||
$this->SetValue("PowerSteps", json_encode($dummy_array));
|
$this->SetValue("PowerSteps", json_encode($dummy_array));
|
||||||
IPS_LogMessage("Batterie", "im 3");
|
IPS_LogMessage("Batterie", "im 3");
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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": ""
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
@@ -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);"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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": ""
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
```
|
|
||||||
@@ -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": []
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "{DDE89CBE-4411-5FF4-4931-14204E05CAD0}",
|
|
||||||
"name": "PV_Visu",
|
|
||||||
"type": 3,
|
|
||||||
"vendor": "Belevo AG",
|
|
||||||
"aliases": [],
|
|
||||||
"parentRequirements": [],
|
|
||||||
"childRequirements": [],
|
|
||||||
"implemented": [],
|
|
||||||
"prefix": "",
|
|
||||||
"url": ""
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
```
|
|
||||||
@@ -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.*
|
|
||||||
@@ -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": []
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "{C26E97C8-BA00-0563-6B14-B807C5ACE17F}",
|
|
||||||
"name": "SofarWechselrichter",
|
|
||||||
"type": 3,
|
|
||||||
"vendor": "Belevo AG",
|
|
||||||
"aliases": [],
|
|
||||||
"parentRequirements": [],
|
|
||||||
"childRequirements": [],
|
|
||||||
"implemented": [],
|
|
||||||
"prefix": "GEF",
|
|
||||||
"url": ""
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user