diff --git a/PV_Forecast_plotmemory/form.json b/PV_Forecast_plotmemory/form.json
new file mode 100644
index 0000000..ff547a2
--- /dev/null
+++ b/PV_Forecast_plotmemory/form.json
@@ -0,0 +1,30 @@
+{
+ "elements": [
+ { "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
new file mode 100644
index 0000000..be31056
--- /dev/null
+++ b/PV_Forecast_plotmemory/module.html
@@ -0,0 +1,186 @@
+
+
+
+
+ PV_Forecast_plotmemory
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PV_Forecast_plotmemory/module.json b/PV_Forecast_plotmemory/module.json
new file mode 100644
index 0000000..1b034b7
--- /dev/null
+++ b/PV_Forecast_plotmemory/module.json
@@ -0,0 +1,12 @@
+{
+ "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
new file mode 100644
index 0000000..3f92ed6
--- /dev/null
+++ b/PV_Forecast_plotmemory/module.php
@@ -0,0 +1,589 @@
+RegisterPropertyString("URL", "");
+ $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);
+
+ // 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");
+ }
+
+ 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);
+ }
+
+ $this->RegisterHook("/hook/solcastcompare");
+ }
+
+ 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->GetBuffer("LastDailyRunForecast");
+ $today0 = strtotime("today");
+
+ if ($nowSec >= $targetSec && $lastRun < $today0) {
+ $this->SendDebug("Scheduler", "Taegliches Forecast-Update (" . $timeStr . ")", 0);
+ $this->UpdateForecast();
+ $this->SetBuffer("LastDailyRunForecast", (string)$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);
+
+ $lastRun = (int)$this->GetBuffer("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->SetBuffer("LastDailySnapshotRun", (string)$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
+ {
+ 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 + 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"] ?? []);
+
+ // Hinweis: nutzt Highcharts CDN. (Wenn du offline öffnen willst, sag Bescheid, dann liefern wir highcharts.js lokal mit.)
+ 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;
+ }
+
+ header("Content-Type: application/zip");
+ header('Content-Disposition: attachment; filename="pv_plots_' . $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
new file mode 100644
index 0000000..bc8dd0b
--- /dev/null
+++ b/PV_Forecast_plotmemory/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);
+```