no message
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "ValidationTextBox",
|
||||||
|
"name": "URL",
|
||||||
|
"caption": "Solcast URL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "SelectVariable",
|
||||||
|
"name": "ActualVariableID",
|
||||||
|
"caption": "Ist-Produktion Variable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"name": "RefreshMinutes",
|
||||||
|
"caption": "Refresh (Minuten)",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 120
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!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: sans-serif; margin: 0; padding: 12px; }
|
||||||
|
#chart { height: 420px; width: 100%; }
|
||||||
|
.row { display:flex; gap:12px; align-items:center; margin-bottom:10px; flex-wrap:wrap; }
|
||||||
|
.badge { padding:4px 8px; border-radius:10px; background:#eee; }
|
||||||
|
button { padding:6px 10px; cursor:pointer; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="row">
|
||||||
|
<button id="reload">Neu laden</button>
|
||||||
|
<span class="badge" id="meta">…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="chart"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const instanceId = {INSTANCE_ID};
|
||||||
|
const endpoint = `/hook/solcastcompare?instance=${instanceId}&action=data`;
|
||||||
|
|
||||||
|
let chart;
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
const r = await fetch(endpoint, { cache: "no-store" });
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return await r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
const cachedAt = data?.meta?.forecast_cached_at
|
||||||
|
? new Date(data.meta.forecast_cached_at * 1000).toLocaleString()
|
||||||
|
: "unbekannt";
|
||||||
|
|
||||||
|
document.getElementById("meta").textContent = `Forecast Cache: ${cachedAt}`;
|
||||||
|
|
||||||
|
const forecast = data?.series?.forecast ?? [];
|
||||||
|
const actual = data?.series?.actual ?? [];
|
||||||
|
|
||||||
|
if (!chart) {
|
||||||
|
chart = Highcharts.chart("chart", {
|
||||||
|
title: { text: "PV: Erwartung vs. Tatsächlich" },
|
||||||
|
xAxis: { type: "datetime" },
|
||||||
|
yAxis: { title: { text: "Leistung" } },
|
||||||
|
tooltip: { shared: true, xDateFormat: "%d.%m.%Y %H:%M" },
|
||||||
|
legend: { enabled: true },
|
||||||
|
series: [
|
||||||
|
{ name: "Erwartet (Solcast)", data: forecast },
|
||||||
|
{ name: "Tatsächlich (Archiv)", data: actual }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
chart.series[0].setData(forecast, false);
|
||||||
|
chart.series[1].setData(actual, false);
|
||||||
|
chart.redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const data = await loadData();
|
||||||
|
render(data);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("meta").textContent = "Fehler beim Laden: " + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("reload").addEventListener("click", refresh);
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"id": "{D32FEB32-3F6E-AA8C-CEE6-A09C2789EDC7}",
|
||||||
|
"name": "PV_Forecast ",
|
||||||
|
"type": 3,
|
||||||
|
"vendor": "Belevo AG",
|
||||||
|
"aliases": [],
|
||||||
|
"parentRequirements": [],
|
||||||
|
"childRequirements": [],
|
||||||
|
"implemented": [],
|
||||||
|
"prefix": "",
|
||||||
|
"url": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class PV_Forecast extends IPSModule
|
||||||
|
{
|
||||||
|
// GUID vom Archive Control (Standard in IP-Symcon)
|
||||||
|
private const ARCHIVE_GUID = "{43192F0B-135B-4CE7-A0A7-1475603F3060}";
|
||||||
|
|
||||||
|
public function Create()
|
||||||
|
{
|
||||||
|
parent::Create();
|
||||||
|
|
||||||
|
$this->RegisterPropertyString("URL", "");
|
||||||
|
$this->RegisterPropertyInteger("ActualVariableID", 0);
|
||||||
|
$this->RegisterPropertyInteger("RefreshMinutes", 5);
|
||||||
|
|
||||||
|
// Timer
|
||||||
|
$this->RegisterTimer("UpdateForecastTimer", 0, 'IPS_RequestAction($_IPS["TARGET"], "UpdateForecast", 0);');
|
||||||
|
|
||||||
|
// Hook für Visualisierung (JSON Endpoint)
|
||||||
|
$this->RegisterHook("/hook/solcastcompare");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ApplyChanges()
|
||||||
|
{
|
||||||
|
parent::ApplyChanges();
|
||||||
|
|
||||||
|
$mins = max(1, (int)$this->ReadPropertyInteger("RefreshMinutes"));
|
||||||
|
$this->SetTimerInterval("UpdateForecastTimer", $mins * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function RequestAction($Ident, $Value)
|
||||||
|
{
|
||||||
|
switch ($Ident) {
|
||||||
|
case "UpdateForecast":
|
||||||
|
$this->UpdateForecast();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Exception("Unknown Ident: " . $Ident);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function UpdateForecast()
|
||||||
|
{
|
||||||
|
$url = trim($this->ReadPropertyString("URL"));
|
||||||
|
if ($url === "") {
|
||||||
|
$this->SendDebug("UpdateForecast", "URL ist leer", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Datenaufbereitung fürs Diagramm --------
|
||||||
|
|
||||||
|
private function GetForecastSeries()
|
||||||
|
{
|
||||||
|
$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)
|
||||||
|
// Wir geben dem Chart epoch-ms in UTC und lassen JS lokal anzeigen (ok).
|
||||||
|
$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 ist i.d.R. kW (Solcast rooftop_pv_power)
|
||||||
|
$val = (float)$row["pv_power_rooftop"];
|
||||||
|
$series[] = [ $ts * 1000, $val ];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solcast ist oft absteigend sortiert -> aufsteigend fürs Chart
|
||||||
|
usort($series, function($a, $b) { return $a[0] <=> $b[0]; });
|
||||||
|
|
||||||
|
return $series;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function GetActualSeriesFromArchive($startTs, $endTs, $bucketSeconds = 1800)
|
||||||
|
{
|
||||||
|
$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"];
|
||||||
|
$bucketStart = intdiv($ts, $bucketSeconds) * $bucketSeconds;
|
||||||
|
$bucketEnd = $bucketStart + $bucketSeconds; // wir nehmen 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 = [];
|
||||||
|
foreach ($buckets as $bucketEnd => [$sum, $count]) {
|
||||||
|
if ($count <= 0) continue;
|
||||||
|
$avg = $sum / $count;
|
||||||
|
|
||||||
|
// Falls deine Ist-Variable in W ist und Solcast in kW:
|
||||||
|
// -> hier ggf. /1000 machen.
|
||||||
|
// Ich lasse es neutral. Wenn du willst: uncomment:
|
||||||
|
// $avg = $avg / 1000.0;
|
||||||
|
|
||||||
|
$series[] = [ $bucketEnd * 1000, $avg ];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $series;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function GetArchiveInstanceID()
|
||||||
|
{
|
||||||
|
$list = IPS_GetInstanceListByModuleID(self::ARCHIVE_GUID);
|
||||||
|
if (is_array($list) && count($list) > 0) {
|
||||||
|
return (int)$list[0];
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Visualisierung / Hook --------
|
||||||
|
|
||||||
|
public function GetVisualization()
|
||||||
|
{
|
||||||
|
// Forecast ggf. initial laden, wenn noch nichts da
|
||||||
|
if ($this->GetBuffer("ForecastRaw") === "") {
|
||||||
|
$this->UpdateForecast();
|
||||||
|
}
|
||||||
|
|
||||||
|
$htmlPath = __DIR__ . "/module.html";
|
||||||
|
$html = file_get_contents($htmlPath);
|
||||||
|
|
||||||
|
// Platzhalter ersetzen
|
||||||
|
$html = str_replace("{{INSTANCE_ID}}", (string)$this->InstanceID, $html);
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function ProcessHookData()
|
||||||
|
{
|
||||||
|
// Erwartet: /hook/solcastcompare?instance=12345&action=data
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeitraum: letzte 24h (passt zu deinem hours=24)
|
||||||
|
$end = time();
|
||||||
|
$start = $end - 24 * 3600;
|
||||||
|
|
||||||
|
$forecast = $this->GetForecastSeries();
|
||||||
|
$actual = $this->GetActualSeriesFromArchive($start, $end, 1800);
|
||||||
|
|
||||||
|
$out = [
|
||||||
|
"meta" => [
|
||||||
|
"forecast_cached_at" => (int)$this->GetBuffer("ForecastTS"),
|
||||||
|
"bucket_seconds" => 1800
|
||||||
|
],
|
||||||
|
"series" => [
|
||||||
|
"forecast" => $forecast,
|
||||||
|
"actual" => $actual
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
header("Content-Type: application/json; charset=utf-8");
|
||||||
|
echo json_encode($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function RegisterHook($Hook)
|
||||||
|
{
|
||||||
|
$ids = IPS_GetInstanceListByModuleID("{015A6EB8-D6E5-4B93-B496-0D3FAD4D0E6E}"); // WebHook Control GUID
|
||||||
|
if (count($ids) === 0) {
|
||||||
|
$this->SendDebug("Hook", "WebHook Control nicht gefunden", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$hookID = $ids[0];
|
||||||
|
|
||||||
|
$hooks = json_decode(IPS_GetProperty($hookID, "Hooks"), true);
|
||||||
|
if (!is_array($hooks)) $hooks = [];
|
||||||
|
|
||||||
|
$found = false;
|
||||||
|
foreach ($hooks as $h) {
|
||||||
|
if ($h["Hook"] === $Hook) {
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$found) {
|
||||||
|
$hooks[] = ["Hook" => $Hook, "TargetID" => $this->InstanceID];
|
||||||
|
IPS_SetProperty($hookID, "Hooks", json_encode($hooks));
|
||||||
|
IPS_ApplyChanges($hookID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 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);
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user