/* ========================================================================== 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); }); }