From b8b2e66fd0f20f11da70c0a443e837e917023567 Mon Sep 17 00:00:00 2001 From: "m0@nas" Date: Tue, 15 Sep 2026 19:15:46 +0200 Subject: [PATCH] Vollbildanzeige fuer den Raspberry Pi (800 x 480) index.php?action=anzeige: Uebersicht, Anlage und Speicher ohne Kopf und Seitenleiste, schmale Leiste links, immer quer. Nach zwei Minuten ohne Beruehrung zurueck zur Uebersicht, MQTT verbindet still neu (Punkt in der Leiste), alle sechs Stunden neu laden. Standard "ruhig" fuer den Pi 3B+ (nur Fluesse, grobe Stufen, kein Mauszeiger); &animation=1 und &maus=1. Dafuer: - Seitentabelle kennt "eigen" (Vorlage ist eine ganze Seite) - Energiefluss: Optionen hochkantUnter und klickFilter; Anlage: hochkantUnter - Bewaesserungszustand aus solarMQTT.js nach bewaesserungAnzeige.js, von Solar-Seite und Anzeige gemeinsam genutzt - Speicher: "voll/leer" mit Tag ("morgen 21:52", "in 3 Tagen") Co-Authored-By: Claude Opus 5 --- README.md | 9 ++ css/solar.css | 67 +++++++++++ index.php | 18 ++- js/solar/anlage.js | 5 +- js/solar/anzeige.js | 122 +++++++++++++++++++ js/solar/bewaesserungAnzeige.js | 206 ++++++++++++++++++++++++++++++++ js/solar/energiefluss.js | 14 ++- js/solar/solarMQTT.js | 169 -------------------------- js/solar/speicher.js | 13 +- restricted/anzeige.php | 60 ++++++++++ 10 files changed, 503 insertions(+), 180 deletions(-) create mode 100644 js/solar/anzeige.js create mode 100644 js/solar/bewaesserungAnzeige.js create mode 100644 restricted/anzeige.php diff --git a/README.md b/README.md index f8f2351..f177b21 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,15 @@ genau die Topics, die auf den Kacheln stehen (`tileTopics()` in `rooms.php`). | `settings` | `settings.php` | `settings.js`, `grundriss.js`, `energieflussKatalog.js`, `energiefluss.js`, `energieflussEinstellungen.js` | `settings.php?action=…` | — | | `logs` | `logs.php` | `logs.js` | `logs.php?action=…` | — | | `einfuehrung` | `einfuehrung.php` | `einfuehrung.js` | nichts — Folien und Bilder stehen in der Seite | — | +| `anzeige` | `anzeige.php` (ganze Seite, ohne `header.php`/`footer.php`) | `anlageKatalog.js`, `anlage.js`, `speicher.js`, `energieflussKatalog.js`, `energiefluss.js`, `bewaesserungAnzeige.js`, `anzeige.js` | `speicher` | `solarManager/#`, `Gartenwasser/#` | + +**Vollbildanzeige** (`?action=anzeige`) — Übersicht, Anlage und Speicher für ein +festes Display, gebaut für den Raspberry Pi 3B+ mit 800 × 480 im Kioskmodus: +schmale Leiste links, immer quer, nach zwei Minuten ohne Berührung zurück zur +Übersicht, MQTT verbindet still neu (Punkt unten: grün/gelb/rot), alle sechs +Stunden lädt die Seite neu. Ohne weitere Parameter läuft sie „ruhig“ — nur die +Flüsse bewegen sich, in groben Stufen — und ohne Mauszeiger; +`&animation=1` schaltet alle Animationen ein, `&maus=1` zeigt den Zeiger. Und dasselbe als Abhängigkeitsbild — von der Seite bis in den Speicher: diff --git a/css/solar.css b/css/solar.css index ce4ef43..556cce3 100644 --- a/css/solar.css +++ b/css/solar.css @@ -3868,6 +3868,73 @@ svg.anl-plan.anl-hoch { max-height: none; } .spk .spk-verlauf.neu .spk-linie { stroke-dashoffset: 0; } } +/* ========================================================================== + Vollbildanzeige (restricted/anzeige.php, js/solar/anzeige.js), z.B. am + Raspberry Pi mit 800 x 480: schmale Leiste links, Ansicht auf voller Höhe. + ========================================================================== */ +body.anzeige { + margin: 0; height: 100vh; overflow: hidden; + display: grid; grid-template-columns: 3.2rem minmax(0, 1fr); + background: var(--ton-grund, #191d20); color: var(--ton-schrift, #e8ede9); + font-family: "Poppins", system-ui, sans-serif; font-size: 14px; + -webkit-tap-highlight-color: transparent; user-select: none; +} +body.anzeige-ohne-maus, +body.anzeige-ohne-maus * { cursor: none !important; } +/* Die Seite lädt AdminLTE nicht - dessen Grundregeln fehlen hier. */ +body.anzeige [hidden] { display: none !important; } +body.anzeige *, body.anzeige *::before, body.anzeige *::after { box-sizing: border-box; } +.anzeige .anzeige-leiste { + display: flex; flex-direction: column; align-items: center; gap: .4rem; padding: .5rem 0; + background: var(--ton-flaeche, #23282c); border-right: 1px solid var(--ton-linie, rgba(138, 148, 158, .2)); +} +.anzeige .anzeige-leiste button { + all: unset; box-sizing: border-box; width: 2.6rem; height: 2.6rem; border-radius: .7rem; + display: grid; place-items: center; font-size: 1.3rem; color: var(--ton-schrift-2, #9aa6ad); +} +.anzeige .anzeige-leiste button[aria-pressed="true"] { background: var(--ton-flaeche-2, #2b3237); color: #f7c000; } +.anzeige .anzeige-fuss { margin-top: auto; display: grid; justify-items: center; gap: .3rem; } +.anzeige .anzeige-punkt { width: .6rem; height: .6rem; border-radius: 50%; background: #e5624f; } +.anzeige .anzeige-punkt.warn { background: #e8a33a; } +.anzeige .anzeige-punkt.gut { background: #6cbe58; } +.anzeige .anzeige-uhr { font-size: .72rem; font-weight: 600; color: var(--ton-schrift-2, #9aa6ad); font-variant-numeric: tabular-nums; } +.anzeige .anzeige-inhalt { min-width: 0; height: 100vh; } +.anzeige .anzeige-flaeche { box-sizing: border-box; height: 100vh; padding: .35rem; overflow-y: auto; scrollbar-width: none; } +.anzeige .anzeige-flaeche::-webkit-scrollbar { display: none; } +.anzeige .anzeige-flaeche[hidden] { display: none; } +.anzeige #anzeige-fluss { overflow: hidden; padding: .2rem; } +.anzeige #anzeige-fluss svg.enf { width: 100%; height: 100%; max-height: none; } +/* Anlage und Speicher: die Umbrüche der Solar-Seite richten sich nach der + Fensterbreite und würden am 800er-Display zu früh greifen. */ +.anzeige .anl svg.anl-plan { max-height: calc(100vh - 7.5rem); } +.anzeige .spk .spk-kennzahlen { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .35rem; } +.anzeige .spk .spk-kachel { padding: .4rem .55rem .45rem; } +.anzeige .spk .spk-kachel b { font-size: 1.05rem; } +.anzeige .spk .spk-kachel small, +.anzeige .spk .spk-kachel-titel { font-size: .66rem; } +.anzeige .spk .spk-mitte, +.anzeige .spk .spk-diagramme { grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: .5rem; } +.anzeige .spk .spk-fach { padding: .6rem .7rem; gap: .5rem; } +.anzeige .spk .spk-modul { grid-template-columns: 1.7rem minmax(0, 1fr) 5.4rem; gap: .4rem; padding: .3rem .45rem; } +.anzeige .spk .spk-zellen { height: 1.4rem; } +.anzeige .spk .spk-werte b { font-size: .68rem; } +.anzeige .spk .spk-werte small { font-size: .62rem; } +.anzeige .spk .spk-gross b { font-size: 1.4rem; } + +/* Ruhig (Vorgabe am Pi): alles Zierwerk aus, nur die Flüsse laufen - in + groben Stufen, damit ein Raspberry Pi 3 nicht dauernd neu zeichnet. */ +.anzeige-ruhig *, +.anzeige-ruhig *::after { animation: none !important; } +.anzeige-ruhig .enf .enf-bett, +.anzeige-ruhig .spk .spk-verlauf.neu .spk-linie { stroke-dashoffset: 0; } +.anzeige-ruhig .enf .enf-strom-hof { display: none; } +.anzeige-ruhig .enf .enf-strom { animation: enf-fliessen var(--dauer, 1.5s) steps(8) infinite !important; } +.anzeige-ruhig .enf .enf-rueck .enf-strom { animation-direction: reverse !important; } +.anzeige-ruhig .enf .enf-still .enf-strom, +.anzeige-ruhig .enf.enf-pause .enf-strom { animation: none !important; } +.anzeige-ruhig .enf .enf-strahlen, +.anzeige-ruhig .enf .enf-koerper { transition: none; } + /* --- Karte: Umschalter Übersicht / Anlage -------------------------------- */ .enf-umschalter { display: inline-flex; gap: 2px; padding: 3px; border-radius: var(--ton-radius-klein, .5rem); background: var(--ton-grund, #191d20); } .enf-umschalter .btn.btn-sm { border: 0; color: var(--ton-schrift-2, #9aa6ad); font-weight: 600; padding: .2rem .8rem; } diff --git a/index.php b/index.php index 79377ee..cb2da1a 100644 --- a/index.php +++ b/index.php @@ -101,7 +101,7 @@ if (isset($_GET["addUser"])) { $pages = [ "solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false, "scripts" => ["js/solar/jahresstatistik.js", "js/solar/anlageKatalog.js", "js/solar/anlage.js", "js/solar/speicher.js", - "js/solar/energieflussKatalog.js", "js/solar/energiefluss.js", "js/solar/solarMQTT.js"]], + "js/solar/energieflussKatalog.js", "js/solar/energiefluss.js", "js/solar/bewaesserungAnzeige.js", "js/solar/solarMQTT.js"]], "home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false, "scripts" => ["js/solar/autoActionFuncs.js", "js/solar/zeitleiste.js", "js/solar/homeMQTT.js"]], "heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false, @@ -120,6 +120,12 @@ if (isset($_GET["addUser"])) { "scripts" => ["js/solar/logs.js"]], "einfuehrung" => ["template" => "einfuehrung.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false, "scripts" => ["js/solar/einfuehrung.js"]], + // Vollbild für ein festes Display (Raspberry Pi). "eigen": die Vorlage + // ist eine ganze Seite, ohne header.php und footer.php. + "anzeige" => ["template" => "anzeige.php", "eigen" => true, "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false, + "scripts" => ["js/solar/anlageKatalog.js", "js/solar/anlage.js", "js/solar/speicher.js", + "js/solar/energieflussKatalog.js", "js/solar/energiefluss.js", + "js/solar/bewaesserungAnzeige.js", "js/solar/anzeige.js"]], ]; // Unbekannte oder fehlende Aktion faellt auf die Solar-Seite zurueck. @@ -128,9 +134,13 @@ if (isset($_GET["addUser"])) { } $page = $pages[$_GET["action"]]; - include "restricted/header.php"; - include "restricted/" . $page["template"]; - include "restricted/footer.php"; + if (!empty($page["eigen"])) { + include "restricted/" . $page["template"]; + } else { + include "restricted/header.php"; + include "restricted/" . $page["template"]; + include "restricted/footer.php"; + } } else { echo << diff --git a/js/solar/anlage.js b/js/solar/anlage.js index b4734b8..6a98d70 100644 --- a/js/solar/anlage.js +++ b/js/solar/anlage.js @@ -74,6 +74,7 @@ constructor(behaelter, opt = {}) { this.behaelter = behaelter; this.katalog = opt.katalog || ANLAGE_KATALOG; + this.hochkantUnter = opt.hochkantUnter ?? HOCHKANT_UNTER; // px; 0 = nie hochkant this.nummer = ++laufendeNummer; this.auswahl = null; // {art: "platte"|"string"|"wr", schluessel} this.verlauf = {}; // Eingang -> [[zeit, W], ...] @@ -87,7 +88,7 @@ this.beobachter = new ResizeObserver(() => { if (!this.behaelter.clientWidth) return; - if ((this.behaelter.clientWidth < HOCHKANT_UNTER) !== this.hochkant) this.zeichnen(); + if ((this.behaelter.clientWidth < this.hochkantUnter) !== this.hochkant) this.zeichnen(); else this.masstab(); }); this.beobachter.observe(behaelter); @@ -108,7 +109,7 @@ /* --------------------------------------------------------- Zeichnen */ zeichnen() { const breite = this.behaelter.clientWidth; - this.hochkant = breite > 0 && breite < HOCHKANT_UNTER; + this.hochkant = breite > 0 && breite < this.hochkantUnter; const id = "anl" + this.nummer; const k = this.katalog; diff --git a/js/solar/anzeige.js b/js/solar/anzeige.js new file mode 100644 index 0000000..efda909 --- /dev/null +++ b/js/solar/anzeige.js @@ -0,0 +1,122 @@ +/* ========================================================================== + Vollbildanzeige (restricted/anzeige.php): Übersicht, Anlage und Speicher + für ein festes Display, z.B. den Raspberry Pi mit 800 x 480. + + Dieselben Bausteine wie auf der Solar-Seite, nur anders verdrahtet: + - immer quer (hochkantUnter: 0), das Display steht quer + - antippbar sind nur Kreise, die eine andere Ansicht öffnen; Bedienfenster + (Heizstab, Wallbox) gibt es hier nicht + - nach LEERLAUF ohne Berührung zurück zur Übersicht + - MQTT verbindet nach einem Abbruch still neu; der Punkt unten in der + Leiste zeigt den Zustand (grün verbunden, gelb keine frischen Werte, + rot getrennt). Kein alert() - am Display ist niemand, der ihn wegklickt. + - alle NEU_LADEN Stunden lädt die Seite neu, damit ein Browser, der + wochenlang läuft, keinen Speicher ansammelt und Änderungen am Code + ankommen + ========================================================================== */ + +var mqttData = {}; + +(function () { + "use strict"; + + const ADRESSE = "wss://mqtt.nas.el-wa.org:443"; // wie in solarMQTT.js + const LEERLAUF = 2 * 60000; + const NEU_LADEN = 6 * 3600000; + const TAKT = typeof anzeigeRuhig !== "undefined" && anzeigeRuhig ? 2000 : 1000; + const VERALTET = 30000; + const THEMEN = [ + "solarManager/#", + "Gartenwasser/vorn/Timers", "Gartenwasser/vorn/status", "Gartenwasser/vorn/WateringToday", + "Gartenwasser/hinten/Timers", "Gartenwasser/hinten/status", "Gartenwasser/hinten/WateringToday", + ]; + + const $ = id => document.getElementById(id); + let bild = null, anlage = null, speicher = null; + let leerlaufUhr = null, geplant = false, verbunden = false, letzteDaten = 0; + + function zeige(name) { + document.querySelectorAll("[data-anzeige]").forEach(b => b.setAttribute("aria-pressed", b.dataset.anzeige === name ? "true" : "false")); + document.querySelectorAll("[data-anzeige-flaeche]").forEach(f => { f.hidden = f.dataset.anzeigeFlaeche !== name; }); + wachHalten(); + } + + function wachHalten() { + clearTimeout(leerlaufUhr); + leerlaufUhr = setTimeout(() => zeige("fluss"), LEERLAUF); + } + + function zustand() { + const punkt = $("anzeigeVerbindung"); + const frisch = Date.now() - letzteDaten < VERALTET; + const klasse = "anzeige-punkt" + (verbunden && frisch ? " gut" : verbunden ? " warn" : ""); + if (punkt.className !== klasse) { + punkt.className = klasse; + punkt.title = verbunden && frisch ? "verbunden" : verbunden ? "verbunden, aber keine frischen Werte" : "Verbindung getrennt – wird neu aufgebaut"; + } + } + + function uhr() { + const d = new Date(); + const text = String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); + if ($("anzeigeUhr").textContent !== text) $("anzeigeUhr").textContent = text; + } + + function aktualisieren() { + if (geplant) return; + geplant = true; + setTimeout(() => { + geplant = false; + bild.aktualisieren(mqttData); + anlage.aktualisieren(mqttData); + speicher.aktualisieren(mqttData); + }, TAKT); + } + + function verbinden() { + const client = mqtt.connect(ADRESSE, { rejectUnauthorized: false, reconnectPeriod: 5000, connectTimeout: 15000 }); + client.on("connect", () => { + verbunden = true; + client.subscribe(THEMEN); + zustand(); + }); + const weg = () => { verbunden = false; zustand(); }; + client.on("close", weg); + client.on("offline", weg); + client.on("error", () => {}); + client.on("message", (topic, nachricht) => { + mqttData[topic] = nachricht; + if (topic.endsWith("/Timers")) wateringTimersLastSeen[topic] = Date.now(); + if (topic === "solarManager/P_Load") { + letzteDaten = Date.now(); + aktualisieren(); + } + }); + } + + document.addEventListener("DOMContentLoaded", () => { + const bewaesserung = () => wateringSymboleSetzen(id => document.getElementById(id)); + + bild = new Energiefluss($("anzeige-fluss"), { + einstellungen: typeof energieflussEinstellungen !== "undefined" ? energieflussEinstellungen : {}, + hochkantUnter: 0, + klickFilter: k => !!k.klick.ansicht, + onKlick: k => zeige(k.klick.ansicht), + onGezeichnet: bewaesserung, + }); + bild.zeichnen(); + anlage = new Anlage($("anzeige-anlage"), { hochkantUnter: 0 }); + anlage.zeichnen(); + speicher = new Speicher($("anzeige-speicher")); + speicher.zeichnen(); + + document.querySelectorAll("[data-anzeige]").forEach(b => b.addEventListener("click", () => zeige(b.dataset.anzeige))); + document.addEventListener("pointerdown", wachHalten, { passive: true }); + + setInterval(() => { uhr(); zustand(); bewaesserung(); }, 1000); + uhr(); + zustand(); + verbinden(); + setTimeout(() => location.reload(), NEU_LADEN); + }); +})(); diff --git a/js/solar/bewaesserungAnzeige.js b/js/solar/bewaesserungAnzeige.js new file mode 100644 index 0000000..ad733cb --- /dev/null +++ b/js/solar/bewaesserungAnzeige.js @@ -0,0 +1,206 @@ +/* ========================================================================== + Zustand der Bewässerungszonen - geteilt von der Solar-Seite (solarMQTT.js, + dort zusätzlich das Modal) und der Vollbildanzeige (anzeige.js). + + Liest aus dem globalen mqttData, das die jeweilige Seite füllt: + Gartenwasser//Timers, /status, /WateringToday. Wer die + Timers-Nachricht empfängt, trägt die Uhrzeit in wateringTimersLastSeen ein + - daran hängt, ob eine Zone als laufend gilt. + + Bis September 2026 stand das alles in solarMQTT.js. + ========================================================================== */ + +const wateringTimersLastSeen = {}; +// Eine Tabelle fuer alles: Ventil, Beschriftung und die Anzeige-Ids im +// Realtime-SVG. Vorher standen dieselben Zuordnungen in drei getrennten +// Tabellen. +const wateringZones = { + troughs: { + topic: "Gartenwasser/vorn", valve: 1, label: "Tröge", + statusId: "wateringTroughStatus", iconId: "wateringTroughIcon" + }, + frontGarden: { + topic: "Gartenwasser/vorn", valve: 6, label: "Vorn", + statusId: "wateringFrontStatus", iconId: "wateringFrontIcon" + }, + raisedBeds: { + topic: "Gartenwasser/hinten", valve: 6, label: "Beete", + statusId: "wateringBedsStatus", iconId: "wateringBedsIcon" + } +}; +// Feste Liste - der Sekundentakt soll sie nicht jedes Mal neu aufbauen. +const wateringZoneList = Object.entries(wateringZones); + +// Die Anzeige laeuft im Sekundentakt, die Nutzlasten aendern sich aber nur, +// wenn eine neue MQTT-Nachricht kommt. messageReceived legt dabei jedes Mal +// ein neues Buffer-Objekt ab - der Identitaetsvergleich reicht also als +// Gueltigkeitspruefung, ein Invalidieren von Hand braucht es nicht. +const wateringJsonCache = {}; +const wateringDecoder = new TextDecoder(); + +function wateringJson(topic) { + const value = mqttData[topic]; + if (!value) return null; + const cached = wateringJsonCache[topic]; + if (cached && cached.raw === value) return cached.parsed; + + let parsed = null; + try { + parsed = JSON.parse(typeof value === "string" ? value : wateringDecoder.decode(value)); + } catch (error) { + parsed = null; + } + wateringJsonCache[topic] = { raw: value, parsed: parsed }; + return parsed; +} + +function wateringHistory(topic, valve) { + const data = wateringJson(topic + "/LastWatering"); + if (!data) return { dateTime: "--", duration: "--" }; + return { + dateTime: data["wateringDateTime" + valve] || "--", + duration: data["wateringDurationSecs" + valve] !== undefined + ? wateringTimeLabel(Number(data["wateringDurationSecs" + valve])) + : "--" + }; +} + +// Tagesdatum im Format der Ventilsteuerung ("29.08.26"). +function wateringDayStamp(date) { + const zwei = (zahl) => String(zahl).padStart(2, "0"); + return zwei(date.getDate()) + "." + zwei(date.getMonth() + 1) + "." + zwei(date.getFullYear() % 100); +} + +// Tagessummen der Ventilsteuerung: WateringToday fuer den laufenden Tag, +// LastWateringDay fuer den letzten Tag, an dem ueberhaupt bewaessert wurde. +// Beide Nutzlasten tragen den zugehoerigen Tag als "wateringDay" mit. Bei +// WateringToday wird der geprueft - die Nachricht ist retained, nach einem +// Tageswechsel ohne Bewaesserung stuende sonst die Summe von gestern als +// "heute" in der Karte. +function wateringDayTotal(topic, subTopic, feld, valve, mussHeuteSein) { + const data = wateringJson(topic + "/" + subTopic); + if (!data) return { label: "--", day: "", sekunden: null }; + const tag = data.wateringDay || ""; + const sekunden = data[feld + valve]; + if (sekunden === undefined) return { label: "--", day: tag, sekunden: null }; + if (mussHeuteSein && tag !== wateringDayStamp(new Date())) { + return { label: "--", day: tag, sekunden: null }; + } + return { label: wateringTimeLabel(Number(sekunden)), day: tag, sekunden: Number(sekunden) }; +} + +function wateringAutoActive(topic, mode) { + const timers = wateringJson(topic + "/Timers"); + return !!(timers && timers.auto === mode); +} + +function wateringValue(data, valve) { + if (!data) return null; + const candidates = [ + data.timers && data.timers[valve], + data.timers && data.timers[String(valve)], + data[valve], + data[String(valve)], + data.valves && data.valves[valve], + data.valves && data.valves[String(valve)], + data.status && data.status[valve], + data.status && data.status[String(valve)], + data.valveState && data.valveState[valve], + data.valveState && data.valveState[String(valve)], + data.setValveState && data.setValveState[valve], + data.setValveState && data.setValveState[String(valve)] + ]; + for (const candidate of candidates) { + if (candidate !== undefined) return candidate; + } + return null; +} + +function wateringState(value) { + if (value && typeof value === "object") { + return value.state ?? value.status ?? value.valveState ?? value.set; + } + return value; +} + +function wateringSeconds(value) { + if (value && typeof value === "object") { + value = value.remaining ?? value.timer ?? value.seconds ?? value.time; + } + return Number(value); +} + +function wateringLabel(topic, valve) { + const timers = wateringJson(topic + "/Timers"); + const status = wateringJson(topic + "/status"); + const lastSeen = wateringTimersLastSeen[topic + "/Timers"]; + if (!lastSeen || Date.now() - lastSeen > 3000) return "aus"; + const remaining = wateringValue(timers, valve); + if (remaining !== null) { + const seconds = wateringSeconds(remaining); + if (seconds !== 0) return wateringTimeLabel(Math.abs(seconds)); + return "aus"; + } + const state = wateringState(wateringValue(status, valve)); + if (state === true || state === 1 || state === "1" || state === "open" || state === "OPEN") return "an"; + if (state === false || state === 0 || state === "0" || state === "close" || state === "closed" || state === "CLOSED") return "aus"; + return "--"; +} + +function wateringTimeLabel(seconds) { + if (seconds < 60) return seconds + "s"; + return Math.ceil(seconds / 60) + "m"; +} + +function wateringIsPaused(topic, valve) { + const timers = wateringJson(topic + "/Timers"); + const remaining = wateringValue(timers, valve); + return remaining !== null && wateringSeconds(remaining) < 0; +} + +function updateWateringIndicator(status, icon, name, state, paused, bewaessert) { + // "Aus" bleibt stehen, solange die Zone heute noch kein Wasser hatte; + // danach ersetzt das Haekchen (U+2713, ohne Emoji-Variationsselektor -> + // einfarbig, folgt der Icon-Farbe) den Text, um Platz auf der textPath zu + // sparen. Laeuft im Sekundentakt, aendert sich aber selten - ohne Pruefung + // wuerde jede Sekunde derselbe Wert geschrieben und das Icon samt + // Filter neu gezeichnet. + const text = state === "aus" ? (bewaessert ? "✓" : "Aus") : state; + if (status.textContent !== text) status.textContent = text; + // Das Blau ("#3A6292") entspricht dem Wassertropfen-Icon (id="water"). + const color = state !== "aus" ? (paused ? "#ffc107" : "#20c997") : bewaessert ? "#3A6292" : "#6c757d"; + if (icon.getAttribute("color") !== color) { + icon.setAttribute("color", color); + icon.setAttribute("fill", color); + icon.style.color = color; + } + const titel = name + ": " + (state === "aus" && bewaessert ? "Aus (heute bereits bewässert)" : state); + const titelKnoten = icon.querySelector("title"); + if (titelKnoten.textContent !== titel) titelKnoten.textContent = titel; +} + +/** + * Zustand einer Zone aus den MQTT-Werten. + * + * Die Regenautomatik laeuft zum Sonnenaufgang; wenn jemand auf die Anzeige + * schaut, ist die Entscheidung laengst gefallen. Angezeigt wird deshalb nicht, + * was geplant ist, sondern was passiert ist. Der Zaehler unterscheidet nicht, + * wer ausgeloest hat - eine Bewaesserung von Hand zaehlt genauso. + */ +function wateringZonenZustand(zone) { + const state = wateringLabel(zone.topic, zone.valve); + const paused = wateringIsPaused(zone.topic, zone.valve); + const heute = wateringDayTotal(zone.topic, "WateringToday", "wateringDurationTodaySecs", zone.valve, true); + return { state, paused, heute, bewaessert: state === "aus" && heute.sekunden > 0 }; +} + +/** Symbol und Text jeder Zone setzen; finde(id) liefert das Element. */ +function wateringSymboleSetzen(finde) { + wateringZoneList.forEach(([, zone]) => { + const status = finde(zone.statusId); + const icon = finde(zone.iconId); + if (!status || !icon) return; + const z = wateringZonenZustand(zone); + updateWateringIndicator(status, icon, zone.label, z.state, z.paused, z.bewaessert); + }); +} diff --git a/js/solar/energiefluss.js b/js/solar/energiefluss.js index 95e7eac..8a111be 100644 --- a/js/solar/energiefluss.js +++ b/js/solar/energiefluss.js @@ -8,6 +8,8 @@ onKlick(knoten), // Kreis mit klick-Ziel angetippt onVerschoben(id, x, y), // nur beim Bearbeiten onGezeichnet(), // nach jedem (Neu-)Zeichnen + klickFilter(knoten), // optional: welche Kreise antippbar sind + hochkantUnter: 720, // optional: px Breite, darunter hochkant (0 = nie) }); bild.aktualisieren(mqttData); @@ -220,8 +222,14 @@ } } + /** Antippbar: hat ein Ziel, nicht im Bearbeiten, und opt.klickFilter lässt es zu. */ + klickbar(k) { + return !!k.klick && !this.bearbeiten && (!this.opt.klickFilter || this.opt.klickFilter(k)); + } + hochkantGewuenscht() { - return !this.bearbeiten && this.behaelter.clientWidth > 0 && this.behaelter.clientWidth < HOCHKANT_UNTER; + const grenze = this.opt.hochkantUnter ?? HOCHKANT_UNTER; + return !this.bearbeiten && this.behaelter.clientWidth > 0 && this.behaelter.clientWidth < grenze; } /** Lage eines Kreises in der aktuellen Ansicht. */ @@ -280,7 +288,7 @@ const r = this.radius(k); const id = "enf" + this.nummer + "-" + k.id; const g = el("g", { - class: "enf-knoten" + (k.aktiv ? "" : " enf-aus") + ((k.klick && !this.bearbeiten) ? " enf-klickbar" : ""), + class: "enf-knoten" + (k.aktiv ? "" : " enf-aus") + (this.klickbar(k) ? " enf-klickbar" : ""), transform: `translate(${x} ${y})`, style: `--farbe:${k.farbe};--verz:${k.tiefe * 110}ms`, "data-knoten": k.id, @@ -319,7 +327,7 @@ const zeichen = el("g", { class: "enf-zeichen" }, koerper); - if (k.klick && !this.bearbeiten) { + if (this.klickbar(k)) { const t = el("g", { class: "enf-tipp", transform: `translate(${r * 0.72} ${-r * 0.72})` }, koerper); el("circle", { r: 15 }, t); el("path", { d: "M-3.5-6l6 6-6 6" }, t); diff --git a/js/solar/solarMQTT.js b/js/solar/solarMQTT.js index 88e3a25..52c5882 100644 --- a/js/solar/solarMQTT.js +++ b/js/solar/solarMQTT.js @@ -565,27 +565,6 @@ var chartSettings = { }); } - const wateringTimersLastSeen = {}; - // Eine Tabelle fuer alles: Ventil, Beschriftung und die Anzeige-Ids im - // Realtime-SVG. Vorher standen dieselben Zuordnungen in drei getrennten - // Tabellen. - const wateringZones = { - troughs: { - topic: "Gartenwasser/vorn", valve: 1, label: "Tröge", - statusId: "wateringTroughStatus", iconId: "wateringTroughIcon" - }, - frontGarden: { - topic: "Gartenwasser/vorn", valve: 6, label: "Vorn", - statusId: "wateringFrontStatus", iconId: "wateringFrontIcon" - }, - raisedBeds: { - topic: "Gartenwasser/hinten", valve: 6, label: "Beete", - statusId: "wateringBedsStatus", iconId: "wateringBedsIcon" - } - }; - // Feste Liste - der Sekundentakt soll sie nicht jedes Mal neu aufbauen. - const wateringZoneList = Object.entries(wateringZones); - function wateringCommand(topic, command, directTopic = false) { const feedback = document.getElementById("watering-feedback"); const targetTopic = directTopic ? topic : topic + "/set"; @@ -643,133 +622,6 @@ var chartSettings = { updateWateringStatus(); } - // Die Anzeige laeuft im Sekundentakt, die Nutzlasten aendern sich aber nur, - // wenn eine neue MQTT-Nachricht kommt. messageReceived legt dabei jedes Mal - // ein neues Buffer-Objekt ab - der Identitaetsvergleich reicht also als - // Gueltigkeitspruefung, ein Invalidieren von Hand braucht es nicht. - const wateringJsonCache = {}; - const wateringDecoder = new TextDecoder(); - - function wateringJson(topic) { - const value = mqttData[topic]; - if (!value) return null; - const cached = wateringJsonCache[topic]; - if (cached && cached.raw === value) return cached.parsed; - - let parsed = null; - try { - parsed = JSON.parse(typeof value === "string" ? value : wateringDecoder.decode(value)); - } catch (error) { - parsed = null; - } - wateringJsonCache[topic] = { raw: value, parsed: parsed }; - return parsed; - } - - function wateringHistory(topic, valve) { - const data = wateringJson(topic + "/LastWatering"); - if (!data) return { dateTime: "--", duration: "--" }; - return { - dateTime: data["wateringDateTime" + valve] || "--", - duration: data["wateringDurationSecs" + valve] !== undefined - ? wateringTimeLabel(Number(data["wateringDurationSecs" + valve])) - : "--" - }; - } - - // Tagesdatum im Format der Ventilsteuerung ("29.08.26"). - function wateringDayStamp(date) { - const zwei = (zahl) => String(zahl).padStart(2, "0"); - return zwei(date.getDate()) + "." + zwei(date.getMonth() + 1) + "." + zwei(date.getFullYear() % 100); - } - - // Tagessummen der Ventilsteuerung: WateringToday fuer den laufenden Tag, - // LastWateringDay fuer den letzten Tag, an dem ueberhaupt bewaessert wurde. - // Beide Nutzlasten tragen den zugehoerigen Tag als "wateringDay" mit. Bei - // WateringToday wird der geprueft - die Nachricht ist retained, nach einem - // Tageswechsel ohne Bewaesserung stuende sonst die Summe von gestern als - // "heute" in der Karte. - function wateringDayTotal(topic, subTopic, feld, valve, mussHeuteSein) { - const data = wateringJson(topic + "/" + subTopic); - if (!data) return { label: "--", day: "", sekunden: null }; - const tag = data.wateringDay || ""; - const sekunden = data[feld + valve]; - if (sekunden === undefined) return { label: "--", day: tag, sekunden: null }; - if (mussHeuteSein && tag !== wateringDayStamp(new Date())) { - return { label: "--", day: tag, sekunden: null }; - } - return { label: wateringTimeLabel(Number(sekunden)), day: tag, sekunden: Number(sekunden) }; - } - - function wateringAutoActive(topic, mode) { - const timers = wateringJson(topic + "/Timers"); - return !!(timers && timers.auto === mode); - } - - function wateringValue(data, valve) { - if (!data) return null; - const candidates = [ - data.timers && data.timers[valve], - data.timers && data.timers[String(valve)], - data[valve], - data[String(valve)], - data.valves && data.valves[valve], - data.valves && data.valves[String(valve)], - data.status && data.status[valve], - data.status && data.status[String(valve)], - data.valveState && data.valveState[valve], - data.valveState && data.valveState[String(valve)], - data.setValveState && data.setValveState[valve], - data.setValveState && data.setValveState[String(valve)] - ]; - for (const candidate of candidates) { - if (candidate !== undefined) return candidate; - } - return null; - } - - function wateringState(value) { - if (value && typeof value === "object") { - return value.state ?? value.status ?? value.valveState ?? value.set; - } - return value; - } - - function wateringSeconds(value) { - if (value && typeof value === "object") { - value = value.remaining ?? value.timer ?? value.seconds ?? value.time; - } - return Number(value); - } - - function wateringLabel(topic, valve) { - const timers = wateringJson(topic + "/Timers"); - const status = wateringJson(topic + "/status"); - const lastSeen = wateringTimersLastSeen[topic + "/Timers"]; - if (!lastSeen || Date.now() - lastSeen > 3000) return "aus"; - const remaining = wateringValue(timers, valve); - if (remaining !== null) { - const seconds = wateringSeconds(remaining); - if (seconds !== 0) return wateringTimeLabel(Math.abs(seconds)); - return "aus"; - } - const state = wateringState(wateringValue(status, valve)); - if (state === true || state === 1 || state === "1" || state === "open" || state === "OPEN") return "an"; - if (state === false || state === 0 || state === "0" || state === "close" || state === "closed" || state === "CLOSED") return "aus"; - return "--"; - } - - function wateringTimeLabel(seconds) { - if (seconds < 60) return seconds + "s"; - return Math.ceil(seconds / 60) + "m"; - } - - function wateringIsPaused(topic, valve) { - const timers = wateringJson(topic + "/Timers"); - const remaining = wateringValue(timers, valve); - return remaining !== null && wateringSeconds(remaining) < 0; - } - function updateWateringStatus() { wateringZoneList.forEach(([key, zone]) => { const state = wateringLabel(zone.topic, zone.valve); @@ -855,27 +707,6 @@ var chartSettings = { button.classList.toggle("btn-outline-danger", active); } - function updateWateringIndicator(status, icon, name, state, paused, bewaessert) { - // "Aus" bleibt stehen, solange die Zone heute noch kein Wasser hatte; - // danach ersetzt das Haekchen (U+2713, ohne Emoji-Variationsselektor -> - // einfarbig, folgt der Icon-Farbe) den Text, um Platz auf der textPath zu - // sparen. Laeuft im Sekundentakt, aendert sich aber selten - ohne Pruefung - // wuerde jede Sekunde derselbe Wert geschrieben und das Icon samt - // Filter neu gezeichnet. - const text = state === "aus" ? (bewaessert ? "✓" : "Aus") : state; - if (status.textContent !== text) status.textContent = text; - // Das Blau ("#3A6292") entspricht dem Wassertropfen-Icon (id="water"). - const color = state !== "aus" ? (paused ? "#ffc107" : "#20c997") : bewaessert ? "#3A6292" : "#6c757d"; - if (icon.getAttribute("color") !== color) { - icon.setAttribute("color", color); - icon.setAttribute("fill", color); - icon.style.color = color; - } - const titel = name + ": " + (state === "aus" && bewaessert ? "Aus (heute bereits bewässert)" : state); - const titelKnoten = icon.querySelector("title"); - if (titelKnoten.textContent !== titel) titelKnoten.textContent = titel; - } - window.setInterval(updateWateringStatus, 1000); diff --git a/js/solar/speicher.js b/js/solar/speicher.js index 3f8278d..74496fb 100644 --- a/js/solar/speicher.js +++ b/js/solar/speicher.js @@ -34,6 +34,15 @@ const uhrzeit = d => String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); const schutz = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); + /** Zeitpunkt in h Stunden: "um 17:40", "morgen 06:10" oder "in 3 Tagen". */ + function wann(h) { + const ziel = new Date(Date.now() + h * 3600000); + const tage = Math.round((new Date(ziel).setHours(0, 0, 0, 0) - new Date().setHours(0, 0, 0, 0)) / 86400000); + if (tage <= 0) return "um " + uhrzeit(ziel); + if (tage === 1) return "morgen " + uhrzeit(ziel); + return "in " + tage + " Tagen"; + } + function leser(daten) { return { da(t) { return daten[t] !== undefined; }, @@ -219,10 +228,10 @@ let prognose = "in Ruhe"; if (p > 50 && kwh) { const h = kwh * (100 - soc) / 100 / (p / 1000); - prognose = soc >= 99.5 ? "voll" : "voll um " + uhrzeit(new Date(Date.now() + h * 3600000)); + prognose = soc >= 99.5 ? "voll" : "voll " + wann(h); } else if (p < -50 && kwh) { const h = kwh * soc / 100 / (-p / 1000); - prognose = "leer um " + uhrzeit(new Date(Date.now() + h * 3600000)); + prognose = "leer " + wann(h); } if (p > 50 && s.grenze > 0 && s.grenze < 100) prognose += " · begrenzt auf " + Math.round(s.grenze) + " %"; diff --git a/restricted/anzeige.php b/restricted/anzeige.php new file mode 100644 index 0000000..a20f7fe --- /dev/null +++ b/restricted/anzeige.php @@ -0,0 +1,60 @@ + + + + + + + Energiefluss + + + + " /> + +"> + +
+
+ + +
+ + + + + + +