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 + + + + " /> + +"> + +
+
+ + +
+ + + + + + +