diff --git a/PV_Forecast/form.json b/PV_Forecast/form.json
new file mode 100644
index 0000000..91e2ac9
--- /dev/null
+++ b/PV_Forecast/form.json
@@ -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."
+ }
+ ]
+}
diff --git a/PV_Forecast/module.html b/PV_Forecast/module.html
new file mode 100644
index 0000000..830de33
--- /dev/null
+++ b/PV_Forecast/module.html
@@ -0,0 +1,78 @@
+
+
+
+
+ PV_Forecast
+
+
+
+
+
+
+ …
+
+
+
+
+
+
+
diff --git a/PV_Forecast/module.json b/PV_Forecast/module.json
new file mode 100644
index 0000000..dd88242
--- /dev/null
+++ b/PV_Forecast/module.json
@@ -0,0 +1,12 @@
+{
+ "id": "{D32FEB32-3F6E-AA8C-CEE6-A09C2789EDC7}",
+ "name": "PV_Forecast ",
+ "type": 3,
+ "vendor": "Belevo AG",
+ "aliases": [],
+ "parentRequirements": [],
+ "childRequirements": [],
+ "implemented": [],
+ "prefix": "",
+ "url": ""
+}
diff --git a/PV_Forecast/module.php b/PV_Forecast/module.php
new file mode 100644
index 0000000..4a0e0e2
--- /dev/null
+++ b/PV_Forecast/module.php
@@ -0,0 +1,262 @@
+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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/PV_Forecast/readme.md b/PV_Forecast/readme.md
new file mode 100644
index 0000000..bc8dd0b
--- /dev/null
+++ b/PV_Forecast/readme.md
@@ -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);
+```