no message

This commit is contained in:
belevo\mh
2026-01-20 10:05:08 +01:00
parent 2a605a6138
commit 5db059fc51
2 changed files with 142 additions and 205 deletions

View File

@@ -3,11 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>PV_Forecast</title> <title>PV_Forecast</title>
<!-- Hinweis: Wenn Symcon/CDN blockiert, kommt "Highcharts nicht geladen".
Dann sag Bescheid, dann liefern wir Highcharts lokal aus. -->
<script src="https://code.highcharts.com/highcharts.js"></script> <script src="https://code.highcharts.com/highcharts.js"></script>
<style> <style>
body { font-family: sans-serif; margin: 0; padding: 12px; } body { font-family: sans-serif; margin: 0; padding: 12px; }
#chart { height: 420px; width: 100%; } #chart { height: 420px; width: 100%; }
@@ -26,17 +22,13 @@
<script> <script>
const metaEl = document.getElementById("meta"); const metaEl = document.getElementById("meta");
window.onerror = (msg, url, line, col) => {
window.onerror = function (msg, url, line, col) {
metaEl.textContent = `JS-Fehler: ${msg} (${line}:${col})`; metaEl.textContent = `JS-Fehler: ${msg} (${line}:${col})`;
}; };
// VSCode-freundlich (Platzhalter als String) const instanceId = Number("{{INSTANCE_ID}}");
window.instanceId = Number("{{INSTANCE_ID}}");
const instanceId = window.instanceId;
if (!Number.isFinite(instanceId) || instanceId <= 0) { if (!Number.isFinite(instanceId) || instanceId <= 0) {
metaEl.textContent = "Fehler: INSTANCE_ID wurde nicht korrekt eingesetzt."; metaEl.textContent = "Fehler: INSTANCE_ID nicht korrekt.";
throw new Error("Invalid INSTANCE_ID"); throw new Error("Invalid INSTANCE_ID");
} }
@@ -57,26 +49,24 @@
} }
function render(data) { function render(data) {
if (typeof Highcharts === "undefined") {
metaEl.textContent = "Fehler: Highcharts nicht geladen (CSP/CDN).";
return;
}
const forecast = data?.series?.forecast ?? [];
const actual = data?.series?.actual ?? [];
const cachedAt = data?.meta?.forecast_cached_at const cachedAt = data?.meta?.forecast_cached_at
? new Date(data.meta.forecast_cached_at * 1000).toLocaleString() ? new Date(data.meta.forecast_cached_at * 1000).toLocaleString()
: "unbekannt"; : "unbekannt";
const forecast = data?.series?.forecast ?? []; metaEl.textContent = `OK | Cache: ${cachedAt} | Forecast: ${forecast.length} | Ist: ${actual.length}`;
const actual = data?.series?.actual ?? [];
const isWatt = !!data?.meta?.actual_is_watt;
if (typeof Highcharts === "undefined") {
metaEl.textContent = "Fehler: Highcharts nicht geladen (CSP/CDN/Mixed Content).";
return;
}
const yLabel = isWatt ? "Leistung (kW) [Ist umgerechnet]" : "Leistung (kW)";
if (!chart) { if (!chart) {
chart = Highcharts.chart("chart", { chart = Highcharts.chart("chart", {
title: { text: "PV: Erwartung vs. Tatsächlich" }, title: { text: "PV: Erwartung vs. Tatsächlich (heute)" },
xAxis: { type: "datetime" }, xAxis: { type: "datetime" },
yAxis: { title: { text: yLabel } }, yAxis: { title: { text: "Leistung (kW)" } },
tooltip: { shared: true, xDateFormat: "%d.%m.%Y %H:%M" }, tooltip: { shared: true, xDateFormat: "%d.%m.%Y %H:%M" },
legend: { enabled: true }, legend: { enabled: true },
series: [ series: [
@@ -85,13 +75,10 @@
] ]
}); });
} else { } else {
chart.yAxis[0].setTitle({ text: yLabel }, false);
chart.series[0].setData(forecast, false); chart.series[0].setData(forecast, false);
chart.series[1].setData(actual, false); chart.series[1].setData(actual, false);
chart.redraw(); chart.redraw();
} }
metaEl.textContent = `OK | Cache: ${cachedAt} | Forecast: ${forecast.length} | Ist: ${actual.length}`;
} }
async function refresh() { async function refresh() {

View File

@@ -2,7 +2,6 @@
class PV_Forecast extends IPSModule class PV_Forecast extends IPSModule
{ {
// GUID vom Archive Control (Standard in IP-Symcon)
private const ARCHIVE_GUID = "{43192F0B-135B-4CE7-A0A7-1475603F3060}"; private const ARCHIVE_GUID = "{43192F0B-135B-4CE7-A0A7-1475603F3060}";
public function Create() public function Create()
@@ -14,11 +13,8 @@ class PV_Forecast extends IPSModule
$this->RegisterPropertyInteger("RefreshMinutes", 5); $this->RegisterPropertyInteger("RefreshMinutes", 5);
$this->RegisterPropertyBoolean("ActualIsWatt", true); $this->RegisterPropertyBoolean("ActualIsWatt", true);
// Timer
$this->RegisterTimer("UpdateForecastTimer", 0, 'IPS_RequestAction($_IPS["TARGET"], "UpdateForecast", 0);'); $this->RegisterTimer("UpdateForecastTimer", 0, 'IPS_RequestAction($_IPS["TARGET"], "UpdateForecast", 0);');
// Hook für Visualisierung (JSON Endpoint)
// (Falls das automatische Registrieren bei dir nicht greift: du hast es manuell im WebHook Control bereits erledigt.)
$this->RegisterHook("/hook/solcastcompare"); $this->RegisterHook("/hook/solcastcompare");
} }
@@ -26,13 +22,11 @@ class PV_Forecast extends IPSModule
{ {
parent::ApplyChanges(); parent::ApplyChanges();
// Tile Visualization aktivieren
$this->SetVisualizationType(1); $this->SetVisualizationType(1);
$mins = max(1, (int)$this->ReadPropertyInteger("RefreshMinutes")); $mins = max(1, (int)$this->ReadPropertyInteger("RefreshMinutes"));
$this->SetTimerInterval("UpdateForecastTimer", $mins * 60 * 1000); $this->SetTimerInterval("UpdateForecastTimer", $mins * 60 * 1000);
// Hook hier nochmal versuchen zu registrieren (ist oft zuverlässiger)
$this->RegisterHook("/hook/solcastcompare"); $this->RegisterHook("/hook/solcastcompare");
} }
@@ -42,7 +36,6 @@ class PV_Forecast extends IPSModule
case "UpdateForecast": case "UpdateForecast":
$this->UpdateForecast(); $this->UpdateForecast();
break; break;
default: default:
throw new Exception("Unknown Ident: " . $Ident); throw new Exception("Unknown Ident: " . $Ident);
} }
@@ -56,194 +49,70 @@ class PV_Forecast extends IPSModule
return; return;
} }
try { $json = @Sys_GetURLContent($url);
$json = Sys_GetURLContent($url); if ($json === false || $json === "") {
if ($json === false || $json === "") { $this->SendDebug("UpdateForecast", "Leere Antwort von URL", 0);
$this->SendDebug("UpdateForecast", "Leere Antwort von URL", 0); return;
return;
}
$data = json_decode($json, true);
if (!is_array($data)) {
$this->SendDebug("UpdateForecast", "JSON decode fehlgeschlagen", 0);
return;
}
// Cache in Buffer
$this->SetBuffer("ForecastRaw", $json);
$this->SetBuffer("ForecastTS", (string)time());
$this->SendDebug("UpdateForecast", "Forecast aktualisiert", 0);
} catch (Throwable $e) {
$this->SendDebug("UpdateForecast", "Fehler: " . $e->getMessage(), 0);
} }
$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);
} }
// -------- Datenaufbereitung fürs Diagramm --------
private function GetForecastSeries(): array
{
$raw = $this->GetBuffer("ForecastRaw");
if ($raw === "") {
return [];
}
$data = json_decode($raw, true);
if (!is_array($data) || !isset($data["estimated_actuals"]) || !is_array($data["estimated_actuals"])) {
return [];
}
// Solcast liefert period_end in UTC (Z)
$series = [];
foreach ($data["estimated_actuals"] as $row) {
if (!isset($row["period_end"], $row["pv_power_rooftop"])) {
continue;
}
$ts = strtotime($row["period_end"]); // UTC korrekt wegen "Z"
if ($ts === false) {
continue;
}
// pv_power_rooftop i.d.R. kW
$val = (float)$row["pv_power_rooftop"];
$series[] = [$ts * 1000, $val];
}
// oft absteigend -> aufsteigend fürs Chart
usort($series, function ($a, $b) {
return $a[0] <=> $b[0];
});
return $series;
}
private function GetActualSeriesFromArchive(int $startTs, int $endTs, int $bucketSeconds = 1800): 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 [];
}
// Rohwerte holen
$logged = AC_GetLoggedValues($archiveId, $varId, $startTs, $endTs, 0);
if (!is_array($logged) || count($logged) === 0) {
return [];
}
// Bucket: 30 Minuten
$buckets = []; // key=bucketEndTs => [sum, count]
foreach ($logged as $row) {
if (!isset($row["TimeStamp"], $row["Value"])) {
continue;
}
$ts = (int)$row["TimeStamp"];
// 30min Raster
$bucketStart = intdiv($ts, $bucketSeconds) * $bucketSeconds;
$bucketEnd = $bucketStart + $bucketSeconds; // wie Solcast period_end
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;
}
ksort($buckets);
$series = [];
$isWatt = (bool)$this->ReadPropertyBoolean("ActualIsWatt");
foreach ($buckets as $bucketEnd => $sc) {
$sum = $sc[0];
$count = $sc[1];
if ($count <= 0) {
continue;
}
$avg = $sum / $count;
// W -> kW, damit es zu Solcast passt
if ($isWatt) {
$avg = $avg / 1000.0;
}
$series[] = [$bucketEnd * 1000, $avg];
}
return $series;
}
private function GetArchiveInstanceID(): int
{
$list = IPS_GetInstanceListByModuleID(self::ARCHIVE_GUID);
if (is_array($list) && count($list) > 0) {
return (int)$list[0];
}
return 0;
}
// -------- Tile Visualisierung --------
public function GetVisualizationTile(): string public function GetVisualizationTile(): string
{ {
// Forecast ggf. initial laden
if ($this->GetBuffer("ForecastRaw") === "") { if ($this->GetBuffer("ForecastRaw") === "") {
$this->UpdateForecast(); $this->UpdateForecast();
} }
$html = file_get_contents(__DIR__ . "/module.html"); $html = file_get_contents(__DIR__ . "/module.html");
$html = str_replace("{{INSTANCE_ID}}", (string)$this->InstanceID, $html); $html = str_replace("{{INSTANCE_ID}}", (string)$this->InstanceID, $html);
return $html; return $html;
} }
// -------- WebHook Handler --------
protected function ProcessHookData() protected function ProcessHookData()
{ {
// Erwartet: /hook/solcastcompare?instance=12345&action=data
$instance = isset($_GET["instance"]) ? (int)$_GET["instance"] : 0; $instance = isset($_GET["instance"]) ? (int)$_GET["instance"] : 0;
$action = isset($_GET["action"]) ? (string)$_GET["action"] : ""; $action = isset($_GET["action"]) ? (string)$_GET["action"] : "";
if ($instance !== $this->InstanceID) { if ($instance !== $this->InstanceID) {
http_response_code(404); http_response_code(404);
echo "Wrong instance"; echo "Wrong instance";
return; return;
} }
if ($action !== "data") { if ($action !== "data") {
http_response_code(400); http_response_code(400);
echo "Unknown action"; echo "Unknown action";
return; return;
} }
$end = time(); // Zeitraum: HEUTE (lokal)
$start = $end - 24 * 3600; $tz = new DateTimeZone(date_default_timezone_get());
$startDT = new DateTime('today', $tz);
$endDT = new DateTime('tomorrow', $tz);
$forecast = $this->GetForecastSeries(); $start = $startDT->getTimestamp();
$actual = $this->GetActualSeriesFromArchive($start, $end, 1800); $end = $endDT->getTimestamp();
$now = time();
$forecast = $this->GetForecastSeriesFiltered($start, $end);
$actual = $this->GetActualSeriesFromArchive($start, $end, 1800, $now);
$out = [ $out = [
"meta" => [ "meta" => [
"forecast_cached_at" => (int)$this->GetBuffer("ForecastTS"), "forecast_cached_at" => (int)$this->GetBuffer("ForecastTS"),
"bucket_seconds" => 1800, "bucket_seconds" => 1800,
"actual_is_watt" => (bool)$this->ReadPropertyBoolean("ActualIsWatt") "actual_is_watt" => (bool)$this->ReadPropertyBoolean("ActualIsWatt"),
"start" => $start * 1000,
"end" => $end * 1000,
"now" => $now * 1000
], ],
"series" => [ "series" => [
"forecast" => $forecast, "forecast" => $forecast,
@@ -255,34 +124,115 @@ class PV_Forecast extends IPSModule
echo json_encode($out); echo json_encode($out);
} }
// -------- optional: Hook automatisch im WebHook Control registrieren -------- private function GetForecastSeriesFiltered(int $startTs, int $endTs): array
// Wenn du es bereits manuell eingetragen hast, ist das hier nicht zwingend. {
$raw = $this->GetBuffer("ForecastRaw");
if ($raw === "") return [];
$data = json_decode($raw, true);
if (!is_array($data) || !isset($data["estimated_actuals"]) || !is_array($data["estimated_actuals"])) {
return [];
}
$series = [];
foreach ($data["estimated_actuals"] as $row) {
if (!isset($row["period_end"], $row["pv_power_rooftop"])) continue;
$ts = strtotime($row["period_end"]); // UTC korrekt
if ($ts === false) continue;
// nur HEUTE (lokal) anzeigen
if ($ts < $startTs || $ts >= $endTs) continue;
// Solcast: kW
$val = (float)$row["pv_power_rooftop"];
$series[] = [$ts * 1000, $val];
}
usort($series, fn($a, $b) => $a[0] <=> $b[0]);
return $series;
}
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) return [];
$buckets = []; // bucketEnd => [sum, count]
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;
}
// Wir wollen ein sauberes 30-Minuten Raster über den ganzen Tag,
// aber nach "jetzt" soll die Ist-Linie abbrechen (null).
$series = [];
$isWatt = (bool)$this->ReadPropertyBoolean("ActualIsWatt");
for ($t = $startTs + $bucketSeconds; $t <= $endTs; $t += $bucketSeconds) {
// Zukunft: null (Linie bricht)
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, damit es zu Solcast passt
if ($isWatt) $avg = $avg / 1000.0;
$series[] = [$t * 1000, $avg];
}
return $series;
}
private function GetArchiveInstanceID(): int
{
$list = IPS_GetInstanceListByModuleID(self::ARCHIVE_GUID);
return (is_array($list) && count($list) > 0) ? (int)$list[0] : 0;
}
private function RegisterHook(string $Hook) private function RegisterHook(string $Hook)
{ {
// WebHook Control GUID (kann je nach Version variieren; wenn nicht gefunden -> einfach ignorieren)
$ids = IPS_GetInstanceListByModuleID("{015A6EB8-D6E5-4B93-B496-0D3FAD4D0E6E}"); $ids = IPS_GetInstanceListByModuleID("{015A6EB8-D6E5-4B93-B496-0D3FAD4D0E6E}");
if (count($ids) === 0) { if (count($ids) === 0) return;
return;
}
$hookID = (int)$ids[0]; $hookID = (int)$ids[0];
$hooks = json_decode(IPS_GetProperty($hookID, "Hooks"), true); $hooks = json_decode(IPS_GetProperty($hookID, "Hooks"), true);
if (!is_array($hooks)) { if (!is_array($hooks)) $hooks = [];
$hooks = [];
}
$found = false;
foreach ($hooks as $h) { foreach ($hooks as $h) {
if (isset($h["Hook"]) && $h["Hook"] === $Hook) { if (isset($h["Hook"]) && $h["Hook"] === $Hook) return;
$found = true;
break;
}
} }
if (!$found) { $hooks[] = ["Hook" => $Hook, "TargetID" => $this->InstanceID];
$hooks[] = ["Hook" => $Hook, "TargetID" => $this->InstanceID]; IPS_SetProperty($hookID, "Hooks", json_encode($hooks));
IPS_SetProperty($hookID, "Hooks", json_encode($hooks)); IPS_ApplyChanges($hookID);
IPS_ApplyChanges($hookID);
}
} }
} }