no message

This commit is contained in:
mh
2026-03-03 11:29:51 +01:00
parent f11255b071
commit 6757682399
20 changed files with 0 additions and 2436 deletions
-29
View File
@@ -250,35 +250,6 @@ public function RequestAction($Ident, $Value)
}elseif ($batterieManagement == 2 && $batterietyp == 2) { }elseif ($batterieManagement == 2 && $batterietyp == 2) {
$this->SetValue("Batteriemanagement_Variabel", 4); $this->SetValue("Batteriemanagement_Variabel", 4);
} }
/*
if($this->GetValue("Is_Peak_Shaving")==true){
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);
}
}else{
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);
}
}
*/
$batterietyp = $this->ReadPropertyInteger("Batterietyp"); $batterietyp = $this->ReadPropertyInteger("Batterietyp");
-51
View File
@@ -1,51 +0,0 @@
{
"elements": [
{
"type": "ValidationTextBox",
"name": "URL",
"caption": "Solcast URL"
},
{
"type": "SelectVariable",
"name": "ActualVariableID",
"caption": "Ist-Produktion Variable (Leistung)"
},
{
"type": "Select",
"name": "RefreshMode",
"caption": "Aktualisierungsmodus",
"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. 06:00)"
},
{
"type": "CheckBox",
"name": "ActualIsWatt",
"caption": "Istwerte sind in Watt (in kW umrechnen)"
}
],
"actions": [
{
"type": "Button",
"caption": "Forecast jetzt aktualisieren",
"onClick": "IPS_RequestAction($id, \"UpdateForecast\", 0);"
},
{
"type": "Label",
"caption": "Hinweis: Istwerte werden aus dem Archiv gelesen. Variable muss im Archiv geloggt werden."
}
]
}
-186
View File
@@ -1,186 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>PV_Forecast</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?instance=${instanceId}&action=data`;
let chart;
async function loadData() {
metaEl.textContent = "⏳ Daten werden geladen…";
const r = await fetch(endpoint, { cache: "no-store" });
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`HTTP ${r.status}${text ? " - " + text : ""}`);
}
return await r.json();
}
function render(data) {
if (typeof Highcharts === "undefined") {
metaEl.textContent = "Fehler: Highcharts nicht geladen.";
return;
}
const forecast = data?.series?.forecast ?? [];
const actual = data?.series?.actual ?? [];
const cachedAt = data?.meta?.forecast_cached_at
? new Date(data.meta.forecast_cached_at * 1000).toLocaleString()
: "unbekannt";
const nowMs = data?.meta?.now ?? Date.now();
metaEl.textContent =
`OK | Cache: ${cachedAt} | Forecast: ${forecast.length} | Ist: ${actual.length}`;
const options = {
title: {
text: "PV: Erwartung vs. Tatsächlich (heute)",
style: { fontWeight: "700" }
},
chart: {
animation: false,
spacingTop: 18
},
xAxis: {
type: "datetime",
},
yAxis: {
title: {
text: "Leistung (kW)",
style: { fontWeight: "700" }
},
gridLineColor: "#e5e7eb"
},
legend: { enabled: true },
tooltip: {
shared: true,
formatter: function () {
let s = `<b>${Highcharts.dateFormat('%d.%m.%Y %H:%M', this.x)}</b><br/>`;
this.points.forEach(p => {
const val = (p.y === null || typeof p.y === "undefined")
? "–"
: `${p.y.toFixed(1)} kW`;
s += `<span style="color:${p.color}">●</span> ${p.series.name}: <b>${val}</b><br/>`;
});
return s;
}
},
plotOptions: {
series: {
animation: false,
turboThreshold: 0,
marker: { enabled: true }
}
},
series: [
{
name: "Erwartet (Solcast)",
data: forecast,
color: "#38bdf8",
lineWidth: 4,
marker: { radius: 4 }
},
{
name: "Tatsächlich (Archiv)",
data: actual,
color: "#7c3aed",
lineWidth: 4,
marker: { radius: 5, symbol: "diamond" }
}
],
credits: { enabled: false }
};
if (!chart) {
chart = Highcharts.chart("chart", options);
} else {
chart.series[0].setData(forecast, false);
chart.series[1].setData(actual, false);
chart.redraw();
}
}
(async () => {
try {
const data = await loadData();
render(data);
} catch (e) {
metaEl.textContent = "Fehler beim Laden: " + (e?.message ?? e);
}
})();
</script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
{
"id": "{D32FEB32-3F6E-AA8C-CEE6-A09C2789EDC7}",
"name": "PV_Forecast ",
"type": 3,
"vendor": "Belevo AG",
"aliases": [],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "PVF",
"url": ""
}
-348
View File
@@ -1,348 +0,0 @@
<?php
class PV_Forecast extends IPSModule
{
private const ARCHIVE_GUID = "{43192F0B-135B-4CE7-A0A7-1475603F3060}";
public function Create()
{
parent::Create();
$this->RegisterPropertyString("URL", "");
$this->RegisterPropertyInteger("ActualVariableID", 0);
// Scheduler
$this->RegisterPropertyString("RefreshMode", "interval"); // interval | daily
$this->RegisterPropertyInteger("RefreshMinutes", 5);
$this->RegisterPropertyString("RefreshTime", "06:00"); // HH:MM
$this->RegisterPropertyBoolean("ActualIsWatt", true);
// Timer -> ruft RequestAction(UpdateForecast) auf
$this->RegisterTimer("UpdateForecastTimer", 0, 'IPS_RequestAction($_IPS["TARGET"], "UpdateForecast", 0);');
// WebHook Endpoint
$this->RegisterHook("/hook/solcastcompare");
}
public function ApplyChanges()
{
parent::ApplyChanges();
// Tile Visualization aktivieren
$this->SetVisualizationType(1);
$mode = $this->ReadPropertyString("RefreshMode");
if ($mode === "interval") {
$mins = max(1, (int)$this->ReadPropertyInteger("RefreshMinutes"));
$this->SetTimerInterval("UpdateForecastTimer", $mins * 60 * 1000);
} else {
// daily: jede Minute prüfen
$this->SetTimerInterval("UpdateForecastTimer", 60 * 1000);
}
// optional: auto-register hook (wenn GUID passt)
$this->RegisterHook("/hook/solcastcompare");
}
public function RequestAction($Ident, $Value)
{
if ($Ident === "UpdateForecast") {
$this->HandleScheduledUpdate();
return;
}
throw new Exception("Unknown Ident: " . $Ident);
}
// ----------------- Scheduler -----------------
private function HandleScheduledUpdate(): void
{
$mode = $this->ReadPropertyString("RefreshMode");
if ($mode === "interval") {
$this->UpdateForecast();
return;
}
// daily
$timeStr = trim($this->ReadPropertyString("RefreshTime")); // "06:00"
$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);
// Schon heute gelaufen?
$lastRun = (int)$this->GetBuffer("LastDailyRun");
$today0 = strtotime("today");
if ($nowSec >= $targetSec && $lastRun < $today0) {
$this->SendDebug("Scheduler", "Taegliches Update ausgeloest (" . $timeStr . ")", 0);
$this->UpdateForecast();
$this->SetBuffer("LastDailyRun", (string)$now);
}
}
private function ParseHHMMToSeconds(string $hhmm): ?int
{
// akzeptiert "6:00" oder "06:00"
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 for chart -----------------
protected function ProcessHookData()
{
$instance = isset($_GET["instance"]) ? (int)$_GET["instance"] : 0;
$action = isset($_GET["action"]) ? (string)$_GET["action"] : "";
if ($instance !== $this->InstanceID) {
http_response_code(404);
echo "Wrong instance";
return;
}
if ($action !== "data") {
http_response_code(400);
echo "Unknown action";
return;
}
$tzLocal = new DateTimeZone(date_default_timezone_get());
$tzUtc = new DateTimeZone('UTC');
// Lokaler Tag (für Archiv / Anzeige)
$startLocal = new DateTime('today', $tzLocal);
$endLocal = new DateTime('tomorrow', $tzLocal);
$startLocalTs = $startLocal->getTimestamp();
$endLocalTs = $endLocal->getTimestamp();
// Lokaler Tagesbereich als UTC-Grenzen (für Solcast "Z")
$startUtcTs = (clone $startLocal)->setTimezone($tzUtc)->getTimestamp();
$endUtcTs = (clone $endLocal)->setTimezone($tzUtc)->getTimestamp();
$nowTs = time();
// Forecast: komplett für HEUTE (lokaler Tag), korrekt über UTC gefiltert
$forecast = $this->GetForecastSeriesFilteredUtc($startUtcTs, $endUtcTs);
// Actual: komplett HEUTE lokal, aber nach "jetzt" abbrechen (null)
$actual = $this->GetActualSeriesFromArchive($startLocalTs, $endLocalTs, 1800, $nowTs);
$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,
"start_utc" => $startUtcTs * 1000,
"end_utc" => $endUtcTs * 1000,
"now" => $nowTs * 1000
],
"series" => [
"forecast" => $forecast,
"actual" => $actual
]
];
header("Content-Type: application/json; charset=utf-8");
echo json_encode($out);
}
// ----------------- Series: Forecast (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"
if (isset($data["forecasts"]) && is_array($data["forecasts"])) {
$rows = $data["forecasts"];
} elseif (isset($data["estimated_actuals"]) && is_array($data["estimated_actuals"])) {
// fallback live endpoint
$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;
$series[] = [$ts * 1000, (float)$row["pv_power_rooftop"]];
}
usort($series, fn($a, $b) => $a[0] <=> $b[0]);
return $series;
}
// ----------------- Series: Actual (Archive) -----------------
private function GetActualSeriesFromArchive(int $startTs, int $endTs, int $bucketSeconds, int $nowTs): 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) {
// trotzdem Raster mit null zurückgeben, damit Linie sauber endet
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");
// Sauberes Raster über den Tag, nach "jetzt" -> null
for ($t = $startTs + $bucketSeconds; $t <= $endTs; $t += $bucketSeconds) {
// Zukunft: null, damit Istlinie nicht parallel bis 24:00 weiterläuft
if ($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;
}
// ----------------- optional: auto register hook -----------------
private function RegisterHook(string $Hook)
{
$ids = IPS_GetInstanceListByModuleID("{015A6EB8-D6E5-4B93-B496-0D3FAD4D0E6E}");
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"] === $Hook) return;
}
$hooks[] = ["Hook" => $Hook, "TargetID" => $this->InstanceID];
IPS_SetProperty($hookID, "Hooks", json_encode($hooks));
IPS_ApplyChanges($hookID);
}
}
-59
View File
@@ -1,59 +0,0 @@
# PV_Visu
Visualisierung des Eigenverbrauchs: Tages-Quoten für PV-Produktion vs. Einspeisung und Verbrauch vs. Netz-Bezug.
## Inhaltsverzeichnis
1. [Funktionsumfang](#funktionsumfang)
2. [Voraussetzungen](#voraussetzungen)
3. [Installation](#installation)
4. [Instanz einrichten](#instanz-einrichten)
5. [WebFront](#webfront)
6. [PHP-Befehlsreferenz](#php-befehlsreferenz)
## Funktionsumfang
- Anzeige von Tages-Quoten (%)
- Produktion: Eigenverbrauch vs. Einspeisung
- Verbrauch: PV-Anteil vs. Netz-Anteil
- Zwei Balkendiagramme
- Absolute Tages-Summen (kWh)
## Voraussetzungen
- IP-Symcon ≥ 7.1
- Archiv-Modul aktiviert
- Vier kWh-Zähler-Variablen
## Installation
1. **Module Store** → Suche nach „PV_Visu“ und installieren
2. **Alternativ**: Unter Module → Repositories folgende URL hinzufügen:
```
https://github.com/DeinRepo/PV_Visu.git
```
und Modul neu einlesen.
## Instanz einrichten
- **Instanz hinzufügen** → Filter: „PV_Visu“
- Variablen zuweisen:
| Property | Beschreibung |
| -------------- | -------------------------- |
| VarProduction | PV-Produktionszähler (kWh) |
| VarConsumption | Gesamtverbrauch (kWh) |
| VarFeedIn | Einspeisung (kWh) |
| VarGrid | Netz-Bezug (kWh) |
## WebFront
- **Tile-Typ:** PV_Visu
- Balken 1 (Grün): Produktion
- Balken 2 (Orange/Rot): Verbrauch
## PHP-Befehlsreferenz
```php
IPS_RequestAction($InstanceID, 'update', true);
```
-31
View File
@@ -1,31 +0,0 @@
{
"elements": [
{ "type": "ValidationTextBox", "name": "kunde", "caption": "Kundenname" },
{ "type": "ValidationTextBox", "name": "URL", "caption": "Solcast URL" },
{ "type": "SelectVariable", "name": "ActualVariableID", "caption": "Ist-Produktion Variable (Leistung)" },
{ "type": "CheckBox", "name": "ActualIsWatt", "caption": "Istwerte sind in Watt (in kW umrechnen)" },
{
"type": "Select",
"name": "RefreshMode",
"caption": "Forecast-Aktualisierung",
"options": [
{ "caption": "Alle X Minuten", "value": "interval" },
{ "caption": "Einmal täglich (Uhrzeit)", "value": "daily" }
]
},
{ "type": "NumberSpinner", "name": "RefreshMinutes", "caption": "Intervall (Minuten)", "minimum": 1, "maximum": 240 },
{ "type": "ValidationTextBox", "name": "RefreshTime", "caption": "Tägliche Uhrzeit (HH:MM, z.B. 00:01)" },
{ "type": "CheckBox", "name": "EnableDailySnapshots", "caption": "Tägliche Tages-Plots speichern" },
{ "type": "ValidationTextBox", "name": "SnapshotTime", "caption": "Snapshot-Uhrzeit (HH:MM, z.B. 23:59)" },
{ "type": "NumberSpinner", "name": "KeepDays", "caption": "Aufbewahrung (Tage)", "minimum": 1, "maximum": 365 }
],
"actions": [
{
"type": "Button",
"caption": "Forecast jetzt aktualisieren",
"onClick": "IPS_RequestAction($id, \"UpdateForecast\", 0);"
}
]
}
-186
View File
@@ -1,186 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>PV_Forecast_plotmemory</title>
<script src="https://code.highcharts.com/highcharts.js"></script>
<style>
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
margin: 0;
padding: 0;
background: transparent;
}
/* Kopfzeile */
.header {
display: flex;
align-items: center;
padding: 10px 14px;
background: #1f2937;
border-bottom: 1px solid #374151;
}
.meta {
color: #e5e7eb;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Chart */
#chart {
height: 520px;
width: 100%;
padding: 10px;
box-sizing: border-box;
background: #ffffff;
}
</style>
</head>
<body>
<div class="header">
<div class="meta" id="meta">Lade…</div>
</div>
<div id="chart"></div>
<script>
const metaEl = document.getElementById("meta");
window.onerror = (msg, url, line, col) => {
metaEl.textContent = `JS-Fehler: ${msg} (${line}:${col})`;
};
const instanceId = Number("{{INSTANCE_ID}}");
if (!Number.isFinite(instanceId) || instanceId <= 0) {
metaEl.textContent = "Fehler: INSTANCE_ID nicht korrekt.";
throw new Error("Invalid INSTANCE_ID");
}
const endpoint =
`${location.protocol}//${location.host}` +
`/hook/solcastcompare_plotmemory?instance=${instanceId}&action=data`;
let chart;
async function loadData() {
metaEl.textContent = "⏳ Daten werden geladen…";
const r = await fetch(endpoint, { cache: "no-store" });
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`HTTP ${r.status}${text ? " - " + text : ""}`);
}
return await r.json();
}
function render(data) {
if (typeof Highcharts === "undefined") {
metaEl.textContent = "Fehler: Highcharts nicht geladen.";
return;
}
const forecast = data?.series?.forecast ?? [];
const actual = data?.series?.actual ?? [];
const cachedAt = data?.meta?.forecast_cached_at
? new Date(data.meta.forecast_cached_at * 1000).toLocaleString()
: "unbekannt";
const nowMs = data?.meta?.now ?? Date.now();
metaEl.textContent =
`OK | Cache: ${cachedAt} | Forecast: ${forecast.length} | Ist: ${actual.length}`;
const options = {
title: {
text: "PV: Erwartung vs. Tatsächlich (heute)",
style: { fontWeight: "700" }
},
chart: {
animation: false,
spacingTop: 18
},
xAxis: {
type: "datetime",
},
yAxis: {
title: {
text: "Leistung (kW)",
style: { fontWeight: "700" }
},
gridLineColor: "#e5e7eb"
},
legend: { enabled: true },
tooltip: {
shared: true,
formatter: function () {
let s = `<b>${Highcharts.dateFormat('%d.%m.%Y %H:%M', this.x)}</b><br/>`;
this.points.forEach(p => {
const val = (p.y === null || typeof p.y === "undefined")
? "–"
: `${p.y.toFixed(1)} kW`;
s += `<span style="color:${p.color}">●</span> ${p.series.name}: <b>${val}</b><br/>`;
});
return s;
}
},
plotOptions: {
series: {
animation: false,
turboThreshold: 0,
marker: { enabled: true }
}
},
series: [
{
name: "Erwartet (Solcast)",
data: forecast,
color: "#38bdf8",
lineWidth: 4,
marker: { radius: 4 }
},
{
name: "Tatsächlich (Archiv)",
data: actual,
color: "#7c3aed",
lineWidth: 4,
marker: { radius: 5, symbol: "diamond" }
}
],
credits: { enabled: false }
};
if (!chart) {
chart = Highcharts.chart("chart", options);
} else {
chart.series[0].setData(forecast, false);
chart.series[1].setData(actual, false);
chart.redraw();
}
}
(async () => {
try {
const data = await loadData();
render(data);
} catch (e) {
metaEl.textContent = "Fehler beim Laden: " + (e?.message ?? e);
}
})();
</script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
{
"id": "{DF3B17A3-C2F7-0B57-F632-34668D9206E6}",
"name": "PV_Forecast_plotmemory ",
"type": 3,
"vendor": "Belevo AG",
"aliases": [],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "PVF",
"url": ""
}
-604
View File
@@ -1,604 +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->RegisterPropertyString("kunde", "");
$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);
// Important: Prevent tile from triggering URL fetch (strict daily behavior)
$this->RegisterPropertyBoolean("AllowTileFetchIfEmpty", false);
// 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");
// Persisted run markers (survive restarts)
$this->RegisterAttributeInteger("LastDailyRunForecast", 0);
$this->RegisterAttributeInteger("LastDailySnapshotRun", 0);
}
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);
}
}
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->ReadAttributeInteger("LastDailyRunForecast");
$today0 = strtotime("today");
if ($nowSec >= $targetSec && $lastRun < $today0) {
$this->SendDebug("Scheduler", "Taegliches Forecast-Update (" . $timeStr . ")", 0);
$this->UpdateForecast();
$this->WriteAttributeInteger("LastDailyRunForecast", $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);
// IMPORTANT: use Snapshot marker, not Forecast marker
$lastRun = (int)$this->ReadAttributeInteger("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->WriteAttributeInteger("LastDailySnapshotRun", $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
{
$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"] ?? []);
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;
}
$kunde = trim($this->ReadPropertyString("kunde"));
if ($kunde === "") {
$kunde = "unbekannt";
}
// Sonderzeichen entfernen (für Dateinamen!)
$kundeSafe = preg_replace('/[^a-zA-Z0-9_-]/', '_', $kunde);
header("Content-Type: application/zip");
header(
'Content-Disposition: attachment; filename="pv_plots_' .
$kundeSafe . '_' .
$this->InstanceID . '_' .
$days . 'days.zip"'
);
header("Content-Length: " . filesize($zipPath));
readfile($zipPath);
@unlink($zipPath);
}
// ----------------- Hook registration -----------------
private function RegisterHook(string $Hook)
{
$ids = IPS_GetInstanceListByModuleID(self::WEBHOOK_GUID);
if (count($ids) === 0) return;
$hookID = (int)$ids[0];
$hooks = json_decode(IPS_GetProperty($hookID, "Hooks"), true);
if (!is_array($hooks)) $hooks = [];
foreach ($hooks as $h) {
if (isset($h["Hook"]) && $h["Hook"] === ltrim($Hook, "/")) {
return;
}
if (isset($h["Hook"]) && $h["Hook"] === $Hook) {
return;
}
}
$hooks[] = ["Hook" => $Hook, "TargetID" => $this->InstanceID];
IPS_SetProperty($hookID, "Hooks", json_encode($hooks));
IPS_ApplyChanges($hookID);
}
}
-59
View File
@@ -1,59 +0,0 @@
# PV_Visu
Visualisierung des Eigenverbrauchs: Tages-Quoten für PV-Produktion vs. Einspeisung und Verbrauch vs. Netz-Bezug.
## Inhaltsverzeichnis
1. [Funktionsumfang](#funktionsumfang)
2. [Voraussetzungen](#voraussetzungen)
3. [Installation](#installation)
4. [Instanz einrichten](#instanz-einrichten)
5. [WebFront](#webfront)
6. [PHP-Befehlsreferenz](#php-befehlsreferenz)
## Funktionsumfang
- Anzeige von Tages-Quoten (%)
- Produktion: Eigenverbrauch vs. Einspeisung
- Verbrauch: PV-Anteil vs. Netz-Anteil
- Zwei Balkendiagramme
- Absolute Tages-Summen (kWh)
## Voraussetzungen
- IP-Symcon ≥ 7.1
- Archiv-Modul aktiviert
- Vier kWh-Zähler-Variablen
## Installation
1. **Module Store** → Suche nach „PV_Visu“ und installieren
2. **Alternativ**: Unter Module → Repositories folgende URL hinzufügen:
```
https://github.com/DeinRepo/PV_Visu.git
```
und Modul neu einlesen.
## Instanz einrichten
- **Instanz hinzufügen** → Filter: „PV_Visu“
- Variablen zuweisen:
| Property | Beschreibung |
| -------------- | -------------------------- |
| VarProduction | PV-Produktionszähler (kWh) |
| VarConsumption | Gesamtverbrauch (kWh) |
| VarFeedIn | Einspeisung (kWh) |
| VarGrid | Netz-Bezug (kWh) |
## WebFront
- **Tile-Typ:** PV_Visu
- Balken 1 (Grün): Produktion
- Balken 2 (Orange/Rot): Verbrauch
## PHP-Befehlsreferenz
```php
IPS_RequestAction($InstanceID, 'update', true);
```
-9
View File
@@ -1,9 +0,0 @@
{
"elements": [
{ "type": "SelectVariable", "name": "VarProduction", "caption": "Produktion (kWh)" },
{ "type": "SelectVariable", "name": "VarConsumption", "caption": "Verbrauch (kWh)" },
{ "type": "SelectVariable", "name": "VarFeedIn", "caption": "Einspeisung (kWh)" },
{ "type": "SelectVariable", "name": "VarGrid", "caption": "Bezug Netz (kWh)" }
],
"actions": []
}
-78
View File
@@ -1,78 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body { margin: 0; padding: 8px; background: transparent; font-family: sans-serif; color: #ffffff; }
.bar-block { margin-bottom: 20px; }
.bar-title { font-size: 1.2em; font-weight: bold; margin-bottom: 6px; }
.bar-container { width: 100%; background: #ddd; border-radius: 4px; overflow: hidden; height: 24px; position: relative; }
.bar { height: 100%; float: left; position: relative; }
.bar span { position: absolute; width: 100%; text-align: center; line-height: 24px; font-size: 0.8em; color: #fff; }
.bar-cons { background: #4CAF50; }
.bar-feed { background: #8BC34A; }
.bar-pv { background: #FF9800; }
.bar-grid { background: #FF5722; }
.value-text { font-size: 0.95em; margin-top: 4px; }
</style>
</head>
<body>
<div id="pv_visu">
<div class="bar-block">
<div class="bar-title">Produktion (Eigenverbrauch / Einspeisung)</div>
<div class="bar-container">
<div class="bar bar-cons" id="barCons"><span id="barConsText"></span></div>
<div class="bar bar-feed" id="barFeed"><span id="barFeedText"></span></div>
</div>
<div class="value-text" id="prodValues"></div>
</div>
<div class="bar-block">
<div class="bar-title">Verbrauch (PV / Netz)</div>
<div class="bar-container">
<div class="bar bar-pv" id="barPV"><span id="barPVText"></span></div>
<div class="bar bar-grid" id="barGrid"><span id="barGridText"></span></div>
</div>
<div class="value-text" id="consValues"></div>
</div>
</div>
<script>
function Apply(data) {
document.getElementById('barCons').style.width = data.prodCons + '%';
document.getElementById('barFeed').style.width = data.prodFeed + '%';
document.getElementById('barPV').style.width = data.consPV + '%';
document.getElementById('barGrid').style.width = data.consGrid + '%';
document.getElementById('barConsText').innerText = data.prodCons + '%';
document.getElementById('barFeedText').innerText = data.prodFeed + '%';
document.getElementById('barPVText').innerText = data.consPV + '%';
document.getElementById('barGridText').innerText = data.consGrid + '%';
document.getElementById('prodValues').innerText =
'Gesamt: ' + data.value.prod + ' kWh, Eigenverbrauch: ' + (data.consPV/100*data.value.cons).toFixed(2) + ' kWh, Einspeisung: ' + data.value.feed + ' kWh';
document.getElementById('consValues').innerText =
'Gesamt: ' + data.value.cons + ' kWh, PV-Anteil: ' + (data.consPV/100*data.value.cons).toFixed(2) + ' kWh, Netz: ' + data.value.grid + ' kWh';
}
function handleMessage(msg) {
try {
const data = typeof msg === 'string' ? JSON.parse(msg) : msg;
Apply(data);
} catch (e) {
console.error('Fehler beim Verarbeiten der Daten:', e, msg);
}
}
if (typeof registerMessageHandler === 'function') {
registerMessageHandler(handleMessage);
}
// Live-Aktualisierung alle 30 Sekunden
function pollData() {
if (typeof IPS !== 'undefined') {
IPS.RequestAction('update', '');
}
}
setInterval(pollData, 30000);
</script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
{
"id": "{DDE89CBE-4411-5FF4-4931-14204E05CAD0}",
"name": "PV_Visu",
"type": 3,
"vendor": "Belevo AG",
"aliases": [],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "",
"url": ""
}
-99
View File
@@ -1,99 +0,0 @@
<?php
class PV_Visu extends IPSModule
{
public function Create()
{
parent::Create();
$this->RegisterPropertyInteger('VarProduction', 0);
$this->RegisterPropertyInteger('VarConsumption', 0);
$this->RegisterPropertyInteger('VarFeedIn', 0);
$this->RegisterPropertyInteger('VarGrid', 0);
$this->RegisterVariableString('JSONData', 'Visualisierungsdaten', '', 0);
IPS_SetHidden($this->GetIDForIdent('JSONData'), true);
$this->SetVisualizationType(1); // HTML SDK Tile
}
public function ApplyChanges()
{
parent::ApplyChanges();
foreach (['VarProduction', 'VarConsumption', 'VarFeedIn', 'VarGrid'] as $prop) {
$vid = $this->ReadPropertyInteger($prop);
if ($vid > 0) {
$this->RegisterMessage($vid, VM_UPDATE);
}
}
$this->UpdateData(); // Initial
}
public function MessageSink($TimeStamp, $SenderID, $Message, $Data)
{
if ($Message === VM_UPDATE) {
$this->UpdateData();
}
}
public function GetVisualizationTile()
{
$initialData = '<script>handleMessage(' . json_encode($this->UpdateData()) . ');</script>';
$html = file_get_contents(__DIR__ . '/module.html');
return $html . $initialData;
}
public function RequestAction($Ident, $Value)
{
if ($Ident === 'update') {
return $this->UpdateData(); // Rückgabe für Visualisierung
}
throw new \Exception("Unknown Ident: $Ident");
}
public function UpdateData()
{
$start = strtotime('today 00:00');
$end = time();
$prod = $this->GetDailyTotal($this->ReadPropertyInteger('VarProduction'), $start, $end);
$cons = $this->GetDailyTotal($this->ReadPropertyInteger('VarConsumption'), $start, $end);
$feed = $this->GetDailyTotal($this->ReadPropertyInteger('VarFeedIn'), $start, $end);
$grid = $this->GetDailyTotal($this->ReadPropertyInteger('VarGrid'), $start, $end);
$prodCons = $prod > 0 ? (($cons - $grid) / $prod) * 100 : 0;
$prodFeed = $prod > 0 ? 100 - $prodCons : 0;
$consPV = $cons > 0 ? min($prod, ($cons - $grid)) / $cons * 100 : 0;
$consGrid = $cons > 0 ? 100 - $consPV : 0;
$data = [
'prodCons' => round($prodCons, 1),
'prodFeed' => round($prodFeed, 1),
'consPV' => round($consPV, 1),
'consGrid' => round($consGrid, 1),
'value' => [
'prod' => round($prod, 2),
'cons' => round($cons, 2),
'feed' => round($feed, 2),
'grid' => round($grid, 2),
],
];
$json = json_encode($data);
SetValueString($this->GetIDForIdent('JSONData'), $json);
return $data;
}
private function GetDailyTotal(int $varID, int $start, int $end)
{
if ($varID <= 0) return 0.0;
$archiveID = @IPS_GetInstanceListByModuleID('{43192F0B-135B-4CE7-A0A7-1475603F3060}')[0];
if (!$archiveID) return 0.0;
$values = @AC_GetAggregatedValues($archiveID, $varID, 1, $start, $end, 1);
return isset($values[0]['Avg']) ? (float)$values[0]['Avg'] : 0.0;
}
}
-59
View File
@@ -1,59 +0,0 @@
# PV_Visu
Visualisierung des Eigenverbrauchs: Tages-Quoten für PV-Produktion vs. Einspeisung und Verbrauch vs. Netz-Bezug.
## Inhaltsverzeichnis
1. [Funktionsumfang](#funktionsumfang)
2. [Voraussetzungen](#voraussetzungen)
3. [Installation](#installation)
4. [Instanz einrichten](#instanz-einrichten)
5. [WebFront](#webfront)
6. [PHP-Befehlsreferenz](#php-befehlsreferenz)
## Funktionsumfang
- Anzeige von Tages-Quoten (%)
- Produktion: Eigenverbrauch vs. Einspeisung
- Verbrauch: PV-Anteil vs. Netz-Anteil
- Zwei Balkendiagramme
- Absolute Tages-Summen (kWh)
## Voraussetzungen
- IP-Symcon ≥ 7.1
- Archiv-Modul aktiviert
- Vier kWh-Zähler-Variablen
## Installation
1. **Module Store** → Suche nach „PV_Visu“ und installieren
2. **Alternativ**: Unter Module → Repositories folgende URL hinzufügen:
```
https://github.com/DeinRepo/PV_Visu.git
```
und Modul neu einlesen.
## Instanz einrichten
- **Instanz hinzufügen** → Filter: „PV_Visu“
- Variablen zuweisen:
| Property | Beschreibung |
| -------------- | -------------------------- |
| VarProduction | PV-Produktionszähler (kWh) |
| VarConsumption | Gesamtverbrauch (kWh) |
| VarFeedIn | Einspeisung (kWh) |
| VarGrid | Netz-Bezug (kWh) |
## WebFront
- **Tile-Typ:** PV_Visu
- Balken 1 (Grün): Produktion
- Balken 2 (Orange/Rot): Verbrauch
## PHP-Befehlsreferenz
```php
IPS_RequestAction($InstanceID, 'update', true);
```
-105
View File
@@ -1,105 +0,0 @@
2. Starte IP-Symcon neu bzw. klicke in der Konsole auf „Module aktualisieren“.
Anschließend erscheint in der Instanzliste der neue Gerätetyp **Sofar Wechselrichter**.
---
## Konfiguration
1. **Instanz anlegen**
- Gehe in der IPS-Konsole auf „Instanzen hinzufügen“ → Hersteller: (falls sichtbar) → Modul: **Sofar Wechselrichter**.
- Vergib einen sinnvollen Namen und eine Beschreibung.
2. **Einstellungen in den Eigenschaften**
- **Logger-Seriennummer**:
Gib hier die „Logger-Nummer“ deines Sofar-Wechselrichters ein (Dezimal).
- **Abfragezyklus (Sekunden)**:
Intervall in Sekunden, in dem zyklisch alle eingetragenen Register abgefragt werden.
Ein Wert von `0` deaktiviert den Timer (keine zyklischen Abfragen).
- **Register-Tabelle**:
Hier definierst du beliebig viele Zeilen, jeweils mit:
1. **Register-Nummer** (dezimal)
2. **Bezeichnung** (z. B. „Gesamtproduktion“ oder „Spannung Phase L1“)
3. **Skalierungs-faktor** (z. B. `0.1`, `1`, `10` usw.)
Wird beispielsweise für Register `1476` als Bezeichnung „Gesamtproduktion“ mit Skalierungsfaktor `1` eingetragen,
so liest das Modul alle 60 Sekunden (oder den von dir gewählten Zyklus) Register 1476,
multipliziert das rohe UINT16-Ergebnis mit `1` und legt den Wert in einer IPS-Variable an.
3. **Speichern/Übernehmen**
Klicke auf „Übernehmen“, um die Änderungen zu übernehmen.
Das Modul legt automatisch untergeordnete IPS-Variablen an:
- **Vorheriger Wert (Register 1160)**
→ INT16BE, unverändert (analog zur alten Node-RED-Logik).
Diese Variable heißt intern `Vorheriger Wert` und wird automatisch gepflegt.
- **Alle weiteren Einträge aus der Register-Tabelle**
→ Für jede Zeile wird eine Float-Variable mit der von dir angegebenen „Bezeichnung“ angelegt.
Der Variablen-Identifier lautet automatisch `Reg<Registernummer>` (z. B. `Reg1476`).
---
## Funktionsweise
1. **Initialisierung (ApplyChanges)**
- Liest den Abfragezyklus aus den Moduleigenschaften und initialisiert den Timer.
- Legt eine Integer-Variable `Vorheriger Wert` (Reg 1160) an.
- Legt für jede Zeile in der Register-Tabelle eine Float-Variable an (Ident `Reg<Nummer>`).
2. **Zyklische Abfrage (Timer-Callback)**
- **Register 1160 (INT16BE)**
→ Wird als „Vorheriger Wert“ in die Variable geschrieben (signed interpretiert).
- **Alle weiteren Register aus der Tabelle**
→ Jedes Register wird per Modbus-ähnlichem TCP-Paketaustausch abgefragt, als UINT16 ausgelesen,
mit dem angegebenen Skalierungsfaktor multipliziert und in der zugehörigen Float-Variable gespeichert.
3. **Kommunikation**
- TCP-Verbindung zu `192.168.0.0:8899` (feste IP im Code).
- Der Aufruf von `readRegister()` baut ein „Out_Frame“ wie in Node-RED,
rechnet CRC16-Modbus über die letzten 6 Bytes, hängt eine Summen-Checksum + 0x15 an,
sendet das Paket, liest die Antwort, schneidet exakt 2 Daten-Bytes heraus und liefert sie zurück.
---
## Beispiel: Register 1476 („Gesamtproduktion“)
- **Register-Tabelle**
| Register‐Nummer | Bezeichnung | Skalierungsfaktor |
| --------------: | :------------------ | ----------------: |
| 1476 | Gesamtproduktion | 1 |
- **Ergebnis**
- Eine Float-Variable mit der Bezeichnung „Gesamtproduktion“ wird angelegt.
- Wenn der PollInterval auf `60` Sekunden steht, liest das Modul alle 60 Sekunden das Register 1476,
skaliert mit 1 und schreibt den numerischen Wert in `Reg1476`.
---
## Fehlersuche
- Falls die Variable „Vorheriger Wert“ immer denselben Wert liefert oder ein Lesefehler auftritt, prüfe bitte:
1. **Logger-Nummer**: Ist sie korrekt (Dezimal)?
2. **Netzwerk/Firewall**: Kann Symcon die Adresse `192.168.0.100:8899` erreichen?
3. **Debug-Ausgaben**:
– Öffne in der Konsole „Kernel-Log“ → Filter „SofarWechselrichter“.
– Dort werden WARNs und ERRs protokolliert, falls z. B. keine Antwort kommt oder das Datenpaket inkorrekt ist.
- Falls du andere Datentypen brauchst (z. B. INT16 für Register außerhalb 1160), definiere sie analog als separate Zeile:
– Trage die `Register‐Nummer` ein, gib als Skalierungsfaktor `1` (oder `0.1` etc.) an.
– Der absolute Rohwert wird stets als UINT16 interpretiert (0–65535).
– Solltest du negative INT16 benötigen, kannst du nachträglich einfach die Variable „Vorheriger Wert“ (Reg 1160)
als Beispiel nehmen und in einem Script umrechnen (Werte über 32767 → –32768 + Rest).
---
## Versionshistorie
- **1.0**
- Erstveröffentlichung:
• Zyklische Abfrage beliebiger Register in einer Matrix konfigurieren
• Automatische Anlage von Variablen für jeden Eintrag
• Spezieller „Vorheriger Wert“ (Register 1160 als INT16)
---
*Ende der Dokumentation.*
-106
View File
@@ -1,106 +0,0 @@
{
"elements": [
{
"type": "Label",
"caption": "Sofar Wechselrichter Konfiguration"
},
{
"type": "ValidationTextBox",
"name": "IPAddress",
"caption": "Inverter IP-Adresse"
},
{
"type": "ValidationTextBox",
"name": "LoggerNumber",
"caption": "Logger-Seriennummer"
},
{
"type": "NumberSpinner",
"name": "PollInterval",
"caption": "Abfragezyklus (Sekunden)",
"minimum": 1,
"suffix": "s"
},
{
"type": "List",
"name": "Registers",
"caption": "Register-Tabelle",
"add": "Neues Register hinzufügen",
"delete": "Lösche Eintrag",
"columns": [
{
"caption": "Register-Nummer",
"name": "RegisterNumber",
"width": "200px",
"add": 0,
"edit": {
"type": "NumberSpinner",
"minimum": 0
}
},
{
"caption": "Bezeichnung",
"name": "Label",
"width": "300px",
"add": "",
"edit": {
"type": "ValidationTextBox"
}
},
{
"caption": "Skalierungs-faktor",
"name": "ScalingFactor",
"width": "200px",
"add": 1,
"edit": {
"type": "NumberSpinner",
"digits": 4,
"minimum": -999999,
"maximum": 999999
}
},
{
"caption": "Endian",
"name": "Endian",
"width": "80px",
"add": "BE",
"edit": {
"type": "Select",
"options": [
{ "caption": "BE", "value": "BE" },
{ "caption": "LE", "value": "LE" }
]
}
},
{
"caption": "Bit-Länge",
"name": "BitLength",
"width": "80px",
"add": "16",
"edit": {
"type": "Select",
"options": [
{ "caption": "16 Bit", "value": "16" },
{ "caption": "32 Bit", "value": "32" },
{ "caption": "64 Bit", "value": "64" }
]
}
},
{
"caption": "Signed/Unsigned",
"name": "Signedness",
"width": "100px",
"add": "Unsigned",
"edit": {
"type": "Select",
"options": [
{ "caption": "Unsigned", "value": "Unsigned" },
{ "caption": "Signed", "value": "Signed" }
]
}
}
]
}
],
"actions": []
}
-12
View File
@@ -1,12 +0,0 @@
{
"id": "{C26E97C8-BA00-0563-6B14-B807C5ACE17F}",
"name": "SofarWechselrichter",
"type": 3,
"vendor": "Belevo AG",
"aliases": [],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "GEF",
"url": ""
}
-379
View File
@@ -1,379 +0,0 @@
<?php
declare(strict_types=1);
/**
* Sofar Wechselrichter Modul (IP-Symcon)
*
* - LoggerNumber als String (kein Integer-Overflow).
* - Negative Skalierungen erlaubt.
* - Bit-Länge pro Register (16, 32, 64) auswählbar.
* - Signed/Unsigned pro Register wählbar.
* - Liest 32/64-Bit-Werte registerweise einzeln und setzt anschließend zusammen.
* - Gelöschte Register-Variablen werden entfernt.
* - Debug-Logs zeigen Raw-Response, um Slice-Position zu prüfen.
*/
class SofarWechselrichter extends IPSModule
{
public function Create()
{
parent::Create();
// Moduleigenschaften
$this->RegisterPropertyString('IPAddress', '');
$this->RegisterPropertyString('LoggerNumber', '0'); // als String
$this->RegisterPropertyInteger('PollInterval', 60);
$this->RegisterPropertyString('Registers', '[]'); // JSON-String
// Timer für zyklische Abfragen (per RequestAction("Query"))
$script = 'IPS_RequestAction(' . $this->InstanceID . ', "Query", "");';
$this->RegisterTimer('QueryTimer', 0, $script);
}
public function ApplyChanges()
{
parent::ApplyChanges();
// Timer-Intervall (ms)
$intervalSec = $this->ReadPropertyInteger('PollInterval');
$intervalMs = ($intervalSec > 0) ? $intervalSec * 1000 : 0;
$this->SetTimerInterval('QueryTimer', $intervalMs);
// Aktuelle Registerliste
$registers = json_decode($this->ReadPropertyString('Registers'), true);
if (!is_array($registers)) {
$registers = [];
}
// 1) Variablen anlegen (neu hinzugekommene Register)
$position = 10;
foreach ($registers as $entry) {
$regNo = (int) $entry['RegisterNumber'];
$label = trim($entry['Label']);
if ($regNo < 0 || $label === '') {
continue;
}
$ident = 'Reg' . $regNo;
if (!$this->VariableExists($ident)) {
$this->RegisterVariableFloat($ident, $label, '', $position);
}
$position += 10;
}
// 2) Variablen löschen (falls Register entfernt wurden)
$validIdents = [];
foreach ($registers as $entry) {
$validIdents[] = 'Reg' . ((int)$entry['RegisterNumber']);
}
$children = IPS_GetChildrenIDs($this->InstanceID);
foreach ($children as $childID) {
$obj = IPS_GetObject($childID);
if ($obj['ObjectType'] !== 2) {
continue;
}
$ident = $obj['ObjectIdent'];
if (substr($ident, 0, 3) === 'Reg') {
if (!in_array($ident, $validIdents)) {
IPS_DeleteVariable($childID);
}
}
}
}
public function Destroy()
{
parent::Destroy();
}
/**
* Wird aufgerufen durch IPS_RequestAction über den Timer
*/
public function RequestAction($Ident, $Value)
{
switch ($Ident) {
case 'Query':
$this->Query();
break;
default:
throw new Exception('Ungültiger Ident: ' . $Ident);
}
}
/**
* Zyklische Abfrage aller definierten Register
*/
private function Query(): void
{
$this->LogMessage('Query invoked', KL_MESSAGE);
$ip = trim($this->ReadPropertyString('IPAddress'));
$loggerNumberStr = trim($this->ReadPropertyString('LoggerNumber'));
// 1) Validierung IP
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
$this->LogMessage('Abbruch: Ungültige IP = "' . $ip . '"', KL_WARNING);
return;
}
// 2) Validierung LoggerNumber (Dezimal-String, > 0)
if ($loggerNumberStr === '' || !ctype_digit($loggerNumberStr) || bccomp($loggerNumberStr, '1') < 0) {
$this->LogMessage('Abbruch: Ungültige LoggerNumber = "' . $loggerNumberStr . '"', KL_WARNING);
return;
}
// 3) Register-Liste einlesen
$registers = json_decode($this->ReadPropertyString('Registers'), true);
if (!is_array($registers) || count($registers) === 0) {
// Keine Register definiert
return;
}
// 4) Für jedes Register: einzeln auslesen, zusammensetzen, skalieren, speichern
foreach ($registers as $entry) {
$regNo = (int) $entry['RegisterNumber'];
$label = trim((string)$entry['Label']);
$scale = (string) $entry['ScalingFactor']; // kann negativ sein
$endian = strtoupper(trim((string)$entry['Endian']));
$bitLength = (int) $entry['BitLength']; // 16, 32 oder 64
$signedness = trim((string)$entry['Signedness']); // "Signed" oder "Unsigned"
$ident = 'Reg' . $regNo;
if ($regNo < 0 || $label === '' || !in_array($bitLength, [16, 32, 64])) {
continue;
}
try {
$numRegs = $bitLength / 16; // 1, 2 oder 4
// Bytes registerweise einzeln abfragen und zusammenfügen:
$dataBytes = '';
for ($i = 0; $i < $numRegs; $i++) {
$chunk = $this->readSingleRegister($ip, $loggerNumberStr, $regNo + $i);
$dataBytes .= $chunk;
}
// Debug: raw combined response hex
$combinedHex = strtoupper(bin2hex($dataBytes));
$this->LogMessage("Raw data for Reg {$regNo} ({$bitLength}bit): {$combinedHex}", KL_MESSAGE);
// Endian-Handling: falls LE, kehre gesamte Byte-Reihenfolge um
if ($endian === 'LE') {
$combinedHex = $this->reverseByteOrder($combinedHex);
$this->LogMessage("After LE reverse: {$combinedHex}", KL_MESSAGE);
}
// Konvertiere Hex in Dezimal-String
$rawDec = $this->hexToDecimal($combinedHex);
// Bei "Signed" → Zwei-Komplement-Umrechnung
if ($signedness === 'Signed') {
$half = bcpow('2', (string)($bitLength - 1), 0); // 2^(bitLength-1)
$fullRange = bcpow('2', (string)$bitLength, 0); // 2^bitLength
if (bccomp($rawDec, $half) >= 0) {
$rawDec = bcsub($rawDec, $fullRange, 0);
}
$this->LogMessage("Signed rawDec for Reg {$regNo}: {$rawDec}", KL_MESSAGE);
}
// Skaliere (bc*-Multiplikation, 4 Nachkommastellen)
$valueStr = bcmul($rawDec, $scale, 4);
SetValueFloat($this->GetIDForIdent($ident), (float)$valueStr);
$this->LogMessage("Final value for Reg {$regNo}: {$valueStr}", KL_MESSAGE);
} catch (Exception $e) {
$this->LogMessage(
"Fehler Lesen Reg {$regNo} ({$bitLength}bit, {$signedness}): " . $e->getMessage(),
KL_WARNING
);
}
}
}
/**
* Liest genau ein Register (16 Bit) per Modbus-ähnlichem TCP (2 Bytes zurück).
*
* @param string $ip Inverter-IP
* @param string $serial_nr_str Logger-Seriennummer als Dezimal-String
* @param int $reg Register-Adresse
* @return string 2-Byte-Binär-String
* @throws Exception Bei Kommunikationsfehlern
*/
private function readSingleRegister(string $ip, string $serial_nr_str, int $reg): string
{
// 1) Out_Frame ohne CRC aufbauen
$oFrame = 'a5170010450000';
// Dezimal-String → 8-stellige Hex
$hexSN8 = $this->decStringToHex8($serial_nr_str);
$hexSNbytes = [
substr($hexSN8, 6, 2),
substr($hexSN8, 4, 2),
substr($hexSN8, 2, 2),
substr($hexSN8, 0, 2),
];
$oFrame .= implode('', $hexSNbytes);
// Data-Field (16 Hex-Zeichen konstant)
$oFrame .= '020000000000000000000000000000';
// Business-Field: 01 03 + Start-Register + Anzahl Register (1)
$startHex = str_pad(dechex($reg), 4, '0', STR_PAD_LEFT);
$numHex = str_pad(dechex(1), 4, '0', STR_PAD_LEFT);
$oFrame .= '0103' . $startHex . $numHex;
// 2) CRC16-Modbus (letzte 6 Bytes)
$crcInputHex = substr($oFrame, -12);
$crcInputBin = hex2bin($crcInputHex);
if ($crcInputBin === false) {
throw new Exception("Ungültiges Hex in CRC-Input: {$crcInputHex}");
}
$crcValue = $this->calculateCRC16Modbus($crcInputBin);
$crcHex = strtoupper(str_pad(dechex($crcValue), 4, '0', STR_PAD_LEFT));
$crcSwapped = substr($crcHex, 2, 2) . substr($crcHex, 0, 2);
$oFrameWithCRC = $oFrame . strtolower($crcSwapped);
// 3) Summen-Checksum (alle Bytes ab Index 1) + 0x15
$l = strlen($oFrameWithCRC) / 2;
$bArr = [];
for ($i = 0; $i < $l; $i++) {
$byteHex = substr($oFrameWithCRC, 2 * $i, 2);
$bArr[$i] = hexdec($byteHex);
}
$crcSum = 0;
for ($i = 1; $i < $l; $i++) {
$crcSum += $bArr[$i];
$crcSum &= 0xFF;
}
$bArr[$l] = $crcSum;
$bArr[$l+1] = 0x15;
$frameBin = '';
foreach ($bArr as $b) {
$frameBin .= chr($b);
}
// 4) TCP-Verbindung öffnen & Paket senden (Port fest 8899)
$port = 8899;
$fp = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 5);
if (!$fp) {
throw new Exception("Verbindung zu {$ip}:{$port} fehlgeschlagen ({$errno}: {$errstr})");
}
fwrite($fp, $frameBin);
stream_set_timeout($fp, 2);
// 5) Antwort einlesen
$response = '';
while (!feof($fp)) {
$chunk = fread($fp, 1024);
if ($chunk === false || $chunk === '') {
break;
}
$response .= $chunk;
}
fclose($fp);
if ($response === '') {
throw new Exception("Keine Antwort vom Inverter erhalten.");
}
// Debug: log raw response hex
$respHex = strtoupper(bin2hex($response));
$this->LogMessage("Raw response for single reg {$reg}: {$respHex}", KL_MESSAGE);
// 6) Slice-Logik: l = 2*1 + 4 = 6, slice(-6, 2) → 2 Bytes
$lModbus = 2 * 1 + 4; // = 6
$numBytes = 2;
if (strlen($response) < $lModbus) {
throw new Exception("Unerwartet kurze Antwort (< {$lModbus} Bytes).");
}
$dataBytes = substr($response, -$lModbus, $numBytes);
if (strlen($dataBytes) < $numBytes) {
throw new Exception("Data-Segment enthält weniger als {$numBytes} Bytes.");
}
$dataHex = strtoupper(bin2hex($dataBytes));
$this->LogMessage("Sliced data for single reg {$reg}: {$dataHex}", KL_MESSAGE);
return $dataBytes;
}
/**
* Wandelt einen Dezimal-String in einen 8-stelligen Hex-String um.
*
* @param string $decString
* @return string 8-stellige Hex (uppercase)
*/
private function decStringToHex8(string $decString): string
{
$num = ltrim($decString, '0');
if ($num === '') {
return '00000000';
}
$hex = '';
while (bccomp($num, '0') > 0) {
$mod = bcmod($num, '16');
$digit = dechex((int)$mod);
$hex = strtoupper($digit) . $hex;
$num = bcdiv($num, '16', 0);
}
return str_pad($hex, 8, '0', STR_PAD_LEFT);
}
/**
* Kehrt die Byte-Reihenfolge eines Hex-Strings um (2 Hex-Zeichen = 1 Byte).
*
* @param string $hex Hex-Repr. (z.B. "A1B2C3D4")
* @return string Umgekehrte Byte-Reihenfolge (z.B. "D4C3B2A1")
*/
private function reverseByteOrder(string $hex): string
{
$bytes = str_split($hex, 2);
$bytes = array_reverse($bytes);
return implode('', $bytes);
}
/**
* Konvertiert einen Hex-String in einen Dezimal-String (BCMath).
*
* @param string $hex Uppercase-Hex ohne Präfix (z.B. "00FF10A3")
* @return string Dezimal-String (z.B. "16737763")
*/
private function hexToDecimal(string $hex): string
{
$hex = ltrim($hex, '0');
if ($hex === '') {
return '0';
}
$len = strlen($hex);
$dec = '0';
$power16 = '1';
for ($i = $len - 1; $i >= 0; $i--) {
$digit = hexdec($hex[$i]);
$term = bcmul((string)$digit, $power16, 0);
$dec = bcadd($dec, $term, 0);
$power16 = bcmul($power16, '16', 0);
}
return $dec;
}
/**
* Berechnet CRC16-Modbus (Init=0xFFFF, Poly=0xA001) über Binärdaten.
*/
private function calculateCRC16Modbus(string $binaryData): int
{
$crc = 0xFFFF;
$len = strlen($binaryData);
for ($pos = 0; $pos < $len; $pos++) {
$crc ^= ord($binaryData[$pos]);
for ($i = 0; $i < 8; $i++) {
if (($crc & 0x0001) !== 0) {
$crc >>= 1;
$crc ^= 0xA001;
} else {
$crc >>= 1;
}
}
}
return $crc;
}
/**
* Prüft, ob eine Variable mit Ident existiert.
*/
private function VariableExists(string $ident): bool
{
$vid = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
return ($vid !== false && IPS_VariableExists($vid));
}
}