no message

This commit is contained in:
belevo\mh
2026-03-03 11:29:51 +01:00
parent f11255b071
commit 6757682399
20 changed files with 0 additions and 2436 deletions

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

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."
}
]
}

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>

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": ""
}

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

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

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);"
}
]
}

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>

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": ""
}

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

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

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

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>

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": ""
}

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

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

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**
| RegisterNummer | Bezeichnung | Skalierungsfaktor |
| --------------: | :------------------ | ----------------: |
| 1476 | Gesamtproduktion | 1 |
- **Ergebnis**
- Eine Float-Variable mit der Bezeichnung „Gesamtproduktion“ wird angelegt.
- Wenn der PollInterval auf `60` Sekunden steht, liest das Modul alle 60 Sekunden das Register 1476,
skaliert mit 1 und schreibt den numerischen Wert in `Reg1476`.
---
## Fehlersuche
- Falls die Variable „Vorheriger Wert“ immer denselben Wert liefert oder ein Lesefehler auftritt, prüfe bitte:
1. **Logger-Nummer**: Ist sie korrekt (Dezimal)?
2. **Netzwerk/Firewall**: Kann Symcon die Adresse `192.168.0.100:8899` erreichen?
3. **Debug-Ausgaben**:
Öffne in der Konsole „Kernel-Log“ → Filter „SofarWechselrichter“.
Dort werden WARNs und ERRs protokolliert, falls z. B. keine Antwort kommt oder das Datenpaket inkorrekt ist.
- Falls du andere Datentypen brauchst (z. B. INT16 für Register außerhalb 1160), definiere sie analog als separate Zeile:
Trage die `RegisterNummer` ein, gib als Skalierungsfaktor `1` (oder `0.1` etc.) an.
Der absolute Rohwert wird stets als UINT16 interpretiert (065535).
Solltest du negative INT16 benötigen, kannst du nachträglich einfach die Variable „Vorheriger Wert“ (Reg 1160)
als Beispiel nehmen und in einem Script umrechnen (Werte über 32767 → 32768 + Rest).
---
## Versionshistorie
- **1.0**
- Erstveröffentlichung:
• Zyklische Abfrage beliebiger Register in einer Matrix konfigurieren
• Automatische Anlage von Variablen für jeden Eintrag
• Spezieller „Vorheriger Wert“ (Register 1160 als INT16)
---
*Ende der Dokumentation.*

View File

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

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": ""
}

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