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 ' PV Snapshot ' . htmlspecialchars($date) . '

PV: Erwartung vs. Tatsächlich (' . htmlspecialchars($date) . ')

Gespeichert: ' . date('d.m.Y H:i:s', (int)($payload["meta"]["created_at"] ?? time())) . '
'; } 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); } }