no message
This commit is contained in:
@@ -8,14 +8,19 @@
|
|||||||
{
|
{
|
||||||
"type": "SelectVariable",
|
"type": "SelectVariable",
|
||||||
"name": "ActualVariableID",
|
"name": "ActualVariableID",
|
||||||
"caption": "Ist-Produktion Variable"
|
"caption": "Ist-Produktion Variable (Leistung, ideal W oder kW)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "NumberSpinner",
|
"type": "NumberSpinner",
|
||||||
"name": "RefreshMinutes",
|
"name": "RefreshMinutes",
|
||||||
"caption": "Refresh (Minuten)",
|
"caption": "Refresh (Minuten)",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 120
|
"maximum": 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "CheckBox",
|
||||||
|
"name": "ActualIsWatt",
|
||||||
|
"caption": "Istwerte sind in Watt (in kW umrechnen)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"actions": [
|
"actions": [
|
||||||
@@ -30,3 +35,4 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>PV_Forecast</title>
|
<title>PV_Forecast</title>
|
||||||
|
|
||||||
<!-- Externes Script: kann je nach CSP/Mixed-Content blockiert sein -->
|
<!-- 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>
|
||||||
@@ -26,21 +27,19 @@
|
|||||||
<script>
|
<script>
|
||||||
const metaEl = document.getElementById("meta");
|
const metaEl = document.getElementById("meta");
|
||||||
|
|
||||||
// JS-Fehler sichtbar machen (sonst: schwarze Seite, keine Ahnung warum)
|
|
||||||
window.onerror = function (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})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Symcon ersetzt {{INSTANCE_ID}} mit einer ZAHL (z.B. 12345)
|
// 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 wurde nicht korrekt eingesetzt.";
|
||||||
throw new Error("Invalid INSTANCE_ID");
|
throw new Error("Invalid INSTANCE_ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Absoluter Endpoint (robuster als nur /hook/..., vor allem bei Proxies)
|
|
||||||
const endpoint =
|
const endpoint =
|
||||||
`${location.protocol}//${location.host}` +
|
`${location.protocol}//${location.host}` +
|
||||||
`/hook/solcastcompare?instance=${instanceId}&action=data`;
|
`/hook/solcastcompare?instance=${instanceId}&action=data`;
|
||||||
@@ -52,7 +51,7 @@
|
|||||||
const r = await fetch(endpoint, { cache: "no-store" });
|
const r = await fetch(endpoint, { cache: "no-store" });
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const text = await r.text().catch(() => "");
|
const text = await r.text().catch(() => "");
|
||||||
throw new Error(`HTTP ${r.status} ${text ? "- " + text : ""}`.trim());
|
throw new Error(`HTTP ${r.status}${text ? " - " + text : ""}`);
|
||||||
}
|
}
|
||||||
return await r.json();
|
return await r.json();
|
||||||
}
|
}
|
||||||
@@ -64,19 +63,20 @@
|
|||||||
|
|
||||||
const forecast = data?.series?.forecast ?? [];
|
const forecast = data?.series?.forecast ?? [];
|
||||||
const actual = data?.series?.actual ?? [];
|
const actual = data?.series?.actual ?? [];
|
||||||
|
const isWatt = !!data?.meta?.actual_is_watt;
|
||||||
metaEl.textContent = `Forecast Cache: ${cachedAt} | Forecast: ${forecast.length} | Ist: ${actual.length}`;
|
|
||||||
|
|
||||||
if (typeof Highcharts === "undefined") {
|
if (typeof Highcharts === "undefined") {
|
||||||
metaEl.textContent = "Fehler: Highcharts konnte nicht geladen werden (CSP/Mixed Content/CDN).";
|
metaEl.textContent = "Fehler: Highcharts nicht geladen (CSP/CDN/Mixed Content).";
|
||||||
return;
|
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" },
|
||||||
xAxis: { type: "datetime" },
|
xAxis: { type: "datetime" },
|
||||||
yAxis: { title: { text: "Leistung" } },
|
yAxis: { title: { text: yLabel } },
|
||||||
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,10 +85,13 @@
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
} 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() {
|
||||||
@@ -101,7 +104,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("reload").addEventListener("click", refresh);
|
document.getElementById("reload").addEventListener("click", refresh);
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -7,6 +7,6 @@
|
|||||||
"parentRequirements": [],
|
"parentRequirements": [],
|
||||||
"childRequirements": [],
|
"childRequirements": [],
|
||||||
"implemented": [],
|
"implemented": [],
|
||||||
"prefix": "",
|
"prefix": "PVF",
|
||||||
"url": ""
|
"url": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
class PV_Forecast extends IPSModule
|
class PV_Forecast extends IPSModule
|
||||||
{
|
{
|
||||||
// GUID vom Archive Control (Standard in IP-Symcon)
|
// 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}";
|
||||||
@@ -12,11 +12,13 @@ class PV_Forecast extends IPSModule
|
|||||||
$this->RegisterPropertyString("URL", "");
|
$this->RegisterPropertyString("URL", "");
|
||||||
$this->RegisterPropertyInteger("ActualVariableID", 0);
|
$this->RegisterPropertyInteger("ActualVariableID", 0);
|
||||||
$this->RegisterPropertyInteger("RefreshMinutes", 5);
|
$this->RegisterPropertyInteger("RefreshMinutes", 5);
|
||||||
|
$this->RegisterPropertyBoolean("ActualIsWatt", true);
|
||||||
|
|
||||||
// Timer
|
// 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)
|
// 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");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,13 +26,13 @@ class PV_Forecast extends IPSModule
|
|||||||
{
|
{
|
||||||
parent::ApplyChanges();
|
parent::ApplyChanges();
|
||||||
|
|
||||||
// HTML-SDK / Tile Visualization aktivieren
|
// 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 besser hier registrieren
|
// Hook hier nochmal versuchen zu registrieren (ist oft zuverlässiger)
|
||||||
$this->RegisterHook("/hook/solcastcompare");
|
$this->RegisterHook("/hook/solcastcompare");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +81,7 @@ class PV_Forecast extends IPSModule
|
|||||||
|
|
||||||
// -------- Datenaufbereitung fürs Diagramm --------
|
// -------- Datenaufbereitung fürs Diagramm --------
|
||||||
|
|
||||||
private function GetForecastSeries()
|
private function GetForecastSeries(): array
|
||||||
{
|
{
|
||||||
$raw = $this->GetBuffer("ForecastRaw");
|
$raw = $this->GetBuffer("ForecastRaw");
|
||||||
if ($raw === "") {
|
if ($raw === "") {
|
||||||
@@ -92,7 +94,6 @@ class PV_Forecast extends IPSModule
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Solcast liefert period_end in UTC (Z)
|
// Solcast liefert period_end in UTC (Z)
|
||||||
// Wir geben dem Chart epoch-ms in UTC und lassen JS lokal anzeigen (ok).
|
|
||||||
$series = [];
|
$series = [];
|
||||||
foreach ($data["estimated_actuals"] as $row) {
|
foreach ($data["estimated_actuals"] as $row) {
|
||||||
if (!isset($row["period_end"], $row["pv_power_rooftop"])) {
|
if (!isset($row["period_end"], $row["pv_power_rooftop"])) {
|
||||||
@@ -102,18 +103,21 @@ class PV_Forecast extends IPSModule
|
|||||||
if ($ts === false) {
|
if ($ts === false) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// pv_power_rooftop ist i.d.R. kW (Solcast rooftop_pv_power)
|
|
||||||
|
// pv_power_rooftop i.d.R. kW
|
||||||
$val = (float)$row["pv_power_rooftop"];
|
$val = (float)$row["pv_power_rooftop"];
|
||||||
$series[] = [ $ts * 1000, $val ];
|
$series[] = [$ts * 1000, $val];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Solcast ist oft absteigend sortiert -> aufsteigend fürs Chart
|
// oft absteigend -> aufsteigend fürs Chart
|
||||||
usort($series, function($a, $b) { return $a[0] <=> $b[0]; });
|
usort($series, function ($a, $b) {
|
||||||
|
return $a[0] <=> $b[0];
|
||||||
|
});
|
||||||
|
|
||||||
return $series;
|
return $series;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function GetActualSeriesFromArchive($startTs, $endTs, $bucketSeconds = 1800)
|
private function GetActualSeriesFromArchive(int $startTs, int $endTs, int $bucketSeconds = 1800): array
|
||||||
{
|
{
|
||||||
$varId = (int)$this->ReadPropertyInteger("ActualVariableID");
|
$varId = (int)$this->ReadPropertyInteger("ActualVariableID");
|
||||||
if ($varId <= 0 || !IPS_VariableExists($varId)) {
|
if ($varId <= 0 || !IPS_VariableExists($varId)) {
|
||||||
@@ -139,13 +143,19 @@ class PV_Forecast extends IPSModule
|
|||||||
// Bucket: 30 Minuten
|
// Bucket: 30 Minuten
|
||||||
$buckets = []; // key=bucketEndTs => [sum, count]
|
$buckets = []; // key=bucketEndTs => [sum, count]
|
||||||
foreach ($logged as $row) {
|
foreach ($logged as $row) {
|
||||||
if (!isset($row["TimeStamp"], $row["Value"])) continue;
|
if (!isset($row["TimeStamp"], $row["Value"])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$ts = (int)$row["TimeStamp"];
|
$ts = (int)$row["TimeStamp"];
|
||||||
$bucketStart = intdiv($ts, $bucketSeconds) * $bucketSeconds;
|
|
||||||
$bucketEnd = $bucketStart + $bucketSeconds; // wir nehmen wie Solcast period_end
|
|
||||||
|
|
||||||
if ($bucketEnd < $startTs || $bucketEnd > $endTs) continue;
|
// 30min Raster
|
||||||
|
$bucketStart = intdiv($ts, $bucketSeconds) * $bucketSeconds;
|
||||||
|
$bucketEnd = $bucketStart + $bucketSeconds; // wie Solcast period_end
|
||||||
|
|
||||||
|
if ($bucketEnd < $startTs || $bucketEnd > $endTs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isset($buckets[$bucketEnd])) {
|
if (!isset($buckets[$bucketEnd])) {
|
||||||
$buckets[$bucketEnd] = [0.0, 0];
|
$buckets[$bucketEnd] = [0.0, 0];
|
||||||
@@ -157,22 +167,29 @@ class PV_Forecast extends IPSModule
|
|||||||
ksort($buckets);
|
ksort($buckets);
|
||||||
|
|
||||||
$series = [];
|
$series = [];
|
||||||
foreach ($buckets as $bucketEnd => [$sum, $count]) {
|
$isWatt = (bool)$this->ReadPropertyBoolean("ActualIsWatt");
|
||||||
if ($count <= 0) continue;
|
|
||||||
|
foreach ($buckets as $bucketEnd => $sc) {
|
||||||
|
$sum = $sc[0];
|
||||||
|
$count = $sc[1];
|
||||||
|
if ($count <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$avg = $sum / $count;
|
$avg = $sum / $count;
|
||||||
|
|
||||||
// Falls deine Ist-Variable in W ist und Solcast in kW:
|
// W -> kW, damit es zu Solcast passt
|
||||||
// -> hier ggf. /1000 machen.
|
if ($isWatt) {
|
||||||
// Ich lasse es neutral. Wenn du willst: uncomment:
|
$avg = $avg / 1000.0;
|
||||||
// $avg = $avg / 1000.0;
|
}
|
||||||
|
|
||||||
$series[] = [ $bucketEnd * 1000, $avg ];
|
$series[] = [$bucketEnd * 1000, $avg];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $series;
|
return $series;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function GetArchiveInstanceID()
|
private function GetArchiveInstanceID(): int
|
||||||
{
|
{
|
||||||
$list = IPS_GetInstanceListByModuleID(self::ARCHIVE_GUID);
|
$list = IPS_GetInstanceListByModuleID(self::ARCHIVE_GUID);
|
||||||
if (is_array($list) && count($list) > 0) {
|
if (is_array($list) && count($list) > 0) {
|
||||||
@@ -181,7 +198,7 @@ class PV_Forecast extends IPSModule
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- Visualisierung / Hook --------
|
// -------- Tile Visualisierung --------
|
||||||
|
|
||||||
public function GetVisualizationTile(): string
|
public function GetVisualizationTile(): string
|
||||||
{
|
{
|
||||||
@@ -191,18 +208,18 @@ class PV_Forecast extends IPSModule
|
|||||||
}
|
}
|
||||||
|
|
||||||
$html = file_get_contents(__DIR__ . "/module.html");
|
$html = file_get_contents(__DIR__ . "/module.html");
|
||||||
|
|
||||||
// Platzhalter ersetzen
|
|
||||||
$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
|
// 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);
|
||||||
@@ -216,17 +233,17 @@ class PV_Forecast extends IPSModule
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zeitraum: letzte 24h (passt zu deinem hours=24)
|
|
||||||
$end = time();
|
$end = time();
|
||||||
$start = $end - 24 * 3600;
|
$start = $end - 24 * 3600;
|
||||||
|
|
||||||
$forecast = $this->GetForecastSeries();
|
$forecast = $this->GetForecastSeries();
|
||||||
$actual = $this->GetActualSeriesFromArchive($start, $end, 1800);
|
$actual = $this->GetActualSeriesFromArchive($start, $end, 1800);
|
||||||
|
|
||||||
$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")
|
||||||
],
|
],
|
||||||
"series" => [
|
"series" => [
|
||||||
"forecast" => $forecast,
|
"forecast" => $forecast,
|
||||||
@@ -238,21 +255,25 @@ class PV_Forecast extends IPSModule
|
|||||||
echo json_encode($out);
|
echo json_encode($out);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function RegisterHook($Hook)
|
// -------- optional: Hook automatisch im WebHook Control registrieren --------
|
||||||
|
// Wenn du es bereits manuell eingetragen hast, ist das hier nicht zwingend.
|
||||||
|
private function RegisterHook(string $Hook)
|
||||||
{
|
{
|
||||||
$ids = IPS_GetInstanceListByModuleID("{015A6EB8-D6E5-4B93-B496-0D3FAD4D0E6E}"); // WebHook Control GUID
|
// WebHook Control GUID (kann je nach Version variieren; wenn nicht gefunden -> einfach ignorieren)
|
||||||
|
$ids = IPS_GetInstanceListByModuleID("{015A6EB8-D6E5-4B93-B496-0D3FAD4D0E6E}");
|
||||||
if (count($ids) === 0) {
|
if (count($ids) === 0) {
|
||||||
$this->SendDebug("Hook", "WebHook Control nicht gefunden", 0);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$hookID = $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)) $hooks = [];
|
if (!is_array($hooks)) {
|
||||||
|
$hooks = [];
|
||||||
|
}
|
||||||
|
|
||||||
$found = false;
|
$found = false;
|
||||||
foreach ($hooks as $h) {
|
foreach ($hooks as $h) {
|
||||||
if ($h["Hook"] === $Hook) {
|
if (isset($h["Hook"]) && $h["Hook"] === $Hook) {
|
||||||
$found = true;
|
$found = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -264,4 +285,4 @@ class PV_Forecast extends IPSModule
|
|||||||
IPS_ApplyChanges($hookID);
|
IPS_ApplyChanges($hookID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user