diff --git a/README.md b/README.md index 555aeab..e622118 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ genau die Topics, die auf den Kacheln stehen (`tileTopics()` in `rooms.php`). | Seite (`?action=`) | Vorlage | Seitenskripte | holt per fetch | hört auf MQTT | |---|---|---|---|---| -| `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `anlageKatalog.js`, `anlage.js`, `speicher.js`, `energieflussKatalog.js`, `energiefluss.js`, `bewaesserungAnzeige.js`, `ladefenster.js`, `solarMQTT.js` | `speicher`, `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` | +| `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `anlageKatalog.js`, `anlage.js`, `speicher.js`, `energieflussKatalog.js`, `energiefluss.js`, `bewaesserungAnzeige.js`, `ladefenster.js`, `bedienfenster.js`, `solarMQTT.js` | `speicher`, `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` | | `home` | `home.php` | `autoActionFuncs.js`, `zeitleiste.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` | | `heat` | `heat.php` | `heatMQTT.js` | `getHeaterData`, `getWaterData`, `getSunrise` | `solarManager/#`, `weatherStation/#`, Wallbox-Topics | | `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — | @@ -296,7 +296,6 @@ eine Bibliothek es vorgibt. |---|---| | `powerToString(w)` | Leistung als `W` oder `kW`, dieselbe Rundung überall | | `getData(chart, url, opt)` | Diagrammdaten holen, eintragen, alle 5 min auffrischen; optional Sonnenauf-/-untergang aus `getSunrise.php` dazu | -| `submitFormAjax(form)` | Formular abschicken, ohne die Seite zu verlassen | | `bindModalSlider()` | Schieberegler im Modal an ihre Anzeige koppeln | | `mountMeteogram()`, `updateWeatherCards()` | Wetterteile der Solar- und Wetterseite | | `reiterMerken()` | offener Reiter steht in der Adresse und übersteht ein Neuladen | @@ -433,7 +432,7 @@ klein: Diagramme füllen bzw. Logzeilen nachladen und einfärben. | `skodaCmd.php` | `POST befehl=…` | Laden/Klima/Lüftung am Fahrzeug | | `tile.php` | `?z=&x=&y=` | Kartenkachel aus dem eigenen Zwischenspeicher, sonst einmalig von OSM | | `logs.php` | `?action=list\|read` | Zustand und Zeilen der Logdateien | -| `carEG.php` `carOG.php` `carSteuerung.php` `carForm.php` `heater.php` `watering.php` `tahoma.php` | | Wallboxen, Heizstab, Bewässerung, Tahoma-Bedienung. Das Ladefenster (`carForm.php` + `js/solar/ladefenster.js`) liest live aus MQTT und schreibt jede Änderung sofort als JSON an `carSteuerung.php` (Aktionen `frc`, `modus`, `strom`, `plan`) | +| `carEG.php` `carOG.php` `carSteuerung.php` `carForm.php` `heater.php` `watering.php` `tahoma.php` | | Wallboxen, Heizstab, Bewässerung, Tahoma-Bedienung. Das Ladefenster (`carForm.php` + `js/solar/ladefenster.js`) liest live aus MQTT und schreibt jede Änderung sofort als JSON an `carSteuerung.php` (Aktionen `frc`, `modus`, `strom`, `plan`). Heizstab- und Bewässerungsfenster (`heater.php`, `watering.php` + `js/solar/bedienfenster.js`) arbeiten genauso: Zustand live aus MQTT, jede Wahl gilt sofort | | `chartData.php` | (kein Endpunkt) | gemeinsame Bausteine der Chart-Endpunkte | | `phpMQTT.php` | (Bibliothek) | MQTT aus PHP heraus, für Kommandos | diff --git a/ajax/heater.php b/ajax/heater.php index 884b247..a03d9a0 100644 --- a/ajax/heater.php +++ b/ajax/heater.php @@ -1,66 +1,108 @@ = 1 && $stufe <= 60) { - $pfad = "/set?mode=man&pwr=" . ($stufe * 100); - } elseif ($stufe === 0) { - $pfad = "/set?mode=eco&pwr=0"; - } else { - $pfad = null; - } - if ($pfad !== null) { - $remote = @fsockopen("tcp://" . $hostname, 80, $errno, $errstr, 5); - if ($remote) { - fwrite($remote, "GET " . $pfad . " HTTP/1.1\r\nHost: " . $hostname . "\r\nConnection: close\r\n\r\n"); - stream_set_timeout($remote, 5); - fread($remote, 1024); - fclose($remote); - } else { - // Ohne Verbindung eine verstaendliche Meldung statt eines PHP-Fehlers. - // Bewusst Status 200: submitFormAjax() in common.js zeigt die - // Antwort nur dann als Meldung an. - echo "Der Heizstab ist nicht erreichbar."; - } - } - $close = 1; +if (isset($_POST["setMode"]) || isset($_GET["setMode"])) { + // Der Wert kommt aus dem Fenster (POST) oder von Hand aus der Adresse + // (GET) - immer nur einer von beiden. + $stufe = intval($_POST["setMode"] ?? $_GET["setMode"] ?? 0); + if ($stufe >= 1 && $stufe <= 60) { + $pfad = "/set?mode=man&pwr=" . ($stufe * 100); + } elseif ($stufe === 0) { + $pfad = "/set?mode=eco&pwr=0"; + } else { + echo "Unbekannte Leistungsstufe."; + exit; } - } else { - $close = 1; -} -if (!$close) { - echo << -
-
-
- - - -
- Auto - 6.0 kW -
-
-
-
-ENDE; + $remote = @fsockopen("tcp://" . $hostname, 80, $errno, $errstr, 5); + if ($remote) { + fwrite($remote, "GET " . $pfad . " HTTP/1.1\r\nHost: " . $hostname . "\r\nConnection: close\r\n\r\n"); + stream_set_timeout($remote, 5); + fread($remote, 1024); + fclose($remote); + } else { + echo "Der Heizstab ist nicht erreichbar."; + } + exit; } +?> +
+ + +
+
+

Wie heizen?

+
+ + +
+
+ + + +
Änderungen gelten sofort
+
+
diff --git a/ajax/watering.php b/ajax/watering.php index 65d00bf..f84313c 100644 --- a/ajax/watering.php +++ b/ajax/watering.php @@ -73,64 +73,62 @@ if (!checkLogin()) { exit; } -// Zonen des Modals. Die Reihenfolge bestimmt die Reihenfolge der Karten, die -// Keys muessen zu wateringZones in js/solar/solarMQTT.js passen. +// Zonen des Fensters. Die Reihenfolge bestimmt die Reihenfolge der Karten; +// die Keys muessen zu wateringZones in js/solar/bewaesserungAnzeige.js passen, +// "bild" zu ZONENBILDER in js/solar/energiefluss.js. "dauern" sind die +// Schnellwahlen in Minuten, die erste ist die Vorgabe. $zonen = [ - ["key" => "troughs", "label" => "Tröge", "minuten" => 5], - ["key" => "raisedBeds", "label" => "Beete", "minuten" => 30], - ["key" => "frontGarden", "label" => "Vorn", "minuten" => 30], + ["key" => "troughs", "label" => "Tröge", "bild" => "kuebel", "dauern" => [5, 10, 15, 30]], + ["key" => "raisedBeds", "label" => "Beete", "bild" => "hochbeet", "dauern" => [30, 15, 45, 60]], + ["key" => "frontGarden", "label" => "Vorn", "bild" => "garten", "dauern" => [30, 15, 45, 60]], ]; -echo << -
- -
-
-
Automatik
-
- -
- - - +// Die Abläufe der Ventilsteuerung (Topic .../auto). Mehrere Einträge in +// "modi" starten bzw. stoppen gemeinsam. +$ablaeufe = [ + ["label" => "Tröge + Beete", "modi" => ["Gartenwasser/vorn" => "Trog", "Gartenwasser/hinten" => "Hoch"]], + ["label" => "Tröge", "modi" => ["Gartenwasser/vorn" => "Trog"]], + ["label" => "Beete", "modi" => ["Gartenwasser/hinten" => "Hoch"]], + ["label" => "Vorn", "modi" => ["Gartenwasser/vorn" => "Vorn"]], +]; +?> +
+
+ +
" data-dauer=""> +
+ " aria-hidden="true"> +
+ +
+
-
- Startet die Ventilfolge von Hand. Die Regenautomatik läuft davon - unabhängig zum Sonnenaufgang; ob eine Zone dabei schon versorgt - wurde, steht unten bei „Heute“. -
-
-
-HTML; - -foreach ($zonen as $zone) { - $key = $zone["key"]; - $label = $zone["label"]; - $minuten = $zone["minuten"]; - echo << -
-
- $label - unbekannt -
-
-
- - Min. +
+
"> + + +
- -
-
- Zuletzt -- · --
- Heute -- · Vortag -- +
+
+
-HTML; -} -echo "\n
\n"; +
+

Abläufe

+
+ + + +
+
Ein Ablauf gießt die Zonen nacheinander – antippen startet, nochmal antippen stoppt. Die Regenautomatik läuft davon unabhängig zum Sonnenaufgang.
+
+ +
Befehle gehen sofort an die Ventilsteuerung
+
diff --git a/css/solar.css b/css/solar.css index 86eca54..bac42ec 100644 --- a/css/solar.css +++ b/css/solar.css @@ -4047,6 +4047,51 @@ body.anzeige-ohne-maus * { cursor: none !important; } .lade .lade-meldung.fehler { color: #e5624f; } @media (max-width: 420px) { .lade .lade-modus > span:last-child { display: none; } } +.lade .lade-pille.warn { color: #e8a33a; background: rgba(232, 163, 58, .14); } +.lade .lade-pille.blau { color: #6fb3e8; background: rgba(75, 159, 214, .15); } +.lade .lade-modi.zwei { grid-template-columns: repeat(2, minmax(0, 1fr)); } + +/* --- Heizstab (ajax/heater.php, bedienfenster.js) --- */ +/* Akzent: Regler und gewählte Chips im Rot der Karte "Fest" statt Türkis. */ +.lade.heiz { --lade-wallbox: #e5624f; } +.lade.heiz .lade-status { grid-template-columns: 5.5rem minmax(0, 1fr); } +.lade .heiz-tank { width: 5.5rem; } +.lade .heiz-tank svg { width: 100%; height: auto; display: block; } +.lade .heiz-tank rect[data-heiz] { fill: #2b3237; transition: fill 1.5s; } +.lade .heiz-huelle { fill: none; stroke: #9aa6ad; stroke-width: 2.5; stroke-linecap: round; } +.lade .heiz-spirale { fill: none; stroke: rgba(255, 255, 255, .35); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; transition: stroke .4s; } +.lade .heiz-spirale.an { stroke: #ffb04a; animation: lade-gluehen 1.1s ease-in-out infinite alternate; } +@keyframes lade-gluehen { to { stroke: #ff5a2a; } } +.lade .heiz-tank text { fill: #fff; font-size: 13px; font-weight: 600; text-anchor: middle; paint-order: stroke; stroke: rgba(0, 0, 0, .35); stroke-width: 3px; } +.lade .heiz-tank text.klein { font-size: 11px; } +.lade .heiz-regler { --ist: 0%; } + +/* --- Bewässerung (ajax/watering.php, bedienfenster.js) --- */ +.lade.wasser { gap: .8rem; --lade-wallbox: #4b9fd6; } +.lade .wasser-zonen { display: grid; gap: .55rem; } +.lade .wasser-zone { + display: grid; gap: .5rem; padding: .65rem .75rem; border-radius: .8rem; background: var(--ton-flaeche-2, #2b3237); + border: 1.5px solid transparent; transition: border-color .3s; +} +.lade .wasser-zone.laeuft { border-color: #20c997; } +.lade .wasser-kopf { display: flex; align-items: center; gap: .6rem; } +.lade .wasser-bild { width: 2.1rem; height: 2.1rem; flex: 0 0 auto; display: grid; place-items: center; border-radius: .6rem; background: rgba(75, 159, 214, .14); color: #6fb3e8; } +.lade .wasser-zone.laeuft .wasser-bild { background: rgba(32, 201, 151, .16); color: #20c997; } +.lade .wasser-bild svg { width: 1.35rem; height: 1.35rem; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.lade .wasser-titel { display: grid; min-width: 0; margin-right: auto; } +.lade .wasser-titel b { font-weight: 600; } +.lade .wasser-titel span { font-size: .78rem; color: var(--ton-schrift-2, #9aa6ad); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.lade .wasser-fuss { display: flex; flex-wrap: wrap; align-items: center; gap: .45rem; } +.lade .wasser-fuss .lade-chips { flex: 1 1 auto; } +.lade .wasser-fuss .lade-chip { padding: .3rem .6rem; font-size: .76rem; } +.lade .lade-start.wasser-knopf { height: 2.4rem; padding: 0 1rem; font-size: .9rem; flex: 0 0 auto; background: linear-gradient(180deg, #4b9fd6, #3b88bf); color: #06121b; box-shadow: 0 .3rem .9rem rgba(75, 159, 214, .22); } +.lade .lade-start.wasser-knopf .lade-punkt { display: none; } +.lade .lade-start.wasser-knopf.laeuft { background: linear-gradient(180deg, #3a4147, #30363b); color: var(--ton-schrift, #e8ede9); box-shadow: none; } +.lade .lade-start.wasser-knopf.laeuft .lade-punkt { display: block; background: #20c997; } +.lade .wasser-info { font-size: .72rem; color: var(--ton-schrift-3, #6f7a81); } +.lade .wasser-info:empty { display: none; } +.lade .wasser-ablaeufe .lade-chip[aria-pressed="true"] { background: #20c997; color: #06150f; } + /* Am 800er-Display zweispaltig ohne Scrollen. */ .anzeige #modalEV .modal-dialog:has(.lade) { max-width: 46rem; } .anzeige .lade { grid-template-columns: 15rem minmax(0, 1fr); gap: 1rem; padding: .7rem .9rem; } @@ -4058,6 +4103,12 @@ body.anzeige-ohne-maus * { cursor: none !important; } .anzeige #modalEV .modal-body:has(.lade) { max-height: none; overflow: visible; } .anzeige #modalEV .modal-content:has(.lade) .modal-footer { display: none; } .anzeige #modalEV .modal-content:has(.lade) .modal-header { padding: .45rem .9rem; } +.anzeige .lade.heiz .lade-status { grid-template-columns: 1fr; justify-items: center; } +.anzeige .lade.heiz .heiz-tank { width: 6.5rem; } +.anzeige .lade.wasser { grid-template-columns: 1fr; } +.anzeige .lade.wasser .wasser-zonen { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.anzeige .lade.wasser .wasser-fuss { flex-direction: column; align-items: stretch; } +.anzeige .lade.wasser .wasser-knopf { width: 100%; justify-content: center; } /* --- 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); } diff --git a/index.php b/index.php index 7d4ae4a..65fcac0 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/bewaesserungAnzeige.js", "js/solar/ladefenster.js", "js/solar/solarMQTT.js"]], + "js/solar/energieflussKatalog.js", "js/solar/energiefluss.js", "js/solar/bewaesserungAnzeige.js", "js/solar/ladefenster.js", "js/solar/bedienfenster.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, diff --git a/js/solar/bedienfenster.js b/js/solar/bedienfenster.js new file mode 100644 index 0000000..61bbef2 --- /dev/null +++ b/js/solar/bedienfenster.js @@ -0,0 +1,249 @@ +/* ========================================================================== + Bedienfenster Heizstab und Bewässerung - im selben Stil wie das + Ladefenster (ladefenster.js), mit denselben Bausteinen aus solar.css. + + heizfensterStarten(wurzel, url) Gerüst: ajax/heater.php + wasserfensterStarten(wurzel, url) Gerüst: ajax/watering.php + + Beide lesen jede Sekunde aus mqttData und schicken jede Änderung sofort. + Was eben geschickt wurde, gilt HALTEN ms lang lokal, bis das Gerät den + neuen Stand meldet. Der Takt endet, wenn das Fenster zugeht. + ========================================================================== */ + +(function () { + "use strict"; + + const HALTEN = 8000; + const S = " "; // schmales Leerzeichen vor Einheiten + const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ","); + const text = t => { const v = window.mqttData && mqttData[t]; return v === undefined ? undefined : String(v); }; + const zahl = t => { const v = Number(text(t)); return isFinite(v) ? v : null; }; + + /** Gemeinsames Gerüst: lokaler Stand, Meldungszeile, Takt bis zum Schließen. */ + function geruest(wurzel, meldungsFeld, grundText) { + const lokal = {}; + const g = { + merke(name, wert) { lokal[name] = { wert, bis: Date.now() + HALTEN }; }, + gilt(name, gemeldet) { return lokal[name] && lokal[name].bis > Date.now() ? lokal[name].wert : gemeldet; }, + meldung(inhalt, art) { + const m = meldungsFeld(); + m.textContent = inhalt; + m.className = "lade-meldung" + (art ? " " + art : ""); + clearTimeout(g.uhr); + if (art === "gut") g.uhr = setTimeout(() => { m.textContent = grundText; m.className = "lade-meldung"; }, 2200); + }, + takt(zeichnen) { + zeichnen(); + const uhr = setInterval(zeichnen, 1000); + const fenster = wurzel.closest(".modal"); + if (fenster) fenster.addEventListener("hidden.bs.modal", () => clearInterval(uhr), { once: true }); + }, + }; + return g; + } + + function setze(el, inhalt) { + if (el && el.textContent !== inhalt) el.textContent = inhalt; + } + + /** Temperatur als Farbe, wie der Speicher in der Übersicht. */ + function tempFarbe(t) { + const f = Math.max(0, Math.min(1, (t - 20) / 60)); + const kalt = [35, 137, 186], heiss = [186, 59, 35]; + return "rgb(" + kalt.map((k, i) => Math.round(k + (heiss[i] - k) * f)).join(",") + ")"; + } + + /* ======================================================== Heizstab */ + window.heizfensterStarten = function (wurzel, url) { + const $ = name => wurzel.querySelector('[data-heiz="' + name + '"]'); + const g = geruest(wurzel, () => $("meldung"), "Änderungen gelten sofort"); + let ziehen = false; + + async function senden(stufe) { + g.meldung("wird geschickt …"); + try { + const antwort = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "setMode=" + stufe, + }); + const inhalt = (await antwort.text()).trim(); + if (!antwort.ok || inhalt) throw new Error(inhalt || "HTTP " + antwort.status); + g.meldung("✓ an den Heizstab geschickt", "gut"); + } catch (e) { + g.meldung("Nicht übernommen: " + e.message, "fehler"); + } + zeichnen(); + } + + function stand() { + const w = zahl("solarManager/pHeat") || 0; + const gemeldetFest = text("solarManager/heatMode") !== undefined && text("solarManager/heatMode") !== "eco"; + const modus = g.gilt("modus", gemeldetFest ? "fest" : "auto"); + const stufe = g.gilt("stufe", Math.max(1, Math.min(60, Math.round(w / 100) || 30))); + return { + w, modus, stufe, + temps: ["t_buffT", "t_buffM", "t_buffB"].map(t => zahl("solarManager/" + t)), + }; + } + + function zeichnen() { + const s = stand(); + const heizt = s.w >= 20; + + // Speicher + s.temps.forEach((t, i) => { + const schicht = $("t" + i); + if (t !== null) schicht.style.fill = tempFarbe(t); + setze($("tt" + i), t !== null ? Math.round(t) + "°" : "–"); + }); + $("spirale").classList.toggle("an", heizt); + const namen = ["oben", "Mitte", "unten"]; + setze($("temps"), s.temps.map((t, i) => t !== null ? namen[i] + " " + komma(t) : null).filter(Boolean).join(" · ") + S + "°C"); + + $("leistung").innerHTML = komma(s.w / 1000, 2) + S + "kW" + (heizt ? "heizt" : "aus") + ""; + const pille = $("pille"); + setze(pille, s.modus === "auto" ? "Automatik" : "fest"); + pille.className = "lade-pille" + (heizt ? " an" : ""); + + setze($("erklaerung"), s.modus === "auto" + ? "Heizt nur mit dem Überschuss der Anlage – ohne Sonne bleibt der Heizstab aus." + : "Heizt dauerhaft mit " + komma(s.stufe / 10) + S + "kW – reicht die Sonne nicht, kommt der Strom aus dem Netz."); + + wurzel.querySelectorAll("[data-heiz-modus]").forEach(b => b.setAttribute("aria-checked", b.dataset.heizModus === s.modus ? "true" : "false")); + $("fest").hidden = s.modus !== "fest"; + const regler = $("regler"); + if (!ziehen) regler.value = s.stufe; + const wert = Number(regler.value); + regler.style.setProperty("--wert", ((wert - 1) / 59 * 100).toFixed(1) + "%"); + $("reglerAusgabe").innerHTML = komma(wert / 10) + S + "kW"; + wurzel.querySelectorAll("[data-heiz-stufe]").forEach(c => c.setAttribute("aria-pressed", Number(c.dataset.heizStufe) === wert ? "true" : "false")); + } + + wurzel.querySelectorAll("[data-heiz-modus]").forEach(b => b.addEventListener("click", () => { + const modus = b.dataset.heizModus; + g.merke("modus", modus); + senden(modus === "auto" ? 0 : Number($("regler").value)); + })); + $("regler").addEventListener("input", () => { ziehen = true; g.merke("stufe", Number($("regler").value)); zeichnen(); }); + $("regler").addEventListener("change", () => { + ziehen = false; + g.merke("modus", "fest"); + senden(Number($("regler").value)); + }); + wurzel.querySelectorAll("[data-heiz-stufe]").forEach(c => c.addEventListener("click", () => { + const stufe = Number(c.dataset.heizStufe); + g.merke("stufe", stufe); + g.merke("modus", "fest"); + senden(stufe); + })); + + g.takt(zeichnen); + }; + + /* ===================================================== Bewässerung */ + window.wasserfensterStarten = function (wurzel, url) { + const g = geruest(wurzel, () => wurzel.querySelector('[data-w="meldung"]'), "Befehle gehen sofort an die Ventilsteuerung"); + const bilder = window.ENERGIEFLUSS_ZONENBILDER || {}; + const dauer = {}; // Zone -> gewählte Minuten + + wurzel.querySelectorAll("[data-bild]").forEach(b => { + if (bilder[b.dataset.bild]) b.innerHTML = '' + bilder[b.dataset.bild] + ""; + }); + wurzel.querySelectorAll("[data-zone]").forEach(k => { dauer[k.dataset.zone] = Number(k.dataset.dauer); }); + + const zeitText = sek => sek < 60 ? Math.round(sek) + S + "s" : Math.ceil(sek / 60) + S + "min"; + const labelText = l => String(l).replace(/^(\d+)m$/, "$1" + S + "min").replace(/^(\d+)s$/, "$1" + S + "s"); + + async function senden(topic, payload) { + g.meldung("wird geschickt …"); + try { + const antwort = await fetch(url, { + method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ topic, payload: typeof payload === "string" ? payload : JSON.stringify(payload) }), + }); + const d = await antwort.json().catch(() => ({})); + if (!antwort.ok || !d.success) throw new Error(d.message || "HTTP " + antwort.status); + g.meldung("✓ an die Ventilsteuerung geschickt", "gut"); + } catch (e) { + g.meldung("Nicht übernommen: " + e.message, "fehler"); + } + zeichnen(); + } + + function zeichnen() { + wurzel.querySelectorAll("[data-zone]").forEach(karte => { + const key = karte.dataset.zone; + const zone = wateringZones[key]; + if (!zone) return; + const z = wateringZonenZustand(zone); + const gemeldetLaeuft = z.state !== "aus" && z.state !== "--"; + const laeuft = g.gilt("laeuft-" + key, gemeldetLaeuft); + const timer = wateringValue(wateringJson(zone.topic + "/Timers"), zone.valve); + const rest = timer !== null ? Math.abs(wateringSeconds(timer)) : null; + const $ = name => karte.querySelector('[data-w="' + name + '"]'); + + let zustand; + if (laeuft && gemeldetLaeuft) zustand = (z.paused ? "pausiert" : "läuft") + (rest ? " · noch " + zeitText(rest) : ""); + else if (laeuft) zustand = "startet …"; + else if (z.bewaessert) zustand = "heute " + labelText(z.heute.label) + " gegossen"; + else if (z.state === "--") zustand = "Zustand unbekannt"; + else zustand = "heute noch nicht gegossen"; + setze($("zustand"), zustand); + + const pille = $("pille"); + setze(pille, laeuft ? (z.paused ? "pausiert" : "läuft") : z.bewaessert ? "gegossen" : ""); + pille.className = "lade-pille" + (laeuft ? (z.paused ? " warn" : " an") : z.bewaessert ? " blau" : ""); + karte.classList.toggle("laeuft", laeuft); + + const knopf = $("knopf"); + knopf.className = "lade-start wasser-knopf" + (laeuft ? " laeuft" : ""); + setze($("knopfText"), laeuft ? "Stoppen" : "Gießen · " + dauer[key] + S + "min"); + knopf.dataset.aktion = laeuft ? "stopp" : "start"; + + karte.querySelectorAll("[data-min]").forEach(c => c.setAttribute("aria-pressed", Number(c.dataset.min) === dauer[key] ? "true" : "false")); + + const verlauf = wateringHistory(zone.topic, zone.valve); + const vortag = wateringDayTotal(zone.topic, "LastWateringDay", "wateringDurationDaySecs", zone.valve, false); + const teile = []; + if (verlauf.dateTime !== "--") teile.push("zuletzt " + verlauf.dateTime + (verlauf.duration !== "--" ? " · " + labelText(verlauf.duration) : "")); + if (vortag.label !== "--" && vortag.sekunden > 0) teile.push("letzter Gießtag " + labelText(vortag.label)); + setze($("info"), teile.join(" · ")); + }); + + wurzel.querySelectorAll("[data-ablauf]").forEach(c => { + const modi = JSON.parse(c.dataset.ablauf); + const aktiv = g.gilt("ablauf-" + c.textContent, Object.entries(modi).every(([t, m]) => wateringAutoActive(t, m))); + c.setAttribute("aria-pressed", aktiv ? "true" : "false"); + }); + } + + wurzel.querySelectorAll("[data-zone]").forEach(karte => { + const key = karte.dataset.zone; + const zone = wateringZones[key]; + karte.querySelectorAll("[data-min]").forEach(c => c.addEventListener("click", () => { + dauer[key] = Number(c.dataset.min); + zeichnen(); + })); + karte.querySelector('[data-w="knopf"]').addEventListener("click", e => { + const start = e.currentTarget.dataset.aktion === "start"; + g.merke("laeuft-" + key, start); + senden(zone.topic + "/set", start + ? { valve: zone.valve, set: "open", timer: dauer[key] } + : { valve: zone.valve, set: "close" }); + }); + }); + + wurzel.querySelectorAll("[data-ablauf]").forEach(c => c.addEventListener("click", async () => { + const modi = JSON.parse(c.dataset.ablauf); + const aktiv = c.getAttribute("aria-pressed") === "true"; + g.merke("ablauf-" + c.textContent, !aktiv); + for (const [topic, modus] of Object.entries(modi)) { + await senden(topic + "/auto", aktiv ? "Stop" : modus); + } + })); + + g.takt(zeichnen); + }; +})(); diff --git a/js/solar/common.js b/js/solar/common.js index bd9ccdf..82baf5c 100644 --- a/js/solar/common.js +++ b/js/solar/common.js @@ -73,46 +73,6 @@ async function getData(chart, url, options = {}) { } } -/** - * Formular des Modals per POST abschicken ("Save changes", heute nur noch - * der Heizstab). Das Ladefenster schreibt selbst, siehe ladefenster.js. - */ -function submitFormAjax(event) { - let xmlhttp = new XMLHttpRequest(); - - xmlhttp.onreadystatechange = function () { - if (this.readyState === 4 && this.status === 200) { - setTimeout(function () { modalEV.hide(); }, 700); - if (xmlhttp.responseText) { - alert(xmlhttp.responseText); - } - } - }; - - let post = ""; - const form = document.getElementById('modalEV').querySelector('form'); - for (const element of Array.from(form.elements)) { - if (!element.name) continue; - if (element.type == "radio" || element.type == "checkbox") { - if (element.checked) { - post += element.name + "=" + encodeURIComponent(element.value) + "&"; - } - } else if (element.type == "button") { - if (!element.classList.contains("accordion-button")) { - post += element.name + "=" + encodeURIComponent(element.innerHTML) + "&"; - } - } else { - post += element.name + "=" + encodeURIComponent(element.value) + "&"; - } - } - - xmlhttp.open("POST", event.currentTarget.contentURL, true); - xmlhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); - document.getElementById('modalEV').querySelector('.modal-body').innerHTML = loadingHTML("Änderungen werden übernommen..."); - xmlhttp.send(post); - return false; -} - /** * Schieberegler im Modal mit seiner Beschriftung verbinden. * label(wert) liefert den anzuzeigenden Text. diff --git a/js/solar/energiefluss.js b/js/solar/energiefluss.js index 2f4a560..0060fb2 100644 --- a/js/solar/energiefluss.js +++ b/js/solar/energiefluss.js @@ -381,7 +381,7 @@ wert.remove(); neben.remove(); symbol.setAttribute("transform", `translate(0 ${-r * 0.5}) scale(1.4)`); k.zonen.forEach((z, i) => { - const zy = -r * 0.1 + i * 25; + const zy = -r * 0.06 + i * 20; // Eigenes Symbol (ZONENBILDER) oder ein Zeichen aus Bootstrap Icons. // updateWateringIndicator() färbt es über color und schreibt den // Tooltip in das . @@ -622,4 +622,6 @@ } window.Energiefluss = Energiefluss; + // Auch die Bedienfenster (bedienfenster.js) zeigen die Zonen mit diesen Symbolen. + window.ENERGIEFLUSS_ZONENBILDER = ZONENBILDER; })(); diff --git a/js/solar/solarMQTT.js b/js/solar/solarMQTT.js index c5a7a82..e4cfde4 100644 --- a/js/solar/solarMQTT.js +++ b/js/solar/solarMQTT.js @@ -443,9 +443,9 @@ var chartSettings = { function openModal(type) { - // Das Ladefenster und die Bewaesserung uebernehmen jede Aenderung sofort - - // "Save changes" braucht nur noch der Heizstab. - document.getElementById("modalSaveBtn").style.display = type === "heater" ? "" : "none"; + // Alle Fenster der Solar-Seite uebernehmen jede Aenderung sofort - + // "Save changes" braucht hier keines mehr. + document.getElementById("modalSaveBtn").style.display = "none"; switch(type){ case "CarEG": document.getElementById("modal-title").innerHTML = "Autoladung EG"; @@ -471,8 +471,6 @@ var chartSettings = { modalBodyElement = document.getElementById('modal-body'); modalBodyElement.innerHTML = loadingHTML("Wird geladen..."); - document.getElementById("modalSaveBtn").addEventListener("click", submitFormAjax); - document.getElementById("modalSaveBtn").contentURL = contentURL; modalEV.show(); fetch(contentURL, { method: 'GET', @@ -485,25 +483,17 @@ var chartSettings = { .then(html => { modalBodyElement.innerHTML = html; - var slider = document.getElementById("modal-slider"); - var span = document.getElementById("modal-slider-label"); + // Aufbau und Logik der Fenster: ladefenster.js und bedienfenster.js. switch(type){ case "CarEG": case "CarOG": - // Alles Weitere in js/solar/ladefenster.js. ladefensterStarten(modalBodyElement.querySelector(".lade"), contentURL); break; - case "heater": - bindModalSlider(slider, span, value => value == 0 ? "Automatik" : (value / 10) + " kW"); - if(mqttData["solarManager/heatMode"] == "eco"){ - document.getElementById("modal-slider").value = 0; - }else{ - document.getElementById("modal-slider").value = Math.round(mqttData["solarManager/pHeat"]/100); - } - document.getElementById("modal-slider").dispatchEvent(new Event('input')); - break; + case "heater": + heizfensterStarten(modalBodyElement.querySelector(".heiz"), contentURL); + break; case "watering": - initializeWateringModal(); + wasserfensterStarten(modalBodyElement.querySelector(".wasser"), contentURL); break; } @@ -513,146 +503,10 @@ var chartSettings = { }); } - function wateringCommand(topic, command, directTopic = false) { - const feedback = document.getElementById("watering-feedback"); - const targetTopic = directTopic ? topic : topic + "/set"; - const payload = typeof command === "string" ? command : JSON.stringify(command); - const endpoint = window.location.pathname.replace(/\/index\.php$/, "").replace(/\/$/, "") + "/ajax/watering.php"; - const showFeedback = (message, type) => { - if (!feedback) return; - feedback.textContent = message; - feedback.className = "alert alert-" + type + " mb-3"; - feedback.style.whiteSpace = "pre-line"; - }; - fetch(endpoint, { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ topic: targetTopic, payload: payload }) - }).then(async (response) => { - const result = await response.json(); - if (!response.ok || !result.success) throw new Error(result.message || "Unbekannter MQTT-Fehler."); - showFeedback("Befehl gesendet.\nTopic: " + result.topic + "\nInhalt: " + result.payload, "success"); - }).catch((error) => { - showFeedback("Befehl konnte nicht gesendet werden: " + error.message + "\nTopic: " + targetTopic + "\nInhalt: " + payload, "danger"); - }); - } - - function initializeWateringModal() { - document.querySelectorAll("[data-watering-toggle]").forEach((button) => { - const updateButton = () => { - const zone = wateringZones[button.dataset.wateringZone]; - setzeZonenButton(button, wateringLabel(zone.topic, zone.valve)); - }; - button.addEventListener("click", () => { - const zone = wateringZones[button.dataset.wateringZone]; - if (wateringLabel(zone.topic, zone.valve) === "aus") { - const timerInput = document.querySelector(`[data-watering-timer='${button.dataset.wateringZone}']`); - const timer = Math.min(120, Math.max(1, Number(timerInput.value) || 30)); - wateringCommand(zone.topic, { valve: zone.valve, set: "open", timer: timer }); - } else { - wateringCommand(zone.topic, { valve: zone.valve, set: "close" }); - } - }); - updateButton(); - }); - document.querySelectorAll("[data-watering-auto-toggle]").forEach((button) => { - button.addEventListener("click", () => { - const topics = (button.dataset.wateringTopics || button.dataset.wateringTopic).split(","); - const modes = button.dataset.wateringAuto.split(","); - const active = topics.every((topic, index) => wateringAutoActive(topic, modes[index])); - topics.forEach((topic, index) => { - wateringCommand(topic + "/auto", active ? "Stop" : modes[index], true); - }); - }); - updateWateringAutoButton(button); - }); - updateWateringStatus(); - } - + // Die Zonensymbole im Bewaesserungskreis; das Fenster aktualisiert sich + // selbst (bedienfenster.js). function updateWateringStatus() { - wateringZoneList.forEach(([key, 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); - // Die Regenautomatik laeuft zum Sonnenaufgang; wenn jemand auf die - // Anzeige schaut, ist die Entscheidung laengst gefallen. Angezeigt wird - // deshalb nicht mehr, was geplant ist, sondern was passiert ist. Der - // Zaehler unterscheidet nicht, wer ausgeloest hat - eine Bewaesserung - // von Hand zaehlt genauso, und das ist so gewollt. - const bewaessert = state === "aus" && heute.sekunden > 0; - - // Die Anzeigen liegen im Realtime-SVG und bleiben bestehen -> svgElement. - const status = svgElement(zone.statusId); - const icon = svgElement(zone.iconId); - if (status && icon) updateWateringIndicator(status, icon, zone.label, state, paused, bewaessert); - - // Die Modal-Elemente werden bei jedem Oeffnen neu erzeugt -> nicht cachen. - setzeZonenButton(document.querySelector(`[data-watering-toggle][data-watering-zone='${key}']`), state); - setzeSchild(`[data-watering-state='${key}']`, state, paused, bewaessert); - - const history = wateringHistory(zone.topic, zone.valve); - setzeZelle(`[data-watering-last='${key}']`, history.dateTime); - setzeZelle(`[data-watering-duration='${key}']`, history.duration); - const vortag = wateringDayTotal(zone.topic, "LastWateringDay", "wateringDurationDaySecs", zone.valve, false); - setzeZelle(`[data-watering-today='${key}']`, heute.label, heute.day && "Stand: " + heute.day); - setzeZelle(`[data-watering-prevday='${key}']`, vortag.label, vortag.day && "Bewässerungstag: " + vortag.day); - }); - document.querySelectorAll("[data-watering-auto-toggle]").forEach(updateWateringAutoButton); - } - - // Der Knopf zeigt die naechste Aktion, nicht den Zustand: laeuft die Zone, - // steht "Aus" darauf. - function setzeZonenButton(button, state) { - if (!button) return; - const beschriftung = state === "aus" ? "Ein" : "Aus"; - if (button.textContent !== beschriftung) button.textContent = beschriftung; - button.classList.toggle("btn-success", state === "aus"); - button.classList.toggle("btn-danger", state !== "aus"); - } - - // Zustandsschild der Zonenkarte, Farben wie im SVG: laufend gruen, - // pausiert gelb, heute schon bewaessert blau, sonst grau. - function setzeSchild(selektor, state, paused, bewaessert) { - const schild = document.querySelector(selektor); - if (!schild) return; - let text = "unbekannt"; - let farbe = "bg-secondary"; - if (state !== "--" && state !== "aus") { - text = paused ? "pausiert " + state : "läuft " + state; - farbe = paused ? "bg-warning text-dark" : "bg-success"; - } else if (state === "aus") { - text = bewaessert ? "heute bewässert" : "aus"; - farbe = bewaessert ? "bg-primary" : "bg-secondary"; - } - if (schild.textContent !== text) schild.textContent = text; - const klasse = "badge " + farbe; - if (schild.className !== klasse) schild.className = klasse; - } - - // Schreibt nur bei echter Aenderung - die Tabelle wird im Sekundentakt - // aktualisiert, ihre Werte aendern sich aber hoechstens minuetlich. - function setzeZelle(selektor, text, titel) { - const element = document.querySelector(selektor); - if (!element) return; - if (element.textContent !== text) element.textContent = text; - if (titel) { - if (element.getAttribute("title") !== titel) element.setAttribute("title", titel); - } else if (element.hasAttribute("title")) { - element.removeAttribute("title"); - } - } - - function updateWateringAutoButton(button) { - const topics = (button.dataset.wateringTopics || button.dataset.wateringTopic).split(","); - const modes = button.dataset.wateringAuto.split(","); - const active = topics.every((topic, index) => wateringAutoActive(topic, modes[index])); - if (!button.dataset.wateringLabel) { - button.dataset.wateringLabel = button.textContent.replace(/^Automatik\s+/, ""); - } - button.textContent = (active ? "Aus: " : "Ein: ") + button.dataset.wateringLabel; - button.classList.toggle("btn-outline-primary", !active); - button.classList.toggle("btn-outline-danger", active); + wateringSymboleSetzen(svgElement); } window.setInterval(updateWateringStatus, 1000);