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 <noreply@anthropic.com>
This commit is contained in:
+3
-2
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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/<vorn|hinten>/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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
|
||||
+11
-2
@@ -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) + " %";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user