349 lines
11 KiB
PHP
349 lines
11 KiB
PHP
<?php
|
|
|
|
class PV_Forecast extends IPSModule
|
|
{
|
|
private const ARCHIVE_GUID = "{43192F0B-135B-4CE7-A0A7-1475603F3060}";
|
|
|
|
public function Create()
|
|
{
|
|
parent::Create();
|
|
|
|
$this->RegisterPropertyString("URL", "");
|
|
$this->RegisterPropertyInteger("ActualVariableID", 0);
|
|
|
|
// Scheduler
|
|
$this->RegisterPropertyString("RefreshMode", "interval"); // interval | daily
|
|
$this->RegisterPropertyInteger("RefreshMinutes", 5);
|
|
$this->RegisterPropertyString("RefreshTime", "06:00"); // HH:MM
|
|
$this->RegisterPropertyBoolean("ActualIsWatt", true);
|
|
|
|
// Timer -> ruft RequestAction(UpdateForecast) auf
|
|
$this->RegisterTimer("UpdateForecastTimer", 0, 'IPS_RequestAction($_IPS["TARGET"], "UpdateForecast", 0);');
|
|
|
|
// WebHook Endpoint
|
|
$this->RegisterHook("/hook/solcastcompare");
|
|
}
|
|
|
|
public function ApplyChanges()
|
|
{
|
|
parent::ApplyChanges();
|
|
|
|
// Tile Visualization aktivieren
|
|
$this->SetVisualizationType(1);
|
|
|
|
$mode = $this->ReadPropertyString("RefreshMode");
|
|
|
|
if ($mode === "interval") {
|
|
$mins = max(1, (int)$this->ReadPropertyInteger("RefreshMinutes"));
|
|
$this->SetTimerInterval("UpdateForecastTimer", $mins * 60 * 1000);
|
|
} else {
|
|
// daily: jede Minute prüfen
|
|
$this->SetTimerInterval("UpdateForecastTimer", 60 * 1000);
|
|
}
|
|
|
|
// optional: auto-register hook (wenn GUID passt)
|
|
$this->RegisterHook("/hook/solcastcompare");
|
|
}
|
|
|
|
public function RequestAction($Ident, $Value)
|
|
{
|
|
if ($Ident === "UpdateForecast") {
|
|
$this->HandleScheduledUpdate();
|
|
return;
|
|
}
|
|
|
|
throw new Exception("Unknown Ident: " . $Ident);
|
|
}
|
|
|
|
// ----------------- Scheduler -----------------
|
|
|
|
private function HandleScheduledUpdate(): void
|
|
{
|
|
$mode = $this->ReadPropertyString("RefreshMode");
|
|
|
|
if ($mode === "interval") {
|
|
$this->UpdateForecast();
|
|
return;
|
|
}
|
|
|
|
// daily
|
|
$timeStr = trim($this->ReadPropertyString("RefreshTime")); // "06:00"
|
|
$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);
|
|
|
|
// Schon heute gelaufen?
|
|
$lastRun = (int)$this->GetBuffer("LastDailyRun");
|
|
$today0 = strtotime("today");
|
|
|
|
if ($nowSec >= $targetSec && $lastRun < $today0) {
|
|
$this->SendDebug("Scheduler", "Taegliches Update ausgeloest (" . $timeStr . ")", 0);
|
|
$this->UpdateForecast();
|
|
$this->SetBuffer("LastDailyRun", (string)$now);
|
|
}
|
|
}
|
|
|
|
private function ParseHHMMToSeconds(string $hhmm): ?int
|
|
{
|
|
// akzeptiert "6:00" oder "06:00"
|
|
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
|
|
{
|
|
if ($this->GetBuffer("ForecastRaw") === "") {
|
|
$this->UpdateForecast();
|
|
}
|
|
|
|
$html = file_get_contents(__DIR__ . "/module.html");
|
|
$html = str_replace("{{INSTANCE_ID}}", (string)$this->InstanceID, $html);
|
|
return $html;
|
|
}
|
|
|
|
// ----------------- WebHook: data for chart -----------------
|
|
|
|
protected function ProcessHookData()
|
|
{
|
|
$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;
|
|
}
|
|
|
|
$tzLocal = new DateTimeZone(date_default_timezone_get());
|
|
$tzUtc = new DateTimeZone('UTC');
|
|
|
|
// Lokaler Tag (für Archiv / Anzeige)
|
|
$startLocal = new DateTime('today', $tzLocal);
|
|
$endLocal = new DateTime('tomorrow', $tzLocal);
|
|
|
|
$startLocalTs = $startLocal->getTimestamp();
|
|
$endLocalTs = $endLocal->getTimestamp();
|
|
|
|
// Lokaler Tagesbereich als UTC-Grenzen (für Solcast "Z")
|
|
$startUtcTs = (clone $startLocal)->setTimezone($tzUtc)->getTimestamp();
|
|
$endUtcTs = (clone $endLocal)->setTimezone($tzUtc)->getTimestamp();
|
|
|
|
$nowTs = time();
|
|
|
|
// Forecast: komplett für HEUTE (lokaler Tag), korrekt über UTC gefiltert
|
|
$forecast = $this->GetForecastSeriesFilteredUtc($startUtcTs, $endUtcTs);
|
|
|
|
// Actual: komplett HEUTE lokal, aber nach "jetzt" abbrechen (null)
|
|
$actual = $this->GetActualSeriesFromArchive($startLocalTs, $endLocalTs, 1800, $nowTs);
|
|
|
|
$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,
|
|
"start_utc" => $startUtcTs * 1000,
|
|
"end_utc" => $endUtcTs * 1000,
|
|
"now" => $nowTs * 1000
|
|
],
|
|
"series" => [
|
|
"forecast" => $forecast,
|
|
"actual" => $actual
|
|
]
|
|
];
|
|
|
|
header("Content-Type: application/json; charset=utf-8");
|
|
echo json_encode($out);
|
|
}
|
|
|
|
// ----------------- Series: Forecast (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"
|
|
if (isset($data["forecasts"]) && is_array($data["forecasts"])) {
|
|
$rows = $data["forecasts"];
|
|
} elseif (isset($data["estimated_actuals"]) && is_array($data["estimated_actuals"])) {
|
|
// fallback live endpoint
|
|
$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;
|
|
|
|
$series[] = [$ts * 1000, (float)$row["pv_power_rooftop"]];
|
|
}
|
|
|
|
usort($series, fn($a, $b) => $a[0] <=> $b[0]);
|
|
return $series;
|
|
}
|
|
|
|
|
|
// ----------------- Series: Actual (Archive) -----------------
|
|
|
|
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) {
|
|
// trotzdem Raster mit null zurückgeben, damit Linie sauber endet
|
|
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");
|
|
|
|
// Sauberes Raster über den Tag, nach "jetzt" -> null
|
|
for ($t = $startTs + $bucketSeconds; $t <= $endTs; $t += $bucketSeconds) {
|
|
|
|
// Zukunft: null, damit Istlinie nicht parallel bis 24:00 weiterläuft
|
|
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
|
|
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;
|
|
}
|
|
|
|
// ----------------- optional: auto register hook -----------------
|
|
|
|
private function RegisterHook(string $Hook)
|
|
{
|
|
$ids = IPS_GetInstanceListByModuleID("{015A6EB8-D6E5-4B93-B496-0D3FAD4D0E6E}");
|
|
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"] === $Hook) return;
|
|
}
|
|
|
|
$hooks[] = ["Hook" => $Hook, "TargetID" => $this->InstanceID];
|
|
IPS_SetProperty($hookID, "Hooks", json_encode($hooks));
|
|
IPS_ApplyChanges($hookID);
|
|
}
|
|
}
|