/* * Einstellungsseite: Preistabellen bearbeiten. * * Eine Preistabelle ist eine Zeitreihe - ab einem Stichtag gilt ein Preis, bis * der naechste Stichtag kommt. Deshalb gibt es hier nur ein Datumsfeld je * Zeile. Wo eine Zeile endet, steht als grauer Text daneben und ergibt sich * aus der naechsten; die juengste Zeile endet gar nicht, sie gilt weiter, bis * ein neuer Preis eingetragen wird. * * Frueher stand das Ende als eigene Spalte in der Datenbank, und weil es * eigenstaendig gepflegt wurde, konnte es dem naechsten Stichtag widersprechen: * zwei Zeilen liefen ueber dasselbe Quartal, die Jahresstatistik zaehlte diese * Tage doppelt. Ohne die Spalte laesst sich das nicht mehr hinschreiben. * * Gezeichnet wird aus dem Modell, nicht aus dem DOM: karte._modell ist die * Wahrheit, jede Eingabe schreibt hinein und loest ein Neuzeichnen aus. */ // --- Datumskram ------------------------------------------------------------ /** Ein Datum "JJJJ-MM-TT" um $tage verschieben. */ function tagVersatz(iso, tage) { const d = new Date(iso + "T00:00:00Z"); d.setUTCDate(d.getUTCDate() + tage); return d.toISOString().slice(0, 10); } /** "2025-01-01" -> "01.01.2025" */ function datumLesbar(iso) { if (!iso) return "–"; const t = iso.split("-"); return t[2] + "." + t[1] + "." + t[0]; } function heuteISO() { const d = new Date(); return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10); } // --- Modell --------------------------------------------------------------- /** Zeilen aufsteigend nach Stichtag. */ function sortiert(zeilen) { return zeilen.slice().sort((a, b) => (a.ab < b.ab ? -1 : a.ab > b.ab ? 1 : 0)); } /** * Die einzige Luecke, die es noch geben kann: vor dem aeltesten Stichtag gibt * es keinen Preis. Messwerte, die davor liegen, fallen aus der Jahresstatistik * heraus, weil der JOIN fuer sie keine Zeile findet. Passieren kann das, wenn * jemand den aeltesten Zeitraum loescht oder nach hinten schiebt - deshalb * wird die Warnung bei jedem Neuzeichnen geprueft, nicht nur beim Laden. */ function zeitraumWarnung(zeilen) { const liste = sortiert(zeilen); if (!ersteMessung || !liste.length || liste[0].ab <= ersteMessung) return []; return ["Die Messwerte beginnen am " + datumLesbar(ersteMessung) + ", der älteste Preis" + " erst am " + datumLesbar(liste[0].ab) + ". Alles davor fehlt in der Statistik."]; } // --- Zeichnen ------------------------------------------------------------- function preisZeile(karte, zeile, i, anzahl) { const daten = preisDaten[karte.dataset.reihe]; const felder = daten.felder; // Die Spaltenbreiten kommen aus preisSpalten() im PHP - Kopfzeile und // Felder muessen dieselben benutzen, sonst stehen die Ueberschriften schief. const breit = daten.spalten; const letzte = (i === anzahl - 1); const heute = heuteISO(); // Die juengste Zeile hat kein Ende; alle anderen enden am Tag vor der // naechsten. const bis = letzte ? null : tagVersatz(karte._modell[i + 1].ab, -1); const gilt = zeile.ab <= heute && (bis === null || heute <= bis); let html = "
"; html += "
" + "" + "" + "
"; // Die Marke "gilt heute" steht beim Zeitraum, nicht beim Papierkorb: sie // beschreibt ihn, und die Aktionsspalte bleibt schmal genug, dass die // Felder den Platz bekommen. html += "
" + "" + "
" + "" + (bis === null ? "offen" : datumLesbar(bis)) + "" + (gilt ? "gilt heute" : "") + "
"; Object.keys(felder).forEach(function (spalte, nr) { const feld = felder[spalte]; // Schrittweite aus den Nachkommastellen: der Energieinhalt je Liter will // vier Stellen, ein Preis in Cent nur zwei. const schritt = Math.pow(10, -feld.stellen).toFixed(feld.stellen); html += "
" + "" + "
" + "" + "" + feld.einheit + "" + "
"; }); html += "
"; html += "" + "
"; return html; } function zeichnePreise(karte) { karte._modell = sortiert(karte._modell); const anzahl = karte._modell.length; const warnung = zeitraumWarnung(karte._modell); let html = ""; if (warnung.length) { html += "
" + "" + warnung.join(" ") + "
"; } for (let i = 0; i < anzahl; i++) { html += preisZeile(karte, karte._modell[i], i, anzahl); } karte.querySelector(".preis-zeilen").innerHTML = html; } // --- Bedienung ------------------------------------------------------------ function preisHinweis(karte, text, klasse) { const feld = karte.querySelector(".preis-hinweis"); feld.className = "preis-hinweis small " + (klasse || "text-secondary"); feld.textContent = text; } function preisNeueZeile(karte) { const modell = karte._modell; const vorlage = modell[modell.length - 1]; // Der neue Stichtag ist heute; ist der schon vergeben, der naechste freie // Tag. So entsteht nie eine Zeile, die das Speichern gleich ablehnt. let ab = heuteISO(); while (modell.some(z => z.ab === ab)) { ab = tagVersatz(ab, 1); } const zeile = { ab: ab }; for (const spalte in preisDaten[karte.dataset.reihe].felder) { zeile[spalte] = vorlage[spalte]; // Preise uebernehmen, geaendert wird meist nur einer } modell.push(zeile); zeichnePreise(karte); preisHinweis(karte, "Noch nicht gespeichert.", "text-warning"); const zeilen = karte.querySelectorAll(".preis-zeile"); const feld = zeilen[zeilen.length - 1].querySelector("[data-feld='ab']"); feld.focus(); } async function preiseSenden(karte) { const knopf = karte.querySelector(".preis-speichern"); knopf.disabled = true; preisHinweis(karte, "Wird gespeichert …"); try { const antwort = await fetch("ajax/settings.php?action=save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reihe: karte.dataset.reihe, zeilen: karte._modell }) }); const daten = await antwort.json(); if (!antwort.ok) { throw new Error(daten.error || "Fehler " + antwort.status); } // Mit der Antwort des Servers weiterarbeiten, nicht mit dem eigenen // Modell: sortiert und gerundet wird dort. karte._modell = daten.zeilen; zeichnePreise(karte); preisHinweis(karte, "Gespeichert – " + daten.gespeichert + " Zeiträume.", "text-success"); } catch (fehler) { preisHinweis(karte, fehler.message, "text-danger"); } knopf.disabled = false; } async function preiseHolen(karte) { preisHinweis(karte, "Wird geladen …"); try { const antwort = await fetch("ajax/settings.php?action=list&reihe=" + encodeURIComponent(karte.dataset.reihe)); const daten = await antwort.json(); if (!antwort.ok) { throw new Error(daten.error || "Fehler " + antwort.status); } karte._modell = daten.zeilen; zeichnePreise(karte); preisHinweis(karte, ""); } catch (fehler) { preisHinweis(karte, fehler.message, "text-danger"); } } function preisKarteBinden(karte) { karte.addEventListener("input", function (ereignis) { const feld = ereignis.target.closest("[data-feld]"); if (!feld) return; const zeile = karte._modell[parseInt(feld.closest(".preis-zeile").dataset.i, 10)]; if (feld.dataset.feld === "ab") { if (!feld.value) return; // waehrend des Tippens ist das Feld kurz leer zeile.ab = feld.value; } else { zeile[feld.dataset.feld] = isNaN(feld.valueAsNumber) ? 0 : feld.valueAsNumber; } preisHinweis(karte, "Noch nicht gespeichert.", "text-warning"); }); // Ein geaenderter Stichtag verschiebt die Reihenfolge und damit die Enden // aller Zeilen - aber erst, wenn das Feld fertig ist. Waehrend des Tippens // neu zu zeichnen wuerde den Eingabefokus wegreissen. karte.addEventListener("change", function (ereignis) { if (ereignis.target.dataset.feld === "ab") { zeichnePreise(karte); } }); karte.addEventListener("click", function (ereignis) { if (ereignis.target.closest(".preis-neu")) { preisNeueZeile(karte); } else if (ereignis.target.closest(".preis-weg")) { const i = parseInt(ereignis.target.closest(".preis-zeile").dataset.i, 10); karte._modell.splice(i, 1); zeichnePreise(karte); preisHinweis(karte, "Noch nicht gespeichert.", "text-warning"); } else if (ereignis.target.closest(".preis-zurueck")) { preiseHolen(karte); } else if (ereignis.target.closest(".preis-speichern")) { preiseSenden(karte); } }); } document.addEventListener("DOMContentLoaded", function () { document.querySelectorAll(".preis-karte").forEach(function (karte) { const daten = preisDaten[karte.dataset.reihe]; if (!daten) return; karte._modell = sortiert(daten.zeilen); zeichnePreise(karte); preisKarteBinden(karte); }); }); /* ========================================================================== Reiter "Home-Kacheln": welche Messwerte auf den Kacheln stehen. Dieselbe Arbeitsteilung wie bei den Preisen: das Modell ist die Wahrheit, jede Eingabe schreibt hinein, gezeichnet wird daraus neu. Anders als dort kommt es nicht mit der Seite, sondern per Abruf - die Liste haengt an den Raeumen und aendert sich seltener als ein Preis, aber sie ist laenger. Eine Kachel ist entweder "Standard" (keine Zeile in der Datenbank, es gilt restricted/rooms.php) oder "selbst gesetzt". Der Unterschied steht in der Zeile, denn er entscheidet, was beim naechsten Umbau von rooms.php passiert: Standardkacheln ziehen mit, selbst gesetzte nicht. ========================================================================== */ let kachelModell = null; // { kacheln, symbole, topics, max, tabelleDa } /* * Der Katalog ist die Liste der Messwerte, die der Geraetesuchlauf kennt - * dieselbe, aus der der Automatik-Editor seine Bedingungen baut. Ein Wert * daraus bringt Topic, JSON-Pfad und Einheit mit; von Hand einzutippen ist * dann nichts mehr. * * Gespeichert wird trotzdem nur Topic und Pfad. Der Katalog ist eine * Eingabehilfe und keine zweite Wahrheit: verschwindet ein Geraet aus dem * Suchlauf, zeigt die Kachel weiter, was sie zeigt. */ /** Der Katalogeintrag zu einem Wert - oder null, wenn er dort nicht steht. */ function katalogTreffer(wert) { if (!kachelModell || !kachelModell.katalog || wert.wechselrichter) return null; const pfad = wert.pfad || ""; for (let gi = 0; gi < kachelModell.katalog.length; gi++) { const werte = kachelModell.katalog[gi].werte; for (let wi = 0; wi < werte.length; wi++) { if (werte[wi].topic === wert.topic && (werte[wi].pfad || "") === pfad) { return { gi: gi, wi: wi, geraet: kachelModell.katalog[gi], wert: werte[wi] }; } } } return null; } /** * Welche Eingabeart eine Zeile zeigt. * * Beim ersten Zeichnen geraten: steht der Wert im Katalog, ist es eine * Auswahl. Sobald der Benutzer die Art umstellt, gilt seine Wahl (_quelle) - * sonst spraenge die Zeile beim naechsten Neuzeichnen zurueck. */ function kachelQuelle(wert) { if (wert._quelle) return wert._quelle; if (wert.wechselrichter) return "wr"; return katalogTreffer(wert) ? "sensor" : "topic"; } /** * Kurzform eines Wertes fuer die Zeile: das Wesentliche, nicht alles. * * Der Geraetename steht nur dabei, wenn das Geraet in einem anderen Raum haengt * als die Kachel - im eigenen Raum wiederholte er nur, wo man ohnehin steht, * und aus drei Kuerzeln wuerden drei Zeilen. */ function kachelWertKurz(wert, kachel) { const einheit = istLeistung(wert) ? "W/kW" : (wert.einheit || ""); const treffer = wert.wechselrichter ? null : katalogTreffer(wert); let text; if (wert.wechselrichter) { text = "Wechselrichter (" + wert.wechselrichter.length + ")"; } else if (treffer) { // Das Geraet steht nur dabei, wenn es ein fremdes ist - im eigenen Raum // waere es auf jeder Zeile dasselbe Wort. const fremd = !kachel || treffer.geraet.raum !== kachel.name; text = (fremd ? treffer.geraet.label + " · " : "") + treffer.wert.name + (einheit ? " " + einheit : ""); } else { const topic = wert.topic || ""; const teile = topic.split("/"); text = teile[teile.length - 1] || topic; if (wert.pfad) text += " · " + wert.pfad; if (einheit) text += " " + einheit; } // Das Minus steht vorne, wie es auf der Kachel auch stuende - und zwar bei // jeder Herkunft, deshalb erst hier und nicht in den drei Zweigen. return wert.negativ ? "−" + text : text; } /** Eine Kachel im Modell finden. */ function kachelSuchen(floor, room) { return (kachelModell.kacheln || []).find(k => k.floor === floor && k.room === room); } /* --- Zeichnen ------------------------------------------------------------ */ function zeichneKacheln() { const ziel = document.getElementById("kachelListe"); if (!ziel || !kachelModell) return; if (!kachelModell.tabelleDa) { ziel.innerHTML = '
Etagen und Räume ' + 'lassen sich nicht lesen – die Datenbank homeMesh ist nicht ' + 'erreichbar, oder homeMesh_grundriss.sql fehlt noch.
'; return; } // Vorschlagsliste einmal fuer alle Topic-Felder. let html = '' + (kachelModell.topics || []).map(t => '"; let etage = null; (kachelModell.kacheln || []).forEach(function (k) { if (k.floor !== etage) { if (etage !== null) html += ""; etage = k.floor; html += '
' + textSchuetzen(k.floorLabel) + "
"; } html += kachelZeile(k); }); if (etage !== null) html += "
"; ziel.innerHTML = html; } /** Eine Raumzeile: Name, Kurzfassung der Werte, darunter der Editor. */ function kachelZeile(k) { const werte = k._entwurf || k.werte; // Das Symbol steht auch in der Kurzfassung: es ist das, was auf der Kachel // vor der Zahl steht, und damit der schnellste Weg zu erkennen, welcher der // drei Werte gemeint ist. const kurz = werte.length ? werte.map(w => '' + (w.icon && kachelSymbolBekannt(w.icon) ? '' : "") + textSchuetzen(kachelWertKurz(w, k)) + "").join("") : 'ohne Messwert'; let html = '
' + '"; if (k._offen) { html += '
' + werte.map((w, i) => kachelWertZeile(w, i, k)).join(""); if (werte.length < (kachelModell.max || 3)) { html += ''; } html += '
' + '' + '" + '' + "
"; } return html + "
"; } /* --- Symbole ------------------------------------------------------------- */ /** * Das Feld für das Symbol: eine Vorschau und ein Name. * * Statt einer Auswahlliste mit einer Handvoll Symbole steht hier der Name des * Bootstrap-Icons - damit sind alle gut zweitausend möglich, ohne dass für * jedes neue eines im Code nachgetragen werden müsste. Die üblichen stehen in * der Vorschlagsliste obenan, mit deutscher Beschriftung; wer ein anderes * will, tippt und bekommt den Rest angeboten (icons.getbootstrap.com). * * Die Vorschau ist die eigentliche Hilfe: ein Name wie "moisture" sagt nichts, * das Symbol daneben schon. */ function kachelSymbolFeld(icon) { return '' + kachelSymbolVorschau(icon) + "" + ''; } /** Der Inhalt der Vorschau: das Symbol, ein Strich, oder ein Fragezeichen. */ function kachelSymbolVorschau(icon) { if (!icon) return ''; if (!kachelSymbolBekannt(icon)) { return ''; } return ''; } function kachelSymbolBekannt(icon) { if (!kachelSymbolBekannt._satz) { kachelSymbolBekannt._satz = new Set((kachelModell.symbole || []).map(s => s.name)); } return kachelSymbolBekannt._satz.has(icon); } /** * Die Vorschlagsliste steht einmal im Dokument, nicht in jeder Zeile. * * Zweitausend Einträge sind rund siebzig Kilobyte Markup; bei jedem * Neuzeichnen einer Kachel neu zu bauen waere Verschwendung. Sie haengt * deshalb am Ende des Dokuments und ueberlebt das Neuzeichnen der Liste. */ function kachelIconListeSicherstellen() { if (document.getElementById("kachelIcons")) return; const liste = document.createElement("datalist"); liste.id = "kachelIcons"; liste.innerHTML = (kachelModell.symbole || []).map(s => '"; let geraete = ""; const eigene = katalog.map((g, i2) => [g, i2]).filter(([g]) => eigen(g)); if (eigene.length) { geraete += '' + eigene.map(([g, i2]) => option(g, i2)).join("") + ""; } let etage = null; katalog.forEach(function (g, index) { if (eigen(g)) return; if (g.floor !== etage) { if (etage !== null) geraete += ""; etage = g.floor; geraete += ''; } geraete += option(g, index); }); if (etage !== null) geraete += ""; const messwerte = (katalog[gi] || { werte: [] }).werte.map(function (w, index) { // Der aktuelle Wert steht dabei: er beantwortet, ob man den richtigen // Messwert erwischt hat, ohne die Kachel erst speichern zu muessen. const jetzt = (w.wert === null || w.wert === undefined || w.wert === "") ? "" : " · " + w.wert + (w.einheit ? " " + w.einheit : ""); return '"; }).join(""); felder += '
" + '
"; } else if (quelle === "wr") { felder += '
' + '
'; } else { felder += '
' + '
'; } return '
' + felder + '
' + '
' + '
' + kachelSymbolFeld(wert.icon || "") + "
" + '
' // Leistung: die Anzeige skaliert selbst zwischen W und kW. Gewählt wird // nur, in welcher Einheit die Quelle meldet - die Wallboxen schicken kW, // die Zähler W. Früher ein Häkchen, das stillschweigend Watt annahm. + '" + '" + '" + '' + "
"; } /** Skaliert der Wert selbst zwischen W und kW? Egal, aus welcher Einheit. */ function istLeistung(wert) { return wert.format === "leistung" || wert.format === "leistung_kw"; } /** Fremden Text sicher ins Markup bringen - Topics kommen aus der Datenbank. */ function textSchuetzen(s) { const d = document.createElement("div"); d.textContent = String(s === undefined || s === null ? "" : s); return d.innerHTML.replace(/"/g, """); } /* --- Bedienung ----------------------------------------------------------- */ function kachelHinweis(zeile, text, klasse) { const feld = zeile.querySelector(".kachel-hinweis"); if (feld) { feld.textContent = text; feld.className = "kachel-hinweis small " + (klasse || "text-secondary"); } } /** Aus den Feldern einer Zeile die Wertdefinition zusammensetzen. */ function kachelWertLesen(feldZeile) { const hol = (name) => { const el = feldZeile.querySelector('[data-feld="' + name + '"]'); if (!el) return undefined; return el.type === "checkbox" ? el.checked : el.value; }; const wert = {}; const quelle = hol("quelle") || "topic"; wert._quelle = quelle; // die Wahl des Benutzers, nicht gespeichert if (quelle === "wr") { wert.wechselrichter = String(hol("wechselrichter") || "") .split(",").map(t => t.trim()).filter(Boolean); wert.feld = hol("feld") || "p_AC"; } else if (quelle === "sensor") { // Topic und Pfad kommen aus dem Katalog, nicht aus einem Textfeld. const g = ((kachelModell.katalog || [])[parseInt(hol("geraet"), 10)] || { werte: [] }); const w = g.werte[parseInt(hol("messwert"), 10)]; if (w) { wert.topic = w.topic; if (w.pfad) wert.pfad = w.pfad; } } else { wert.topic = (hol("topic") || "").trim(); const pfad = (hol("pfad") || "").trim(); if (pfad) wert.pfad = pfad; } const format = hol("format"); if (format === "leistung" || format === "leistung_kw") { wert.format = format; } else { const einheit = (hol("einheit") || "").trim(); if (einheit) wert.einheit = einheit; const stellen = hol("stellen"); if (stellen !== "" && stellen !== undefined) wert.stellen = parseInt(stellen, 10); } const icon = (hol("icon") || "").trim(); if (icon) wert.icon = icon; if (hol("negativ")) wert.negativ = true; if (hol("gross")) wert.gross = true; return wert; } /** Den Entwurf einer Kachel aus allen sichtbaren Feldern nachziehen. */ function kachelEntwurfLesen(zeile, k) { k._entwurf = Array.from(zeile.querySelectorAll(".kachel-wert")).map(kachelWertLesen); } async function kachelSenden(zeile, k, werte) { kachelHinweis(zeile, "Wird gespeichert …", "text-secondary"); try { const antwort = await fetch("ajax/settings.php?action=kacheln-save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ floor: k.floor, room: k.room, werte: werte }), }); const d = await antwort.json(); if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status); k.werte = d.werte; k.eigen = d.eigen; delete k._entwurf; zeichneKacheln(); // Nach dem Neuzeichnen ist die Zeile eine andere - der Hinweis gehoert // an die neue. const neu = document.querySelector('.kachel-zeile[data-floor="' + k.floor + '"][data-room="' + k.room + '"]'); if (neu && k._offen) kachelHinweis(neu, "Gespeichert.", "text-success"); } catch (e) { kachelHinweis(zeile, e.message, "text-danger"); } } async function kachelnHolen() { const ziel = document.getElementById("kachelListe"); if (!ziel) return; try { const antwort = await fetch("ajax/settings.php?action=kacheln", { cache: "no-store" }); const d = await antwort.json(); if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status); kachelModell = d; delete kachelSymbolBekannt._satz; // neue Antwort, neue Namen kachelIconListeSicherstellen(); zeichneKacheln(); } catch (e) { ziel.innerHTML = '
Kacheln nicht abrufbar: ' + textSchuetzen(e.message) + "
"; } } function kachelnBinden() { const liste = document.getElementById("kachelListe"); if (!liste) return; liste.addEventListener("click", function (ereignis) { const zeile = ereignis.target.closest(".kachel-zeile"); if (!zeile || !kachelModell) return; const k = kachelSuchen(zeile.dataset.floor, zeile.dataset.room); if (!k) return; if (ereignis.target.closest(".kachel-kopf")) { // Nur eine Kachel gleichzeitig offen: zwei Editoren nebeneinander // fuellen den Bildschirm, und die Zeile darunter waere nicht mehr zu // sehen. Ein Wechsel verwirft ungespeicherte Aenderungen nicht - der // Entwurf bleibt am Modell haengen. const wechsel = !k._offen; kachelModell.kacheln.forEach(a => { a._offen = false; }); k._offen = wechsel; if (wechsel && !k._entwurf) k._entwurf = JSON.parse(JSON.stringify(k.werte)); zeichneKacheln(); return; } if (ereignis.target.closest(".kachel-plus")) { kachelEntwurfLesen(zeile, k); k._entwurf.push((kachelModell.katalog || []).length ? { _quelle: "sensor", einheit: "" } // erst waehlen, dann feilen : { topic: "", einheit: "" }); zeichneKacheln(); return; } if (ereignis.target.closest(".kachel-weg")) { kachelEntwurfLesen(zeile, k); k._entwurf.splice(parseInt(ereignis.target.closest(".kachel-wert").dataset.i, 10), 1); zeichneKacheln(); return; } if (ereignis.target.closest(".kachel-speichern")) { kachelEntwurfLesen(zeile, k); kachelSenden(zeile, k, k._entwurf); return; } if (ereignis.target.closest(".kachel-standard")) { kachelSenden(zeile, k, null); } }); // Die Quelle und das Leistungsformat blenden Felder um - dafuer muss neu // gezeichnet werden. Alles andere schreibt nur in den Entwurf. liste.addEventListener("change", function (ereignis) { const feld = ereignis.target.closest("[data-feld]"); const zeile = ereignis.target.closest(".kachel-zeile"); if (!feld || !zeile || !kachelModell) return; const k = kachelSuchen(zeile.dataset.floor, zeile.dataset.room); if (!k) return; const name = feld.dataset.feld; kachelEntwurfLesen(zeile, k); /* * Ein neu gewaehlter Messwert bringt seine Einheit mit - sonst stuende an * einer Temperatur weiter "W". Beim Wechsel des Geraets faellt die alte * Auswahl weg, dann ist erst einmal keiner gewaehlt. */ if (name === "geraet" || name === "messwert") { const i = parseInt(feld.closest(".kachel-wert").dataset.i, 10); const wert = k._entwurf[i]; const treffer = wert && katalogTreffer(wert); if (treffer) { wert.einheit = treffer.wert.einheit; } else if (name === "geraet") { delete wert.topic; delete wert.pfad; } } // Diese drei blenden Felder um; alles andere schreibt nur in den Entwurf. if (name === "quelle" || name === "format" || name === "geraet" || name === "messwert") { zeichneKacheln(); } }); /* * Die Vorschau folgt dem Tippen. Neu gezeichnet wird dafuer nicht - das * naehme dem Feld nach jedem Buchstaben den Cursor. */ liste.addEventListener("input", function (ereignis) { const feld = ereignis.target.closest('[data-feld="icon"]'); if (!feld) return; const schau = feld.parentElement.querySelector(".kw-symbol-schau"); if (schau) schau.innerHTML = kachelSymbolVorschau(feld.value.trim()); }); const neuLaden = document.getElementById("kachelNeuLaden"); if (neuLaden) neuLaden.addEventListener("click", kachelnHolen); } document.addEventListener("DOMContentLoaded", function () { // Das Merken des offenen Reiters macht reiterMerken() in common.js - fuer // jede Leiste der Seite, nicht nur fuer diese. kachelnBinden(); kachelnHolen(); }); /* ========================================================================== Reiter "Geräte": Raumzuordnung, Karteileichen. Die Zuordnung war ein Modal auf der Startseite - erreichbar über ein Zahnradsymbol neben den Automatismen, also genau dort, wo man sie nicht sucht. Sie ist eine Einstellung und steht deshalb jetzt hier, zusammen mit dem, was ohne sie fehlte: seit wann ein Gerät nichts mehr meldet, in welchen Automatiken es steckt und der Knopf, mit dem ein abgebautes Gerät verschwindet. Letzterer ist nötig, weil der Suchlauf nichts löscht - er legt an und aktualisiert, damit die Automatiken ihre Zustands-Ids behalten. Ein Gerät, das aus dem Haus fliegt, bleibt also stehen, bis es hier jemand entfernt. ========================================================================== */ let geraeteModell = null; // { geraete, raeume } const geraeteSieb = { text: "", nurOffen: false }; /* --- Ableitungen --------------------------------------------------------- */ /** * Wie alt die jüngste Meldung eines Geräts ist, in Worten. * * "alt" heißt: lange genug her, um aufzufallen. Ein Thermostat meldet * stündlich - drei Tage Stille sind schon viel; eine Jalousie meldet nur, * wenn sie fährt. Deshalb faellt nur auf, was über einen Monat still ist. * * Geräte ganz ohne Messwerte - die Somfy-Screens etwa, die nur Befehle * entgegennehmen - haben nichts zu melden. Bei ihnen steht ein Strich und * keine Warnung: sonst stünde die halbe Spalte in Alarmfarbe, ohne dass * irgendetwas fehlt. */ function geraetAlter(g) { const zeitpunkt = g.gesehen; if (!g.messwerte) return { text: "–", alt: false }; if (!zeitpunkt) return { text: "nie", alt: true }; const d = new Date(String(zeitpunkt).replace(" ", "T")); if (isNaN(d.getTime())) return { text: "–", alt: false }; const tage = Math.floor((Date.now() - d.getTime()) / 86400000); if (tage <= 0) return { text: "heute", alt: false }; if (tage === 1) return { text: "gestern", alt: false }; if (tage < 31) return { text: "vor " + tage + " Tagen", alt: tage > 30 }; const monate = Math.round(tage / 30); return { text: "vor " + monate + " Monaten", alt: true }; } /** Ein Gerät im Modell finden. */ function geraetSuchen(id) { return (geraeteModell.geraete || []).find(g => g.id === id); } /** Was das Sieb übrig lässt. */ function geraeteGefiltert() { const suche = geraeteSieb.text.trim().toLowerCase(); return (geraeteModell.geraete || []).filter(function (g) { if (geraeteSieb.nurOffen && g.room) return false; if (!suche) return true; return (g.label + " " + g.type + " " + (g.roomLabel || "")).toLowerCase().indexOf(suche) >= 0; }); } /* --- Zeichnen ------------------------------------------------------------ */ function zeichneGeraete() { const ziel = document.getElementById("geraeteListe"); if (!ziel || !geraeteModell) return; const alle = geraeteModell.geraete || []; const sichtbar = geraeteGefiltert(); const ohneRaum = alle.filter(g => !g.room).length; const tipps = alle.filter(g => !g.room && g.tipp).length; let html = '

Die Zuordnung ordnet die Geräteliste ' + 'im Automatik-Editor und die Auswahl der Kachelwerte nach Räumen. Ein neuer ' + 'Suchlauf legt Geräte an und frischt sie auf, löscht aber keine – ' + 'abgebaute Geräte verschwinden nur hier.

'; // Sieb: bei über sechzig Geräten ist Scrollen keine Suche. html += '
' + '' + '
' + '" + '' + "
"; if (tipps) { // Viele Geräte tragen ihren Raum im Topic (Raumtemp/OG/Bad/Temp[degC]). // Das von Hand nachzuklicken ist stumpfe Arbeit für den Rechner. html += '
' + tipps + " Gerät(e) ohne Raum verraten ihn" + ' im MQTT-Topic.
'; } html += '
' + '' + "" + '' + "" + '' + '' + '' + ""; html += sichtbar.length ? sichtbar.map(g => geraetZeile(g)).join("") : ''; html += "
GerätArtRaumGemeldetAutomatiken
Nichts gefunden.
" + '
' + alle.length + " Geräte, " + ohneRaum + " ohne Raum" + (sichtbar.length !== alle.length ? " · " + sichtbar.length + " angezeigt" : "") + '
'; ziel.innerHTML = html; } /** Eine Zeile der Tafel. */ function geraetZeile(g) { const alter = geraetAlter(g); const benutzt = g.benutzt || []; // Die Raumauswahl nach Etagen gruppiert - dieselbe Reihenfolge wie überall, // sie kommt schon sortiert aus raumListe(). let auswahl = '"; let etage = null; (geraeteModell.raeume || []).forEach(function (rm) { if (rm.floor !== etage) { if (etage !== null) auswahl += ""; etage = rm.floor; auswahl += ''; } auswahl += '"; }); if (etage !== null) auswahl += ""; // Der Papierkorb steht sechzigmal untereinander - in Rot waere die Tafel // eine Warnleuchte. Er faerbt sich erst, wenn man ihn anfasst. const weg = benutzt.length ? '' : ''; return '" + '' + textSchuetzen(g.label) /* * Unter der Beschriftung steht genau eine Zusatzzeile - schmal wird die * Art dort gezeigt, weil ihre Spalte weg ist; breit die Anzahl der * Messwerte und Kommandos, die vor dem Loeschen interessiert. Nie beides: * auf dem Handy braeuchte das drei Zeilen und erschluege den Namen. */ + '' + '' + textSchuetzen(g.type) + "" + '' + g.messwerte + " Messwerte · " + g.kommandos + " Kommandos" + '' + textSchuetzen(g.type) + "" + '" + '' + alter.text + "" + '' + (benutzt.length ? '' + benutzt.length + "" : '') + "" + '' + weg + ""; } /** Kurze Rückmeldung im Fuß der Tafel; sie verblasst von selbst. */ function geraeteStand(text, klasse) { const ziel = document.querySelector("#geraeteListe .geraete-stand"); if (!ziel) return; ziel.className = "geraete-stand " + (klasse || "text-body-secondary"); ziel.textContent = text; clearTimeout(geraeteStand._uhr); if (klasse !== "text-danger") { geraeteStand._uhr = setTimeout(() => { if (ziel.textContent === text) ziel.textContent = ""; }, 4000); } } /* --- Sprechen mit dem Endpunkt ------------------------------------------- */ async function geraeteFrage(action, koerper) { const antwort = await fetch("ajax/settings.php?action=" + action, koerper ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper), } : { cache: "no-store" }); const d = await antwort.json(); if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status); return d; } /** * Eine oder mehrere Zuordnungen speichern. * * Gespeichert wird sofort beim Umstellen und ohne die Tafel neu zu zeichnen: * wer sich durch sechzig Geräte arbeitet, will nicht nach jedem Klick seinen * Platz in der Liste wiederfinden. Nur die Zeilenfarbe wandert mit. */ async function geraeteRaeumeSenden(zuordnung) { const ids = Object.keys(zuordnung); try { await geraeteFrage("geraete-raeume", { rooms: zuordnung }); ids.forEach(function (id) { const g = geraetSuchen(parseInt(id, 10)); if (g) { g.room = zuordnung[id] || null; const raum = (geraeteModell.raeume || []).find(r => r.key === g.room); g.roomLabel = raum ? raum.label : null; } const zeile = document.querySelector('#geraeteListe tr[data-id="' + id + '"]'); if (zeile) zeile.classList.toggle("ohne-raum", !zuordnung[id]); }); geraeteStand(ids.length > 1 ? ids.length + " Zuordnungen gespeichert." : "Gespeichert.", "text-success"); } catch (e) { geraeteStand(e.message, "text-danger"); zeichneGeraete(); // Anzeige zurück auf das, was wirklich gilt } } /** Ein Gerät entfernen - endgültig, deshalb mit Nachfrage. */ async function geraetLoeschenFragen(id) { const g = geraetSuchen(id); if (!g) return; if (!confirm("Gerät \"" + g.label + "\" mit " + g.messwerte + " Messwerten und " + g.kommandos + " Kommandos wirklich löschen?\n\n" + "Ein neuer Suchlauf legt es wieder an, wenn es noch antwortet.")) { return; } try { const d = await geraeteFrage("geraet-loeschen", { id: id }); geraeteModell.geraete = d.geraete; zeichneGeraete(); geraeteStand("„" + d.geloescht + "“ entfernt.", "text-success"); } catch (e) { geraeteStand(e.message, "text-danger"); } } async function geraeteHolen() { const ziel = document.getElementById("geraeteListe"); if (!ziel) return; try { geraeteModell = await geraeteFrage("geraete"); zeichneGeraete(); } catch (e) { ziel.innerHTML = '
Geräte nicht abrufbar: ' + textSchuetzen(e.message) + "
"; } } /* --- Bedienung ----------------------------------------------------------- */ function geraeteBinden() { const liste = document.getElementById("geraeteListe"); if (!liste) return; liste.addEventListener("change", function (ereignis) { if (ereignis.target.id === "geraeteNurOffen") { geraeteSieb.nurOffen = ereignis.target.checked; zeichneGeraete(); // Der Schalter ist nach dem Neuzeichnen ein anderes Element - ohne das // hier verlöre die Tastatur nach dem Umlegen ihren Platz. const schalter = document.getElementById("geraeteNurOffen"); if (schalter) schalter.focus(); return; } const wahl = ereignis.target.closest("select[data-actor]"); if (wahl) { const zuordnung = {}; zuordnung[wahl.dataset.actor] = wahl.value; geraeteRaeumeSenden(zuordnung); } }); liste.addEventListener("input", function (ereignis) { if (ereignis.target.id !== "geraeteSuche") return; geraeteSieb.text = ereignis.target.value; zeichneGeraete(); // Nach dem Neuzeichnen ist das Feld ein anderes - der Cursor gehört zurück. const feld = document.getElementById("geraeteSuche"); if (feld) { feld.focus(); feld.setSelectionRange(feld.value.length, feld.value.length); } }); liste.addEventListener("click", function (ereignis) { if (ereignis.target.closest("#geraeteTipps")) { // Alle offenen Vorschläge auf einmal - eine Anfrage, ein Neuzeichnen. const zuordnung = {}; liste.querySelectorAll("select[data-tipp]").forEach(function (s) { if (!s.value) { s.value = s.dataset.tipp; zuordnung[s.dataset.actor] = s.value; } }); if (Object.keys(zuordnung).length) { geraeteRaeumeSenden(zuordnung).then(zeichneGeraete); } return; } const weg = ereignis.target.closest(".geraet-weg"); if (weg) { geraetLoeschenFragen(parseInt(weg.closest("tr").dataset.id, 10)); } }); const neuLaden = document.getElementById("geraeteNeuLaden"); if (neuLaden) neuLaden.addEventListener("click", geraeteHolen); } document.addEventListener("DOMContentLoaded", function () { geraeteBinden(); geraeteHolen(); }); /* ========================================================================== Fahrzeug: die Zugangsschlüssel der MyŠkoda-Anbindung -------------------------------------------------------------------------- Sie laufen ab. Bis hierher hieß der Tausch: per SSH auf die NAS, skoda.conf im Editor öffnen, nicht vertippen. Das gehört in die Einstellungen wie die Preise und die Geräte. Was hier nie ankommt, sind die Schlüssel selbst. Die Seite steht im Heimnetz ohne Anmeldung offen, und ein solcher Schlüssel steuert das Fahrzeug von überall auf der Welt - Laden, Klima, Standort. Zu sehen sind nur die letzten vier Zeichen, gerade genug, um zwei auseinanderzuhalten. Geschrieben wird deshalb auch nur in eine Richtung: ein neuer Wert geht hinein, ein alter kommt nie heraus. Der zweite und dritte Schlüssel beschleunigen nichts: das Kontingent der API gilt gemessen fürs ganze Konto. Sie sind für den Fall da, dass einer abläuft oder widerrufen wird - dann fragt der Abruf mit dem nächsten weiter, statt stillzustehen. Deshalb steht unter den Feldern, was gerade gilt, und nicht, was ein weiterer Schlüssel brächte. ========================================================================== */ let skodaModell = null; // was ?action=skoda-keys liefert, ohne Werte /* --- Zeichnen ------------------------------------------------------------ */ function zeichneSkoda() { const ziel = document.getElementById("skodaListe"); if (!ziel || !skodaModell) return; if (!skodaModell.da) { ziel.innerHTML = '
' + "Die Datei " + textSchuetzen(skodaModell.datei) + " gibt es nicht. " + "Sie gehört zum SolarManager und wird dort angelegt – ohne sie läuft auch " + "der Abruf nicht.
"; return; } const zeilen = skodaModell.felder.map(skodaZeile).join(""); ziel.innerHTML = '
' + zeilen + "
" + (skodaModell.schreibbar ? "" : '
' + "Der Webserver darf " + textSchuetzen(skodaModell.datei) + " nicht schreiben. Eintragen lässt sich hier nichts.
") + '
' + '" + '' + "
" + skodaBilanz(); } /** Eine Zeile: was der Schlüssel tut, ob er da ist, und ein Feld dafür. */ function skodaZeile(f) { const kennung = "skodaFeld-" + f.feld; const stand = f.gesetzt ? 'hinterlegt' + ' endet auf ' + textSchuetzen(f.maske) + "" : '' + (f.pflicht ? "fehlt" : "nicht hinterlegt") + ""; // Entfernen nur, wo es etwas zu entfernen gibt und wo es ohne geht. Der // erste Schlüssel lässt sich ersetzen, aber nicht abschaffen - ohne ihn // steht der Abruf. const weg = (f.gesetzt && !f.pflicht) ? '' : ""; return '
' + '
' + '" + stand + "
" + '

' + textSchuetzen(f.zweck) + "

" + '
' + '' + '' + weg + "
" + "
"; } /** Was die hinterlegten Schlüssel einbringen - die Zahlen dahinter. */ function skodaBilanz() { const m = skodaModell; const teile = []; teile.push(m.abruf + (m.abruf === 1 ? " Schlüssel" : " Schlüssel im Wechsel") + " für den Abruf – beim Laden und während der Fahrt alle " + m.takt + " Sekunden"); teile.push(m.befehle + " Befehle je Stunde"); if (m.ablauf) { teile.push("nächster Ablauf am " + datumLesbar(String(m.ablauf).slice(0, 10))); } return '

' + textSchuetzen(teile.join(" · ")) + "
" + "Ein neuer Schlüssel gilt sofort; der Abruf liest die Datei von selbst neu." + "

"; } function skodaStand(text, klasse) { const ziel = document.querySelector("#skodaListe .skoda-stand"); if (!ziel) return; ziel.className = "skoda-stand small " + (klasse || "text-body-secondary"); ziel.textContent = text; clearTimeout(skodaStand._uhr); if (klasse !== "text-danger") { skodaStand._uhr = setTimeout(function () { if (ziel.textContent === text) ziel.textContent = ""; }, 8000); } } /* --- Sprechen mit dem Endpunkt ------------------------------------------- */ async function skodaFrage(action, koerper) { const antwort = await fetch("ajax/settings.php?action=" + action, koerper ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper), } : { cache: "no-store" }); const d = await antwort.json(); if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status); return d; } async function skodaHolen() { const ziel = document.getElementById("skodaListe"); if (!ziel) return; try { skodaModell = await skodaFrage("skoda-keys"); zeichneSkoda(); } catch (e) { ziel.innerHTML = '
Schlüssel nicht abrufbar: ' + textSchuetzen(e.message) + "
"; } } /** * Alles Eingetragene auf einmal senden. * * Leere Felder bedeuten "so lassen" - sonst löschte ein Speichern ohne * Eingabe alles, was man nicht noch einmal eintippt. * * Das kann dauern: der Server probiert jeden neuen Schlüssel einmal an der * API aus, bevor er ihn hinschreibt. Deshalb der Knopf mit Wartezustand. */ async function skodaSenden() { const knopf = document.getElementById("skodaSpeichern"); const felder = {}; document.querySelectorAll("#skodaListe .skoda-feld").forEach(function (zeile) { const feld = zeile.dataset.feld; const wert = zeile.querySelector("input").value.trim(); if (wert) felder[feld] = wert; }); if (!Object.keys(felder).length) { skodaStand("Kein Schlüssel eingetragen.", "text-warning"); return; } if (knopf) knopf.disabled = true; skodaStand("Wird an der Škoda-API ausprobiert …"); try { const d = await skodaFrage("skoda-keys-save", { felder: felder }); skodaModell = d.stand; zeichneSkoda(); skodaStand((d.meldungen || []).join(" ") || "Eingetragen.", "text-success"); } catch (e) { skodaStand(e.message, "text-danger"); if (knopf) knopf.disabled = false; } } /** * Einen Schlüssel entfernen. * * Mit Rückfrage: er steht danach nirgends mehr, und ein neuer kommt nur aus * der MyŠkoda-App. */ async function skodaLoeschen(feld) { if (!window.confirm(feld + " entfernen?\n\nDer Schlüssel verschwindet aus der " + "Konfiguration. Zurückholen lässt er sich nur über die MyŠkoda-App.")) { return; } skodaStand("Wird entfernt …"); try { const d = await skodaFrage("skoda-keys-save", { loeschen: [feld] }); skodaModell = d.stand; zeichneSkoda(); skodaStand(feld + " entfernt.", "text-success"); } catch (e) { skodaStand(e.message, "text-danger"); } } /* --- Bedienung ----------------------------------------------------------- */ function skodaBinden() { const liste = document.getElementById("skodaListe"); if (!liste) return; liste.addEventListener("click", function (ereignis) { const zeigen = ereignis.target.closest(".skoda-zeigen"); if (zeigen) { // Ein eingefügter Schlüssel ist nicht zu merken, nur zu vergleichen - // wer ihn nicht sehen kann, sucht einen Tippfehler im Dunkeln. const feld = zeigen.closest(".input-group").querySelector("input"); feld.type = feld.type === "password" ? "text" : "password"; zeigen.querySelector("i").className = feld.type === "password" ? "bi bi-eye" : "bi bi-eye-slash"; return; } const weg = ereignis.target.closest(".skoda-weg"); if (weg) { skodaLoeschen(weg.dataset.feld); return; } if (ereignis.target.closest("#skodaSpeichern")) { skodaSenden(); } }); // Enter im Feld soll dasselbe tun wie der Knopf daneben. liste.addEventListener("keydown", function (ereignis) { if (ereignis.key === "Enter" && ereignis.target.matches(".skoda-feld input")) { ereignis.preventDefault(); skodaSenden(); } }); const neuLaden = document.getElementById("skodaNeuLaden"); if (neuLaden) neuLaden.addEventListener("click", skodaHolen); } document.addEventListener("DOMContentLoaded", function () { skodaBinden(); skodaHolen(); });