diff --git a/Batterie/module.php b/Batterie/module.php index 3a061f2..a4f27e5 100644 --- a/Batterie/module.php +++ b/Batterie/module.php @@ -250,35 +250,6 @@ public function RequestAction($Ident, $Value) }elseif ($batterieManagement == 2 && $batterietyp == 2) { $this->SetValue("Batteriemanagement_Variabel", 4); } - /* - if($this->GetValue("Is_Peak_Shaving")==true){ - if ($power >= 0) { - $this->SetValue("Ladeleistung", $power); - $this->SetValue("Entladeleistung", 0); - $this->SetValue("Laden3_Entladen4", 3); - } else { - $this->SetValue("Entladeleistung", abs($power)); - $this->SetValue("Ladeleistung", 0); - $this->SetValue("Laden3_Entladen4", 4); - } - - }else{ - if ($power >= 0) { - $this->SetValue("Ladeleistung", $power); - $this->SetValue("Entladeleistung", 0); - $this->SetValue("Laden3_Entladen4", 3); - } else { - $this->SetValue("Entladeleistung", abs($power)); - $this->SetValue("Ladeleistung", 0); - $this->SetValue("Laden3_Entladen4", 4); - } - - } - */ - - - - $batterietyp = $this->ReadPropertyInteger("Batterietyp"); diff --git a/PV_Forecast/form.json b/PV_Forecast/form.json deleted file mode 100644 index c746a10..0000000 --- a/PV_Forecast/form.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "elements": [ - { - "type": "ValidationTextBox", - "name": "URL", - "caption": "Solcast URL" - }, - { - "type": "SelectVariable", - "name": "ActualVariableID", - "caption": "Ist-Produktion Variable (Leistung)" - }, - { - "type": "Select", - "name": "RefreshMode", - "caption": "Aktualisierungsmodus", - "options": [ - { "caption": "Alle X Minuten", "value": "interval" }, - { "caption": "Einmal täglich (Uhrzeit)", "value": "daily" } - ] - }, - { - "type": "NumberSpinner", - "name": "RefreshMinutes", - "caption": "Intervall (Minuten)", - "minimum": 1, - "maximum": 240 - }, - { - "type": "ValidationTextBox", - "name": "RefreshTime", - "caption": "Tägliche Uhrzeit (HH:MM, z.B. 06:00)" - }, - { - "type": "CheckBox", - "name": "ActualIsWatt", - "caption": "Istwerte sind in Watt (in kW umrechnen)" - } - ], - "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 deleted file mode 100644 index 4ddd974..0000000 --- a/PV_Forecast/module.html +++ /dev/null @@ -1,186 +0,0 @@ - - - - - PV_Forecast - - - - - - -
-
Lade…
-
- -
- - - - diff --git a/PV_Forecast/module.json b/PV_Forecast/module.json deleted file mode 100644 index 4057eb7..0000000 --- a/PV_Forecast/module.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "{D32FEB32-3F6E-AA8C-CEE6-A09C2789EDC7}", - "name": "PV_Forecast ", - "type": 3, - "vendor": "Belevo AG", - "aliases": [], - "parentRequirements": [], - "childRequirements": [], - "implemented": [], - "prefix": "PVF", - "url": "" -} diff --git a/PV_Forecast/module.php b/PV_Forecast/module.php deleted file mode 100644 index 4000592..0000000 --- a/PV_Forecast/module.php +++ /dev/null @@ -1,348 +0,0 @@ -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); - } -} diff --git a/PV_Forecast/readme.md b/PV_Forecast/readme.md deleted file mode 100644 index bc8dd0b..0000000 --- a/PV_Forecast/readme.md +++ /dev/null @@ -1,59 +0,0 @@ -# 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); -``` diff --git a/PV_Forecast_plotmemory/form.json b/PV_Forecast_plotmemory/form.json deleted file mode 100644 index 9a451ff..0000000 --- a/PV_Forecast_plotmemory/form.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "elements": [ - { "type": "ValidationTextBox", "name": "kunde", "caption": "Kundenname" }, - { "type": "ValidationTextBox", "name": "URL", "caption": "Solcast URL" }, - { "type": "SelectVariable", "name": "ActualVariableID", "caption": "Ist-Produktion Variable (Leistung)" }, - { "type": "CheckBox", "name": "ActualIsWatt", "caption": "Istwerte sind in Watt (in kW umrechnen)" }, - - { - "type": "Select", - "name": "RefreshMode", - "caption": "Forecast-Aktualisierung", - "options": [ - { "caption": "Alle X Minuten", "value": "interval" }, - { "caption": "Einmal täglich (Uhrzeit)", "value": "daily" } - ] - }, - { "type": "NumberSpinner", "name": "RefreshMinutes", "caption": "Intervall (Minuten)", "minimum": 1, "maximum": 240 }, - { "type": "ValidationTextBox", "name": "RefreshTime", "caption": "Tägliche Uhrzeit (HH:MM, z.B. 00:01)" }, - - { "type": "CheckBox", "name": "EnableDailySnapshots", "caption": "Tägliche Tages-Plots speichern" }, - { "type": "ValidationTextBox", "name": "SnapshotTime", "caption": "Snapshot-Uhrzeit (HH:MM, z.B. 23:59)" }, - { "type": "NumberSpinner", "name": "KeepDays", "caption": "Aufbewahrung (Tage)", "minimum": 1, "maximum": 365 } - ], - "actions": [ - { - "type": "Button", - "caption": "Forecast jetzt aktualisieren", - "onClick": "IPS_RequestAction($id, \"UpdateForecast\", 0);" - } - ] -} diff --git a/PV_Forecast_plotmemory/module.html b/PV_Forecast_plotmemory/module.html deleted file mode 100644 index be31056..0000000 --- a/PV_Forecast_plotmemory/module.html +++ /dev/null @@ -1,186 +0,0 @@ - - - - - PV_Forecast_plotmemory - - - - - - -
-
Lade…
-
- -
- - - - diff --git a/PV_Forecast_plotmemory/module.json b/PV_Forecast_plotmemory/module.json deleted file mode 100644 index 1b034b7..0000000 --- a/PV_Forecast_plotmemory/module.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "{DF3B17A3-C2F7-0B57-F632-34668D9206E6}", - "name": "PV_Forecast_plotmemory ", - "type": 3, - "vendor": "Belevo AG", - "aliases": [], - "parentRequirements": [], - "childRequirements": [], - "implemented": [], - "prefix": "PVF", - "url": "" -} diff --git a/PV_Forecast_plotmemory/module.php b/PV_Forecast_plotmemory/module.php deleted file mode 100644 index 05159bd..0000000 --- a/PV_Forecast_plotmemory/module.php +++ /dev/null @@ -1,604 +0,0 @@ -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); - } -} diff --git a/PV_Forecast_plotmemory/readme.md b/PV_Forecast_plotmemory/readme.md deleted file mode 100644 index bc8dd0b..0000000 --- a/PV_Forecast_plotmemory/readme.md +++ /dev/null @@ -1,59 +0,0 @@ -# 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); -``` diff --git a/PV_Visu/form.json b/PV_Visu/form.json deleted file mode 100644 index 1acde8f..0000000 --- a/PV_Visu/form.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "elements": [ - { "type": "SelectVariable", "name": "VarProduction", "caption": "Produktion (kWh)" }, - { "type": "SelectVariable", "name": "VarConsumption", "caption": "Verbrauch (kWh)" }, - { "type": "SelectVariable", "name": "VarFeedIn", "caption": "Einspeisung (kWh)" }, - { "type": "SelectVariable", "name": "VarGrid", "caption": "Bezug Netz (kWh)" } - ], - "actions": [] -} diff --git a/PV_Visu/module.html b/PV_Visu/module.html deleted file mode 100644 index 4d2975c..0000000 --- a/PV_Visu/module.html +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - -
-
-
Produktion (Eigenverbrauch / Einspeisung)
-
-
-
-
-
-
-
-
Verbrauch (PV / Netz)
-
-
-
-
-
-
-
- - - diff --git a/PV_Visu/module.json b/PV_Visu/module.json deleted file mode 100644 index dfbdcba..0000000 --- a/PV_Visu/module.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "{DDE89CBE-4411-5FF4-4931-14204E05CAD0}", - "name": "PV_Visu", - "type": 3, - "vendor": "Belevo AG", - "aliases": [], - "parentRequirements": [], - "childRequirements": [], - "implemented": [], - "prefix": "", - "url": "" -} diff --git a/PV_Visu/module.php b/PV_Visu/module.php deleted file mode 100644 index 475b3be..0000000 --- a/PV_Visu/module.php +++ /dev/null @@ -1,99 +0,0 @@ -RegisterPropertyInteger('VarProduction', 0); - $this->RegisterPropertyInteger('VarConsumption', 0); - $this->RegisterPropertyInteger('VarFeedIn', 0); - $this->RegisterPropertyInteger('VarGrid', 0); - - $this->RegisterVariableString('JSONData', 'Visualisierungsdaten', '', 0); - IPS_SetHidden($this->GetIDForIdent('JSONData'), true); - - $this->SetVisualizationType(1); // HTML SDK Tile - } - - public function ApplyChanges() - { - parent::ApplyChanges(); - - foreach (['VarProduction', 'VarConsumption', 'VarFeedIn', 'VarGrid'] as $prop) { - $vid = $this->ReadPropertyInteger($prop); - if ($vid > 0) { - $this->RegisterMessage($vid, VM_UPDATE); - } - } - - $this->UpdateData(); // Initial - } - - public function MessageSink($TimeStamp, $SenderID, $Message, $Data) - { - if ($Message === VM_UPDATE) { - $this->UpdateData(); - } - } - - public function GetVisualizationTile() - { - $initialData = ''; - $html = file_get_contents(__DIR__ . '/module.html'); - return $html . $initialData; - } - - public function RequestAction($Ident, $Value) - { - if ($Ident === 'update') { - return $this->UpdateData(); // Rückgabe für Visualisierung - } - throw new \Exception("Unknown Ident: $Ident"); - } - - public function UpdateData() - { - $start = strtotime('today 00:00'); - $end = time(); - - $prod = $this->GetDailyTotal($this->ReadPropertyInteger('VarProduction'), $start, $end); - $cons = $this->GetDailyTotal($this->ReadPropertyInteger('VarConsumption'), $start, $end); - $feed = $this->GetDailyTotal($this->ReadPropertyInteger('VarFeedIn'), $start, $end); - $grid = $this->GetDailyTotal($this->ReadPropertyInteger('VarGrid'), $start, $end); - - $prodCons = $prod > 0 ? (($cons - $grid) / $prod) * 100 : 0; - $prodFeed = $prod > 0 ? 100 - $prodCons : 0; - $consPV = $cons > 0 ? min($prod, ($cons - $grid)) / $cons * 100 : 0; - $consGrid = $cons > 0 ? 100 - $consPV : 0; - - $data = [ - 'prodCons' => round($prodCons, 1), - 'prodFeed' => round($prodFeed, 1), - 'consPV' => round($consPV, 1), - 'consGrid' => round($consGrid, 1), - 'value' => [ - 'prod' => round($prod, 2), - 'cons' => round($cons, 2), - 'feed' => round($feed, 2), - 'grid' => round($grid, 2), - ], - ]; - - $json = json_encode($data); - SetValueString($this->GetIDForIdent('JSONData'), $json); - return $data; - } - - private function GetDailyTotal(int $varID, int $start, int $end) - { - if ($varID <= 0) return 0.0; - - $archiveID = @IPS_GetInstanceListByModuleID('{43192F0B-135B-4CE7-A0A7-1475603F3060}')[0]; - if (!$archiveID) return 0.0; - - $values = @AC_GetAggregatedValues($archiveID, $varID, 1, $start, $end, 1); - return isset($values[0]['Avg']) ? (float)$values[0]['Avg'] : 0.0; - } -} \ No newline at end of file diff --git a/PV_Visu/readme.md b/PV_Visu/readme.md deleted file mode 100644 index bc8dd0b..0000000 --- a/PV_Visu/readme.md +++ /dev/null @@ -1,59 +0,0 @@ -# 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); -``` diff --git a/SofarWechselrichter/README.md b/SofarWechselrichter/README.md deleted file mode 100644 index 4fa144c..0000000 --- a/SofarWechselrichter/README.md +++ /dev/null @@ -1,105 +0,0 @@ -2. Starte IP-Symcon neu bzw. klicke in der Konsole auf „Module aktualisieren“. - -Anschließend erscheint in der Instanzliste der neue Gerätetyp **Sofar Wechselrichter**. - ---- - -## Konfiguration - -1. **Instanz anlegen** -- Gehe in der IPS-Konsole auf „Instanzen hinzufügen“ → Hersteller: (falls sichtbar) → Modul: **Sofar Wechselrichter**. -- Vergib einen sinnvollen Namen und eine Beschreibung. - -2. **Einstellungen in den Eigenschaften** -- **Logger-Seriennummer**: - Gib hier die „Logger-Nummer“ deines Sofar-Wechselrichters ein (Dezimal). -- **Abfragezyklus (Sekunden)**: - Intervall in Sekunden, in dem zyklisch alle eingetragenen Register abgefragt werden. - Ein Wert von `0` deaktiviert den Timer (keine zyklischen Abfragen). -- **Register-Tabelle**: - Hier definierst du beliebig viele Zeilen, jeweils mit: - 1. **Register-Nummer** (dezimal) - 2. **Bezeichnung** (z. B. „Gesamtproduktion“ oder „Spannung Phase L1“) - 3. **Skalierungs-faktor** (z. B. `0.1`, `1`, `10` usw.) - - Wird beispielsweise für Register `1476` als Bezeichnung „Gesamtproduktion“ mit Skalierungsfaktor `1` eingetragen, - so liest das Modul alle 60 Sekunden (oder den von dir gewählten Zyklus) Register 1476, - multipliziert das rohe UINT16-Ergebnis mit `1` und legt den Wert in einer IPS-Variable an. - -3. **Speichern/Übernehmen** -Klicke auf „Übernehmen“, um die Änderungen zu übernehmen. -Das Modul legt automatisch untergeordnete IPS-Variablen an: - -- **Vorheriger Wert (Register 1160)** - → INT16BE, unverändert (analog zur alten Node-RED-Logik). - Diese Variable heißt intern `Vorheriger Wert` und wird automatisch gepflegt. -- **Alle weiteren Einträge aus der Register-Tabelle** - → Für jede Zeile wird eine Float-Variable mit der von dir angegebenen „Bezeichnung“ angelegt. - Der Variablen-Identifier lautet automatisch `Reg` (z. B. `Reg1476`). - ---- - -## Funktionsweise - -1. **Initialisierung (ApplyChanges)** -- Liest den Abfragezyklus aus den Moduleigenschaften und initialisiert den Timer. -- Legt eine Integer-Variable `Vorheriger Wert` (Reg 1160) an. -- Legt für jede Zeile in der Register-Tabelle eine Float-Variable an (Ident `Reg`). - -2. **Zyklische Abfrage (Timer-Callback)** -- **Register 1160 (INT16BE)** - → Wird als „Vorheriger Wert“ in die Variable geschrieben (signed interpretiert). -- **Alle weiteren Register aus der Tabelle** - → Jedes Register wird per Modbus-ähnlichem TCP-Paketaustausch abgefragt, als UINT16 ausgelesen, - mit dem angegebenen Skalierungsfaktor multipliziert und in der zugehörigen Float-Variable gespeichert. - -3. **Kommunikation** -- TCP-Verbindung zu `192.168.0.0:8899` (feste IP im Code). -- Der Aufruf von `readRegister()` baut ein „Out_Frame“ wie in Node-RED, - rechnet CRC16-Modbus über die letzten 6 Bytes, hängt eine Summen-Checksum + 0x15 an, - sendet das Paket, liest die Antwort, schneidet exakt 2 Daten-Bytes heraus und liefert sie zurück. - ---- - -## Beispiel: Register 1476 („Gesamtproduktion“) - -- **Register-Tabelle** -| Register‐Nummer | Bezeichnung | Skalierungsfaktor | -| --------------: | :------------------ | ----------------: | -| 1476 | Gesamtproduktion | 1 | - -- **Ergebnis** -- Eine Float-Variable mit der Bezeichnung „Gesamtproduktion“ wird angelegt. -- Wenn der PollInterval auf `60` Sekunden steht, liest das Modul alle 60 Sekunden das Register 1476, - skaliert mit 1 und schreibt den numerischen Wert in `Reg1476`. - ---- - -## Fehlersuche - -- Falls die Variable „Vorheriger Wert“ immer denselben Wert liefert oder ein Lesefehler auftritt, prüfe bitte: -1. **Logger-Nummer**: Ist sie korrekt (Dezimal)? -2. **Netzwerk/Firewall**: Kann Symcon die Adresse `192.168.0.100:8899` erreichen? -3. **Debug-Ausgaben**: - – Öffne in der Konsole „Kernel-Log“ → Filter „SofarWechselrichter“. - – Dort werden WARNs und ERRs protokolliert, falls z. B. keine Antwort kommt oder das Datenpaket inkorrekt ist. - -- Falls du andere Datentypen brauchst (z. B. INT16 für Register außerhalb 1160), definiere sie analog als separate Zeile: -– Trage die `Register‐Nummer` ein, gib als Skalierungsfaktor `1` (oder `0.1` etc.) an. -– Der absolute Rohwert wird stets als UINT16 interpretiert (0–65535). -– Solltest du negative INT16 benötigen, kannst du nachträglich einfach die Variable „Vorheriger Wert“ (Reg 1160) - als Beispiel nehmen und in einem Script umrechnen (Werte über 32767 → –32768 + Rest). - ---- - -## Versionshistorie - -- **1.0** -- Erstveröffentlichung: - • Zyklische Abfrage beliebiger Register in einer Matrix konfigurieren - • Automatische Anlage von Variablen für jeden Eintrag - • Spezieller „Vorheriger Wert“ (Register 1160 als INT16) - ---- - -*Ende der Dokumentation.* diff --git a/SofarWechselrichter/form.json b/SofarWechselrichter/form.json deleted file mode 100644 index c7c602b..0000000 --- a/SofarWechselrichter/form.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "elements": [ - { - "type": "Label", - "caption": "Sofar Wechselrichter Konfiguration" - }, - { - "type": "ValidationTextBox", - "name": "IPAddress", - "caption": "Inverter IP-Adresse" - }, - { - "type": "ValidationTextBox", - "name": "LoggerNumber", - "caption": "Logger-Seriennummer" - }, - { - "type": "NumberSpinner", - "name": "PollInterval", - "caption": "Abfragezyklus (Sekunden)", - "minimum": 1, - "suffix": "s" - }, - { - "type": "List", - "name": "Registers", - "caption": "Register-Tabelle", - "add": "Neues Register hinzufügen", - "delete": "Lösche Eintrag", - "columns": [ - { - "caption": "Register-Nummer", - "name": "RegisterNumber", - "width": "200px", - "add": 0, - "edit": { - "type": "NumberSpinner", - "minimum": 0 - } - }, - { - "caption": "Bezeichnung", - "name": "Label", - "width": "300px", - "add": "", - "edit": { - "type": "ValidationTextBox" - } - }, - { - "caption": "Skalierungs-faktor", - "name": "ScalingFactor", - "width": "200px", - "add": 1, - "edit": { - "type": "NumberSpinner", - "digits": 4, - "minimum": -999999, - "maximum": 999999 - } - }, - { - "caption": "Endian", - "name": "Endian", - "width": "80px", - "add": "BE", - "edit": { - "type": "Select", - "options": [ - { "caption": "BE", "value": "BE" }, - { "caption": "LE", "value": "LE" } - ] - } - }, - { - "caption": "Bit-Länge", - "name": "BitLength", - "width": "80px", - "add": "16", - "edit": { - "type": "Select", - "options": [ - { "caption": "16 Bit", "value": "16" }, - { "caption": "32 Bit", "value": "32" }, - { "caption": "64 Bit", "value": "64" } - ] - } - }, - { - "caption": "Signed/Unsigned", - "name": "Signedness", - "width": "100px", - "add": "Unsigned", - "edit": { - "type": "Select", - "options": [ - { "caption": "Unsigned", "value": "Unsigned" }, - { "caption": "Signed", "value": "Signed" } - ] - } - } - ] - } - ], - "actions": [] -} diff --git a/SofarWechselrichter/module.json b/SofarWechselrichter/module.json deleted file mode 100644 index 1d594c2..0000000 --- a/SofarWechselrichter/module.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "{C26E97C8-BA00-0563-6B14-B807C5ACE17F}", - "name": "SofarWechselrichter", - "type": 3, - "vendor": "Belevo AG", - "aliases": [], - "parentRequirements": [], - "childRequirements": [], - "implemented": [], - "prefix": "GEF", - "url": "" -} diff --git a/SofarWechselrichter/module.php b/SofarWechselrichter/module.php deleted file mode 100644 index e289af9..0000000 --- a/SofarWechselrichter/module.php +++ /dev/null @@ -1,379 +0,0 @@ -RegisterPropertyString('IPAddress', ''); - $this->RegisterPropertyString('LoggerNumber', '0'); // als String - $this->RegisterPropertyInteger('PollInterval', 60); - $this->RegisterPropertyString('Registers', '[]'); // JSON-String - - // Timer für zyklische Abfragen (per RequestAction("Query")) - $script = 'IPS_RequestAction(' . $this->InstanceID . ', "Query", "");'; - $this->RegisterTimer('QueryTimer', 0, $script); - } - - public function ApplyChanges() - { - parent::ApplyChanges(); - // Timer-Intervall (ms) - $intervalSec = $this->ReadPropertyInteger('PollInterval'); - $intervalMs = ($intervalSec > 0) ? $intervalSec * 1000 : 0; - $this->SetTimerInterval('QueryTimer', $intervalMs); - - // Aktuelle Registerliste - $registers = json_decode($this->ReadPropertyString('Registers'), true); - if (!is_array($registers)) { - $registers = []; - } - - // 1) Variablen anlegen (neu hinzugekommene Register) - $position = 10; - foreach ($registers as $entry) { - $regNo = (int) $entry['RegisterNumber']; - $label = trim($entry['Label']); - if ($regNo < 0 || $label === '') { - continue; - } - $ident = 'Reg' . $regNo; - if (!$this->VariableExists($ident)) { - $this->RegisterVariableFloat($ident, $label, '', $position); - } - $position += 10; - } - - // 2) Variablen löschen (falls Register entfernt wurden) - $validIdents = []; - foreach ($registers as $entry) { - $validIdents[] = 'Reg' . ((int)$entry['RegisterNumber']); - } - $children = IPS_GetChildrenIDs($this->InstanceID); - foreach ($children as $childID) { - $obj = IPS_GetObject($childID); - if ($obj['ObjectType'] !== 2) { - continue; - } - $ident = $obj['ObjectIdent']; - if (substr($ident, 0, 3) === 'Reg') { - if (!in_array($ident, $validIdents)) { - IPS_DeleteVariable($childID); - } - } - } - } - - public function Destroy() - { - parent::Destroy(); - } - - /** - * Wird aufgerufen durch IPS_RequestAction über den Timer - */ - public function RequestAction($Ident, $Value) - { - switch ($Ident) { - case 'Query': - $this->Query(); - break; - default: - throw new Exception('Ungültiger Ident: ' . $Ident); - } - } - - /** - * Zyklische Abfrage aller definierten Register - */ - private function Query(): void - { - $this->LogMessage('Query invoked', KL_MESSAGE); - - $ip = trim($this->ReadPropertyString('IPAddress')); - $loggerNumberStr = trim($this->ReadPropertyString('LoggerNumber')); - - // 1) Validierung IP - if (!filter_var($ip, FILTER_VALIDATE_IP)) { - $this->LogMessage('Abbruch: Ungültige IP = "' . $ip . '"', KL_WARNING); - return; - } - // 2) Validierung LoggerNumber (Dezimal-String, > 0) - if ($loggerNumberStr === '' || !ctype_digit($loggerNumberStr) || bccomp($loggerNumberStr, '1') < 0) { - $this->LogMessage('Abbruch: Ungültige LoggerNumber = "' . $loggerNumberStr . '"', KL_WARNING); - return; - } - - // 3) Register-Liste einlesen - $registers = json_decode($this->ReadPropertyString('Registers'), true); - if (!is_array($registers) || count($registers) === 0) { - // Keine Register definiert - return; - } - - // 4) Für jedes Register: einzeln auslesen, zusammensetzen, skalieren, speichern - foreach ($registers as $entry) { - $regNo = (int) $entry['RegisterNumber']; - $label = trim((string)$entry['Label']); - $scale = (string) $entry['ScalingFactor']; // kann negativ sein - $endian = strtoupper(trim((string)$entry['Endian'])); - $bitLength = (int) $entry['BitLength']; // 16, 32 oder 64 - $signedness = trim((string)$entry['Signedness']); // "Signed" oder "Unsigned" - $ident = 'Reg' . $regNo; - - if ($regNo < 0 || $label === '' || !in_array($bitLength, [16, 32, 64])) { - continue; - } - - try { - $numRegs = $bitLength / 16; // 1, 2 oder 4 - // Bytes registerweise einzeln abfragen und zusammenfügen: - $dataBytes = ''; - for ($i = 0; $i < $numRegs; $i++) { - $chunk = $this->readSingleRegister($ip, $loggerNumberStr, $regNo + $i); - $dataBytes .= $chunk; - } - // Debug: raw combined response hex - $combinedHex = strtoupper(bin2hex($dataBytes)); - $this->LogMessage("Raw data for Reg {$regNo} ({$bitLength}bit): {$combinedHex}", KL_MESSAGE); - - // Endian-Handling: falls LE, kehre gesamte Byte-Reihenfolge um - if ($endian === 'LE') { - $combinedHex = $this->reverseByteOrder($combinedHex); - $this->LogMessage("After LE reverse: {$combinedHex}", KL_MESSAGE); - } - - // Konvertiere Hex in Dezimal-String - $rawDec = $this->hexToDecimal($combinedHex); - - // Bei "Signed" → Zwei-Komplement-Umrechnung - if ($signedness === 'Signed') { - $half = bcpow('2', (string)($bitLength - 1), 0); // 2^(bitLength-1) - $fullRange = bcpow('2', (string)$bitLength, 0); // 2^bitLength - if (bccomp($rawDec, $half) >= 0) { - $rawDec = bcsub($rawDec, $fullRange, 0); - } - $this->LogMessage("Signed rawDec for Reg {$regNo}: {$rawDec}", KL_MESSAGE); - } - - // Skaliere (bc*-Multiplikation, 4 Nachkommastellen) - $valueStr = bcmul($rawDec, $scale, 4); - SetValueFloat($this->GetIDForIdent($ident), (float)$valueStr); - $this->LogMessage("Final value for Reg {$regNo}: {$valueStr}", KL_MESSAGE); - } catch (Exception $e) { - $this->LogMessage( - "Fehler Lesen Reg {$regNo} ({$bitLength}bit, {$signedness}): " . $e->getMessage(), - KL_WARNING - ); - } - } - } - - /** - * Liest genau ein Register (16 Bit) per Modbus-ähnlichem TCP (2 Bytes zurück). - * - * @param string $ip Inverter-IP - * @param string $serial_nr_str Logger-Seriennummer als Dezimal-String - * @param int $reg Register-Adresse - * @return string 2-Byte-Binär-String - * @throws Exception Bei Kommunikationsfehlern - */ - private function readSingleRegister(string $ip, string $serial_nr_str, int $reg): string - { - // 1) Out_Frame ohne CRC aufbauen - $oFrame = 'a5170010450000'; - - // Dezimal-String → 8-stellige Hex - $hexSN8 = $this->decStringToHex8($serial_nr_str); - $hexSNbytes = [ - substr($hexSN8, 6, 2), - substr($hexSN8, 4, 2), - substr($hexSN8, 2, 2), - substr($hexSN8, 0, 2), - ]; - $oFrame .= implode('', $hexSNbytes); - - // Data-Field (16 Hex-Zeichen konstant) - $oFrame .= '020000000000000000000000000000'; - - // Business-Field: 01 03 + Start-Register + Anzahl Register (1) - $startHex = str_pad(dechex($reg), 4, '0', STR_PAD_LEFT); - $numHex = str_pad(dechex(1), 4, '0', STR_PAD_LEFT); - $oFrame .= '0103' . $startHex . $numHex; - - // 2) CRC16-Modbus (letzte 6 Bytes) - $crcInputHex = substr($oFrame, -12); - $crcInputBin = hex2bin($crcInputHex); - if ($crcInputBin === false) { - throw new Exception("Ungültiges Hex in CRC-Input: {$crcInputHex}"); - } - $crcValue = $this->calculateCRC16Modbus($crcInputBin); - $crcHex = strtoupper(str_pad(dechex($crcValue), 4, '0', STR_PAD_LEFT)); - $crcSwapped = substr($crcHex, 2, 2) . substr($crcHex, 0, 2); - $oFrameWithCRC = $oFrame . strtolower($crcSwapped); - - // 3) Summen-Checksum (alle Bytes ab Index 1) + 0x15 - $l = strlen($oFrameWithCRC) / 2; - $bArr = []; - for ($i = 0; $i < $l; $i++) { - $byteHex = substr($oFrameWithCRC, 2 * $i, 2); - $bArr[$i] = hexdec($byteHex); - } - $crcSum = 0; - for ($i = 1; $i < $l; $i++) { - $crcSum += $bArr[$i]; - $crcSum &= 0xFF; - } - $bArr[$l] = $crcSum; - $bArr[$l+1] = 0x15; - - $frameBin = ''; - foreach ($bArr as $b) { - $frameBin .= chr($b); - } - - // 4) TCP-Verbindung öffnen & Paket senden (Port fest 8899) - $port = 8899; - $fp = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 5); - if (!$fp) { - throw new Exception("Verbindung zu {$ip}:{$port} fehlgeschlagen ({$errno}: {$errstr})"); - } - fwrite($fp, $frameBin); - stream_set_timeout($fp, 2); - - // 5) Antwort einlesen - $response = ''; - while (!feof($fp)) { - $chunk = fread($fp, 1024); - if ($chunk === false || $chunk === '') { - break; - } - $response .= $chunk; - } - fclose($fp); - if ($response === '') { - throw new Exception("Keine Antwort vom Inverter erhalten."); - } - - // Debug: log raw response hex - $respHex = strtoupper(bin2hex($response)); - $this->LogMessage("Raw response for single reg {$reg}: {$respHex}", KL_MESSAGE); - - // 6) Slice-Logik: l = 2*1 + 4 = 6, slice(-6, 2) → 2 Bytes - $lModbus = 2 * 1 + 4; // = 6 - $numBytes = 2; - if (strlen($response) < $lModbus) { - throw new Exception("Unerwartet kurze Antwort (< {$lModbus} Bytes)."); - } - $dataBytes = substr($response, -$lModbus, $numBytes); - if (strlen($dataBytes) < $numBytes) { - throw new Exception("Data-Segment enthält weniger als {$numBytes} Bytes."); - } - $dataHex = strtoupper(bin2hex($dataBytes)); - $this->LogMessage("Sliced data for single reg {$reg}: {$dataHex}", KL_MESSAGE); - - return $dataBytes; - } - - /** - * Wandelt einen Dezimal-String in einen 8-stelligen Hex-String um. - * - * @param string $decString - * @return string 8-stellige Hex (uppercase) - */ - private function decStringToHex8(string $decString): string - { - $num = ltrim($decString, '0'); - if ($num === '') { - return '00000000'; - } - $hex = ''; - while (bccomp($num, '0') > 0) { - $mod = bcmod($num, '16'); - $digit = dechex((int)$mod); - $hex = strtoupper($digit) . $hex; - $num = bcdiv($num, '16', 0); - } - return str_pad($hex, 8, '0', STR_PAD_LEFT); - } - - /** - * Kehrt die Byte-Reihenfolge eines Hex-Strings um (2 Hex-Zeichen = 1 Byte). - * - * @param string $hex Hex-Repr. (z.B. "A1B2C3D4") - * @return string Umgekehrte Byte-Reihenfolge (z.B. "D4C3B2A1") - */ - private function reverseByteOrder(string $hex): string - { - $bytes = str_split($hex, 2); - $bytes = array_reverse($bytes); - return implode('', $bytes); - } - - /** - * Konvertiert einen Hex-String in einen Dezimal-String (BCMath). - * - * @param string $hex Uppercase-Hex ohne Präfix (z.B. "00FF10A3") - * @return string Dezimal-String (z.B. "16737763") - */ - private function hexToDecimal(string $hex): string - { - $hex = ltrim($hex, '0'); - if ($hex === '') { - return '0'; - } - $len = strlen($hex); - $dec = '0'; - $power16 = '1'; - for ($i = $len - 1; $i >= 0; $i--) { - $digit = hexdec($hex[$i]); - $term = bcmul((string)$digit, $power16, 0); - $dec = bcadd($dec, $term, 0); - $power16 = bcmul($power16, '16', 0); - } - return $dec; - } - - /** - * Berechnet CRC16-Modbus (Init=0xFFFF, Poly=0xA001) über Binärdaten. - */ - private function calculateCRC16Modbus(string $binaryData): int - { - $crc = 0xFFFF; - $len = strlen($binaryData); - for ($pos = 0; $pos < $len; $pos++) { - $crc ^= ord($binaryData[$pos]); - for ($i = 0; $i < 8; $i++) { - if (($crc & 0x0001) !== 0) { - $crc >>= 1; - $crc ^= 0xA001; - } else { - $crc >>= 1; - } - } - } - return $crc; - } - - /** - * Prüft, ob eine Variable mit Ident existiert. - */ - private function VariableExists(string $ident): bool - { - $vid = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); - return ($vid !== false && IPS_VariableExists($vid)); - } -}