/** * Editor und Uebersicht fuer die Automatiken. * * Der Editor arbeitet auf einem Modell und zeichnet daraus die Oberflaeche - * nicht umgekehrt. Frueher stand die erste Bedingungszeile als festes HTML im * PHP und alle weiteren wurden im JavaScript zusammengesetzt; beide Fassungen * liefen auseinander, und beim Speichern musste der Server aus durchnummerierten * Feldnamen wieder eine Struktur raten. * * Modell: * autoModel.groups = [ [ {state_id, operator, value}, ... ], ... ] * eine innere Liste ist ein UND-Block, die aeussere * verknuepft die Bloecke mit ODER * autoModel.actions = [ {command_id, params: {parameterID: wert}}, ... ] * * Der Geraetekatalog kommt zusammen mit dem Datensatz im selben Dokument * (siehe ajax/AutoAction.php), es gibt also keine Nachladerei je Zeile. * * Eine Automatik kann eine andere ausloesen. Dafuer steht hier auffallend * wenig: das gerechnete Geraet "Automatiken" fuehrt jede Automatik als * Messwert, und damit ist es fuer den Editor ein Geraet wie jedes andere - * er listet ohnehin Geraete und deren Messwerte. Nur die Uebersicht weiss * davon (toggleAutomationGroup) und das Loeschen (deleteAutomation). */ let autoModel = null; let autoDevices = []; let autoLockouts = []; let autoFerien = []; let autoFeiertage = []; let autoTagVorlagen = []; let autoRooms = []; // Die Etage, mit der der Editor aufgemacht wurde. Wird sie geaendert, // braucht auch die Liste der alten Etage ein Nachladen. let autoFloorVorher = ""; // --- Katalog-Helfer ------------------------------------------------------ function deviceById(id) { return autoDevices.find(d => d.id === id) || null; } function stateById(id) { for (const dev of autoDevices) { const state = dev.states.find(s => s.id === id); if (state) return { device: dev, state: state }; } return null; } function commandById(id) { for (const dev of autoDevices) { const cmd = dev.commands.find(c => c.id === id); if (cmd) return { device: dev, command: cmd }; } return null; } function operatorLabel(state, op) { const treffer = state.operators.find(o => o.value === op); return treffer ? treffer.label : op; } /** * Eine neue Zeile beginnt ohne Geraet. * * Frueher stand dort das erste Geraet mit seinem ersten Messwert - nach dem * Geraete-Discovery also "Bad Links: Gesperrt = ", eine Bedingung, die * niemand gemeint hat und die man erst wegklicken muss. Ohne Vorbelegung * sagt die Zeile, was zu tun ist, und der Server lehnt eine unvollstaendige * Bedingung ohnehin ab. */ function leereBedingung() { return { state_id: 0, operator: "=", value: "" }; } function leereAktion() { return { command_id: 0, params: {} }; } // --- Bausteine fuer die Oberflaeche -------------------------------------- function optionListe(select, eintraege, gewaehlt) { select.innerHTML = ""; eintraege.forEach(e => select.appendChild(option(e, gewaehlt))); } function option(e, gewaehlt) { const opt = document.createElement("option"); opt.value = e.value; opt.text = e.text; if (String(e.value) === String(gewaehlt)) opt.selected = true; return opt; } /** * Geraeteliste fuer die Auswahl: eine Ueberschrift je Etage, darin nach Raum * sortiert. * * Beim Anlegen einer Automatik denkt man in Raeumen ("im Bad soll ..."), eine * Ueberschrift je Raum waeren aber zwanzig Stueck - in einer Liste, die * fuenfzehn Zeilen hoch ist, saehe man dann mehr Ueberschrift als Geraet, und * "OG · Bad", "EG · Bad", "UG · Bad" schrieben die Etage immer wieder * dazu. Deshalb gruppiert nur die Etage; der Raum steht klein an jeder Zeile, * und weil danach sortiert wird, stehen die Geraete eines Raums trotzdem * beisammen. * * Der Raum steht nur da, wo er von Hand vergeben wurde (Zuordnung ueber das * Zahnrad in der Karte "Automatismen"). Solange das niemand getan hat, gaebe * die Etage nichts her - dann bleibt es bei der Ordnung nach Geraeteart. */ function geraeteGruppen(geraete) { if (!geraete.some(d => d.room)) { return nachGeraeteart(geraete); } const etagen = etagenReihenfolge(); // "Ohne Raum" und "Unbekannter Raum" stehen in keiner Raumliste. Ihr // indexOf() waere -1 und damit ganz vorne; sie gehoeren aber ans Ende. const platz = etage => etagen.includes(etage) ? etagen.indexOf(etage) : etagen.length; const nach = new Map(); geraete.forEach(d => { if (!nach.has(d.floorLabel)) nach.set(d.floorLabel, []); nach.get(d.floorLabel).push(d); }); return Array.from(nach.keys()) .sort((a, b) => platz(a) - platz(b) || a.localeCompare(b, "de")) .map(etage => { const liste = nach.get(etage); liste.sort((a, b) => raumIndex(a.room) - raumIndex(b.room) || a.type.localeCompare(b.type, "de") || a.label.localeCompare(b.label, "de")); return { titel: etage + " (" + liste.length + ")", geraete: liste }; }); } /** * Reihenfolge der Etagen-Ueberschriften, wie rooms.php sie notiert - erst OG, * dann EG, dann UG, dann Aussen. */ function etagenReihenfolge() { const etagen = []; autoRooms.forEach(r => { if (!etagen.includes(r.floor)) etagen.push(r.floor); }); return etagen; } /** * Platz eines Raums in rooms.php - die Sortierung innerhalb einer Etage. * * Alles ohne Raum bekommt denselben letzten Platz; dort entscheidet dann die * Geraeteart, sonst stuenden Jalousie, Steckdose und Thermostat wild * durcheinander. */ function raumIndex(key) { const i = autoRooms.findIndex(r => r.key === key); return i < 0 ? autoRooms.length : i; } /** * Was klein hinter dem Geraetenamen steht. * * Unter einer Etagen-Ueberschrift genuegt der Raum. In der Trefferliste einer * Suche steht keine Ueberschrift darueber, dort muss die Etage mit. Wo kein * Raum vergeben ist, steht die Geraeteart - die ist immer da und sagt * wenigstens, was fuer ein Ding das ist. */ function zeilenZusatz(d, mitEtage) { if (!d.roomName) return d.type; return mitEtage ? d.roomLabel : d.roomName; } /** * Worueber die Suche laeuft: Name, Raum und Geraeteart in einem Zug. * * Der Raum gehoert dazu, seit die Liste nach Etagen gruppiert - "bad" soll * auch den Handtuchtrockner finden, der im Bad haengt, ohne es im Namen zu * tragen. Ohne Zuordnung faellt der Raum weg; "Nicht zugeordnet" als * Suchbegriff waere kein Fund, sondern ein Versehen. */ function suchfeld(d) { return (d.label + " " + (d.roomName ? d.roomLabel : "") + " " + d.type).toLowerCase(); } /** * Nach Geraeteart gruppieren - der Rueckfall, solange kein Raum vergeben ist. * * Gruppen aus einem einzigen Geraet fallen dabei unter "Einzelne" zusammen: * fuenfzehn Ueberschriften mit je einem Eintrag waeren unuebersichtlicher als * die flache Liste darunter. */ function nachGeraeteart(geraete) { const nach = new Map(); geraete.forEach(d => { if (!nach.has(d.type)) nach.set(d.type, []); nach.get(d.type).push(d); }); const gruppen = []; const rest = []; Array.from(nach.keys()).sort((a, b) => a.localeCompare(b, "de")).forEach(art => { const liste = nach.get(art); if (liste.length > 1) gruppen.push({ titel: art + " (" + liste.length + ")", geraete: liste }); else rest.push(liste[0]); }); if (rest.length) { rest.sort((a, b) => a.label.localeCompare(b.label, "de")); gruppen.push({ titel: "Einzelne (" + rest.length + ")", geraete: rest }); } return gruppen; } /** * Geraeteauswahl mit Suchfeld. * * Bei ueber fuenfzig Geraeten hilft Tippen mehr als Blaettern - und es hilft * quer zur Gruppierung: "bad" findet das Thermostat im Bad, die Jalousie * "Bad Links" und alles andere, was einem Bad zugeordnet ist, "jalousie" * findet alle elf auf einmal. Gesucht wird deshalb ueber Beschriftung, Raum * und Geraeteart zugleich. * * Statt einer Auswahlliste steht ein Knopf in der Zeile; die Liste klappt * darunter ueber die volle Breite auf. Auf dem Handy ist das mehr Platz als * ein natives Auswahlfeld hergibt, und das Suchfeld waere darin ohnehin * nicht unterzubringen. */ function geraeteWaehler(geraete, aktuelleId, beiAuswahl, klasse) { const knopf = document.createElement("button"); knopf.type = "button"; // Die Aktionszeile braucht eine Auswahlflaeche in voller Hoehe, die // Bedingungszeile nur eine matte Zeile ueber dem Messwert. Dasselbe // Verhalten, zwei Auftritte. knopf.className = klasse || "form-select text-start auto-geraet"; const dev = geraete.find(d => d.id === aktuelleId); // Dieselbe Frage in Bedingungs- und Aktionszeile: es ist derselbe Knopf // mit derselben Aufgabe. knopf.textContent = dev ? dev.label : "Welches Gerät?"; if (!dev) knopf.classList.add("text-body-secondary"); knopf.onclick = () => oeffneWaehler(knopf, geraete, aktuelleId, beiAuswahl); return knopf; } /** Die aufgeklappte Liste - es ist immer hoechstens eine offen. */ function schliesseWaehler() { const offen = document.querySelector(".geraete-waehler"); if (offen) offen.remove(); } function oeffneWaehler(knopf, geraete, aktuelleId, beiAuswahl) { // Die Zeile, an die sich die Liste haengt. In der Aktionszeile ist das // eine input-group, in der Bedingungszeile ein Raster - und dort steckt // der Knopf ausserdem noch eine Ebene tiefer. const zeile = knopf.closest(".input-group, .auto-zeile") || knopf.parentElement; const schonOffen = zeile.nextElementSibling && zeile.nextElementSibling.classList.contains("geraete-waehler"); schliesseWaehler(); if (schonOffen) return; // zweiter Klick schliesst wieder const panel = document.createElement("div"); panel.className = "geraete-waehler border rounded p-2 mt-1"; const suche = document.createElement("input"); suche.type = "search"; suche.className = "form-control form-control-sm mb-2"; suche.placeholder = "Suchen – Name, Raum oder Geräteart"; const liste = document.createElement("div"); liste.className = "list-group list-group-flush"; liste.style.maxHeight = "15rem"; liste.style.overflowY = "auto"; panel.appendChild(suche); panel.appendChild(liste); // Hinter die ganze Zeile haengen, nicht in sie hinein: in einer // input-group stuende die Liste sonst als weitere Spalte daneben. zeile.after(panel); const eintrag = (d, mitEtage) => { const zeile = document.createElement("button"); zeile.type = "button"; // Der Zusatz steht rechts am Rand statt gleich hinter dem Namen: so // stehen die Raeume untereinander in einer Spalte und lassen sich // ueberfliegen, statt bei jeder Zeile woanders anzufangen. zeile.className = "list-group-item list-group-item-action py-1" + " d-flex justify-content-between align-items-center gap-2" + (d.id === aktuelleId ? " active" : ""); zeile.innerHTML = "" + textSicher(d.label) + "" + "" + textSicher(zeilenZusatz(d, mitEtage)) + ""; zeile.onclick = () => { schliesseWaehler(); beiAuswahl(d); }; return zeile; }; const zeichne = () => { const suchtext = suche.value.trim().toLowerCase(); liste.innerHTML = ""; const passend = geraete.filter(d => !suchtext || suchfeld(d).includes(suchtext)); if (!passend.length) { liste.innerHTML = "
Nichts gefunden.
"; return; } // Beim Suchen die Zwischenueberschriften weglassen: die Treffer stehen // dann quer ueber alle Etagen. Dafuer traegt jede Zeile die Etage mit - // "Bad" allein sagt sonst nicht, welches der drei Baeder gemeint ist. if (suchtext) { passend.forEach(d => liste.appendChild(eintrag(d, true))); return; } geraeteGruppen(passend).forEach(g => { const kopf = document.createElement("div"); kopf.className = "px-2 pt-2 pb-1 small fw-bold text-body-secondary"; kopf.textContent = g.titel; liste.appendChild(kopf); g.geraete.forEach(d => liste.appendChild(eintrag(d, false))); }); }; suche.oninput = zeichne; suche.onkeydown = e => { if (e.key === "Escape") { schliesseWaehler(); knopf.focus(); } if (e.key === "Enter") { e.preventDefault(); const erster = liste.querySelector("button.list-group-item"); if (erster) erster.click(); } }; zeichne(); suche.focus(); } // Klick daneben schliesst die Liste - sonst bliebe sie stehen, wenn man es // sich anders ueberlegt. // // Erkannt wird der oeffnende Knopf an .auto-geraet, also daran, WAS er ist - // nicht daran, wie er aussieht. Hier stand ".form-select.text-start", die // Optik-Klassen der Aktionszeile; als die Bedingungszeile ihren Knopf zu // einer matten Textzeile umbaute, fiel sie aus dieser Bedingung heraus. Der // Knopf oeffnete die Liste dann brav, und dieser Horcher schloss sie im // selben Klick wieder - von aussen sah es aus, als passiere gar nichts. document.addEventListener("click", e => { if (e.target.closest(".geraete-waehler") || e.target.closest(".auto-geraet")) return; schliesseWaehler(); }); function textSicher(text) { return String(text).replace(/&/g, "&").replace(//g, ">"); } /** * Wertfeld passend zum Datentyp des Messwerts oder Parameters. * Liefert das fertige Element; der Aufrufer haengt seinen Listener an. */ function wertFeld(spec, wert) { let feld; if (spec.input === "bool") { feld = document.createElement("select"); feld.className = "form-select"; optionListe(feld, [{ value: "true", text: "JA" }, { value: "false", text: "NEIN" }], wert === "" ? "true" : wert); } else if (spec.input === "select") { // Die Auswahl kommt fertig aus dem Katalog: bei Messwerten steht der // Klartext auch als Wert, bei Geraeteparametern die Zahl dahinter. feld = document.createElement("select"); feld.className = "form-select"; optionListe(feld, spec.options.map(o => ({ value: o.value, text: o.label })), wert); } else { feld = document.createElement("input"); feld.className = "form-control"; feld.type = spec.input; if (spec.min !== undefined && spec.min !== null) feld.min = spec.min; if (spec.max !== undefined && spec.max !== null) feld.max = spec.max; feld.value = wert !== "" ? wert : spec.input === "time" ? "00:00" : spec.input === "date" ? new Date().toISOString().split("T")[0] : spec.input === "number" ? "0" : ""; } return feld; } /** Standardwert, wenn eine Zeile auf einen anderen Messwert umgestellt wird. */ function standardWert(spec) { if (spec.input === "bool") return "true"; if (spec.input === "select") return spec.options.length ? spec.options[0].value : ""; if (spec.input === "time") return "00:00"; if (spec.input === "date") return new Date().toISOString().split("T")[0]; if (spec.input === "number") return "0"; return ""; } // --- Ausloeser ----------------------------------------------------------- /** * Ein Sinnbild je Geraeteart. * * Gibt der Zeile einen Anker fuers Auge und spart das Wort: ein Tropfen sagt * "Wasser", bevor man "Gartenwasser hinten" gelesen hat. Die Arten stehen so * in actors.type, wie der Geraetesuchlauf sie hinterlaesst; was fehlt, * bekommt den Chip - lieber ein neutrales Zeichen als ein falsches. */ const GERAETE_SYMBOL = { // Alles, was beschattet, traegt dasselbe Zeichen wie im Raum-Modal und in // der Zeitleiste (bi-beschattung, css/solar.css). "Außenjalousie": "bi-beschattung", "Rollladen": "bi-beschattung", "Screen": "bi-beschattung", "Pergola": "bi-beschattung", "ESP32 Thermostat": "bi-thermometer-half", "Temperatur": "bi-thermometer-half", "Heizung": "bi-fire", "Licht": "bi-lightbulb", "WLED": "bi-lightbulb", "Wechselrichter": "bi-sun", "Solaranlage": "bi-sun", "Stromzähler": "bi-lightning-charge", "Meter Digitizer": "bi-speedometer2", "go-eCharger_V4": "bi-ev-station", "ShellyRelay_SHSW-1": "bi-toggle-on", "Bewässerung": "bi-droplet", "Wetter": "bi-cloud-sun", "Regen": "bi-cloud-rain", "Garagentor": "bi-door-open", "Alarm": "bi-bell", "LOGIC": "bi-clock", "MQTT": "bi-broadcast" }; function symbolFuer(dev) { return (dev && GERAETE_SYMBOL[dev.type]) || "bi-cpu"; } /** * Datentypen, bei denen der Browser kein Urteil faellt. * * Ob "Uhrzeit um 07:30" gerade zutrifft, entscheidet nicht ein Vergleich * zweier Zahlen, sondern das Nachholfenster, der Tagesrand und bei `elapsed` * der echte Abstand seit der letzten Ausloesung - all das steht im Runner * (bedingung_erfuellt in autoaction_runner.py). Das hier nachzubauen hiesse, * eine zweite Wahrheit zu pflegen, die irgendwann auseinanderlaeuft. * * Deshalb: bei diesen Typen zeigt die Zeile den Wert, aber keinen Punkt. * Lieber keine Aussage als eine, die manchmal falsch ist. */ const ZEITARTEN = ["time", "date", "datetime", "deltatime", "elapsed"]; /** Die zuletzt geholten Messwerte, nach state_id. */ let autoWerte = {}; let autoWerteTakt = null; const WERTE_TAKT_MS = 8000; function werteIds() { const ids = new Set(); autoModel.groups.forEach(g => g.forEach(b => { if (b.state_id) ids.add(b.state_id); })); return Array.from(ids); } /** * Die laufenden Werte nachholen. * * Ein eigener, winziger Endpunkt statt des ganzen Geraetekatalogs: der waegt * ueber hundert Kilobyte, und gebraucht werden ein paar Zahlen. Faellt die * Anfrage aus, bleibt der letzte Stand stehen - ein Fehlerbanner mitten im * Editor waere schlimmer als eine Zahl, die zehn Sekunden alt ist. */ function werteHolen() { const ids = werteIds(); if (!ids.length) return; fetch("./ajax/AutoAction.php?action=werte&ids=" + ids.join(","), { headers: { "Requested-With-Ajax": "ajax" } }) .then(a => (a.ok ? a.json() : null)) .then(a => { if (!a || !a.werte || !autoModel) return; autoWerte = a.werte; // Nur nachmalen, nicht neu aufbauen. renderConditions() ersetzt den // ganzen Block - alle acht Sekunden waeren damit eine offene // Geraeteliste weg und der Cursor aus einem Wertfeld, in das man // gerade tippt. Die Struktur aendert der Takt nie, nur die Zahlen. werteZeichnen(); }) .catch(() => {}); } function werteTaktStarten() { werteTaktStoppen(); werteHolen(); autoWerteTakt = setInterval(werteHolen, WERTE_TAKT_MS); } function werteTaktStoppen() { if (autoWerteTakt) clearInterval(autoWerteTakt); autoWerteTakt = null; } /** * Was sagt dieser Messwert gerade, und trifft die Bedingung damit zu? * * `kennt` ist falsch, wenn wir es nicht entscheiden koennen - siehe * ZEITARTEN oben - oder wenn noch gar kein Wert vorliegt. */ function zustandVon(state, bed) { const roh = autoWerte[bed.state_id]; const wert = roh ? roh.value : (state ? state.value : null); const einheit = (roh && roh.unit) || (state && state.unit) || ""; if (wert === null || wert === undefined || wert === "") { return { text: "–", kennt: false }; } const text = wertText(state, wert) + (einheit ? " " + einheit : ""); if (!state || ZEITARTEN.indexOf(state.type) >= 0) { return { text: text, kennt: false }; } return { text: text, kennt: true, erfuellt: vergleicht(state, wert, bed) }; } /** * Ein Rohwert so schreiben, wie ihn das Wertfeld daneben auch schreibt. * * In der Datenbank steht "false", im Auswahlfeld daneben steht "NEIN" - und * "gerade false" neben "= NEIN" liest sich, als waeren es zwei verschiedene * Dinge. Ebenso beim Dezimalpunkt: die Zahl kommt als "13.9" herein, im Rest * der Oberflaeche steht ein Komma. */ function wertText(state, wert) { const s = String(wert); if (!state) return s; if (state.type === "bool") { return ["true", "1", "ja", "an"].indexOf(s.trim().toLowerCase()) >= 0 ? "JA" : "NEIN"; } if (state.options && state.options.length) { const treffer = state.options.find(o => String(o.value) === s); if (treffer) return treffer.label; } if (state.type === "float") return s.replace(".", ","); return s; } /** Der Vergleich fuer die Typen, bei denen er eindeutig ist. */ function vergleicht(state, ist, bed) { if (state.type === "bool") { const ja = v => ["true", "1", "ja", "an"].indexOf(String(v).trim().toLowerCase()) >= 0; return bed.operator === "!=" ? ja(ist) !== ja(bed.value) : ja(ist) === ja(bed.value); } const zahl = state.type === "integer" || state.type === "float"; // Komma als Dezimaltrennzeichen kommt aus Geraeten, die deutsch formatieren. const a = zahl ? Number(String(ist).replace(",", ".")) : String(ist); const b = zahl ? Number(String(bed.value).replace(",", ".")) : String(bed.value); if (zahl && (Number.isNaN(a) || Number.isNaN(b))) return false; switch (bed.operator) { case "=": return a === b; case "!=": return a !== b; case ">": return a > b; case "<": return a < b; case ">=": return a >= b; case "<=": return a <= b; default: return false; } } /** Die Balkenreihe, ein Segment je Bedingung. */ function segmenteFuellen(reihe, zustaende) { reihe.innerHTML = ""; zustaende.forEach(z => { const s = document.createElement("span"); s.className = "auto-segment" + (z.kennt && z.erfuellt ? " an" : z.kennt ? "" : " offen"); s.title = z.kennt ? (z.erfuellt ? "trifft zu" : "trifft gerade nicht zu") : "hängt am Zeitpunkt – das entscheidet der Runner"; reihe.appendChild(s); }); } function segmente(zustaende) { const reihe = document.createElement("span"); reihe.className = "auto-segmente d-inline-flex gap-1"; segmenteFuellen(reihe, zustaende); return reihe; } /** Wie eine Bedingung gerade dasteht - fuer eine ganze Gruppe. */ function zustaendeVon(gruppe) { return gruppe.map(bed => { const treffer = stateById(bed.state_id); return zustandVon(treffer ? treffer.state : null, bed); }); } /** * Die laufenden Zahlen nachziehen, ohne etwas neu zu bauen. * * Angefasst wird nur, was sich wirklich aendert: der Wert neben jeder * Bedingung, ihr Punkt, die Balkenreihen und der Stand im Kopf des Bandes. * Alles andere - Auswahlfelder, Eingaben, eine offene Geraeteliste - bleibt * unberuehrt stehen. */ function werteZeichnen() { const ziel = document.getElementById("condBlocks"); if (!ziel || !autoModel) return; const proGruppe = []; autoModel.groups.forEach((gruppe, gi) => { const zustaende = zustaendeVon(gruppe); proGruppe.push(zustaende); const block = ziel.querySelector('[data-gruppe="' + gi + '"]'); if (!block) return; gruppe.forEach((bed, bi) => { const zeile = block.querySelector('.auto-zeile[data-bed="' + bi + '"]'); if (!zeile) return; zeileZeichnen(zeile, zustaende[bi]); }); const leiste = block.querySelector(".auto-segmente"); if (leiste) segmenteFuellen(leiste, zustaende); }); renderStand(proGruppe); } /** Wert und Punkt einer einzelnen Zeile. */ function zeileZeichnen(zeile, zustand) { const jetzt = zeile.querySelector(".wert-jetzt"); if (jetzt) { jetzt.className = "wert-jetzt" + (zustand.kennt && zustand.erfuellt ? " erfuellt" : ""); jetzt.innerHTML = "gerade " + textSicher(zustand.text) + ""; } const punkt = zeile.querySelector(".punkt"); if (punkt) { punkt.className = "punkt" + (zustand.kennt && zustand.erfuellt ? " an" : ""); punkt.title = zustand.kennt ? (zustand.erfuellt ? "trifft zu" : "trifft gerade nicht zu") : "hängt am Zeitpunkt – das entscheidet der Runner"; punkt.style.borderStyle = zustand.kennt ? "" : "dashed"; } } /** * Zeichnet alle UND-Bloecke. Jeder Block hat sein eigenes "noch eine * Bedingung", denn mit einem einzigen Knopf ganz unten kaeme man immer nur * an den letzten Block heran und koennte eine bestehende Alternative nie * mehr ergaenzen. */ function renderConditions() { const ziel = document.getElementById("condBlocks"); if (!ziel) return; ziel.innerHTML = ""; const proGruppe = []; autoModel.groups.forEach((gruppe, gi) => { if (gi > 0) { const trenner = document.createElement("div"); trenner.className = "d-flex align-items-center my-2 text-body-secondary"; trenner.innerHTML = "
" + "oder" + "
"; ziel.appendChild(trenner); } const block = document.createElement("div"); block.className = "border rounded-3 p-1"; // Kennung, damit werteZeichnen() die Zeile spaeter wiederfindet, ohne // sie neu zu bauen. block.dataset.gruppe = gi; const zustaende = zustaendeVon(gruppe); proGruppe.push(zustaende); if (gruppe.length > 1) { const kopf = document.createElement("div"); kopf.className = "d-flex align-items-center gap-2 px-2 pt-1 small text-body-secondary"; kopf.innerHTML = "alles davon"; kopf.appendChild(segmente(zustaende)); block.appendChild(kopf); } gruppe.forEach((bed, bi) => block.appendChild(conditionRow(gi, bi, bed, zustaende[bi]))); const add = document.createElement("button"); add.type = "button"; add.className = "btn btn-link btn-sm text-body-secondary text-decoration-none"; add.innerHTML = " noch eine Bedingung"; add.onclick = () => { gruppe.push(leereBedingung()); renderConditions(); }; block.appendChild(add); ziel.appendChild(block); }); renderStand(proGruppe); } function conditionRow(gi, bi, bed, zustand) { const zeile = document.createElement("div"); zeile.className = "auto-zeile"; zeile.dataset.bed = bi; const treffer = stateById(bed.state_id); const dev = treffer ? treffer.device : null; const state = treffer ? treffer.state : null; const sym = document.createElement("i"); sym.className = "bi " + symbolFuer(dev) + " auto-symbol"; zeile.appendChild(sym); // Geraet klein und matt ueber dem Messwert: es beantwortet "woher", nicht // "was". Frueher standen beide als gleich breite Kaesten nebeneinander, // und "Gartenwasser hinten" brach dabei jede zweite Zeile auf doppelte // Hoehe um. const was = document.createElement("div"); was.className = "auto-was"; const geraetGewaehlt = neu => { bed.state_id = neu.states[0].id; bed.operator = neu.states[0].operators[0].value; bed.value = standardWert(neu.states[0]); renderConditions(); }; const auswahl = autoDevices.filter(d => d.states.length); if (!dev) { // Solange kein Geraet feststeht, gibt es genau eine sinnvolle Handlung - // und die bekommt die ganze Zeile. // // Vorher standen hier zwei Zeilen: ein klein geschriebenes "Gerät wählen" // und darunter ein totes Auswahlfeld "Messwert". Wer auf das Feld klickte // - und das ist das groessere, auffaelligere von beiden -, bekam keine // Reaktion und musste selbst darauf kommen, dass die matte Zeile darueber // die eigentliche Schaltflaeche ist. Ein Bedienelement, das auf Klick // nichts tut, ist schlechter als gar keines. const waehlen = geraeteWaehler(auswahl, 0, geraetGewaehlt, "feld-als-text auto-geraet"); waehlen.classList.remove("text-body-secondary"); was.appendChild(waehlen); zeile.classList.add("leer"); zeile.appendChild(was); } else { was.appendChild(geraeteWaehler(auswahl, dev.id, geraetGewaehlt, "auto-geraet")); const messwert = document.createElement("select"); messwert.className = "feld-als-text auto-messwert"; optionListe(messwert, dev.states.map(s => ({ value: s.id, text: s.name })), bed.state_id); messwert.onchange = () => { const neu = dev.states.find(s => s.id === Number(messwert.value)); bed.state_id = neu.id; bed.operator = neu.operators[0].value; bed.value = standardWert(neu); renderConditions(); }; was.appendChild(messwert); zeile.appendChild(was); } if (state) { const op = document.createElement("select"); op.className = "form-select form-select-sm text-center auto-op"; optionListe(op, state.operators.map(o => ({ value: o.value, text: o.label })), bed.operator); op.onchange = () => { bed.operator = op.value; renderConditions(); }; zeile.appendChild(op); // Wert und Einheit in einem Feld: die Einheit ist keine Eingabe und // brauchte trotzdem einen eigenen Kasten in der Reihe. const wertfeld = wertFeld(state, bed.value); wertfeld.classList.add(wertfeld.tagName === "SELECT" ? "form-select-sm" : "form-control-sm"); wertfeld.onchange = () => { bed.value = wertfeld.value; renderConditions(); }; if (state.unit) { const huelle = document.createElement("div"); huelle.className = "input-group input-group-sm auto-wert"; huelle.appendChild(wertfeld); const einheit = document.createElement("span"); einheit.className = "input-group-text"; einheit.textContent = state.unit; huelle.appendChild(einheit); zeile.appendChild(huelle); } else { wertfeld.classList.add("auto-wert"); zeile.appendChild(wertfeld); } const jetzt = document.createElement("span"); jetzt.className = "wert-jetzt" + (zustand.kennt && zustand.erfuellt ? " erfuellt" : ""); jetzt.innerHTML = "gerade " + textSicher(zustand.text) + ""; zeile.appendChild(jetzt); const punkt = document.createElement("span"); punkt.className = "punkt" + (zustand.kennt && zustand.erfuellt ? " an" : ""); punkt.title = zustand.kennt ? (zustand.erfuellt ? "trifft zu" : "trifft gerade nicht zu") : "haengt am Zeitpunkt – das entscheidet der Runner"; if (!zustand.kennt) punkt.style.borderStyle = "dashed"; zeile.appendChild(punkt); } const weg = document.createElement("button"); weg.type = "button"; weg.className = "btn btn-sm auto-weg"; weg.title = "Bedingung entfernen"; weg.innerHTML = ""; weg.onclick = () => { autoModel.groups[gi].splice(bi, 1); // Ein leerer Block hat keine Bedeutung mehr und verschwindet mit. if (!autoModel.groups[gi].length) autoModel.groups.splice(gi, 1); renderConditions(); }; zeile.appendChild(weg); return zeile; } /** * Der Stand im Kopf des Bandes. * * Steht dort, wo frueher der Satz stand, der die Regel Wort fuer Wort * nacherzaehlte - fuenf fette Zeilen, die genau das wiederholten, was * darueber schon zu sehen war. Diese Zeile beantwortet stattdessen die * Frage, die man wirklich hat: warum laeuft die Automatik gerade nicht? */ function renderStand(proGruppe) { const ziel = document.getElementById("condStand"); if (!ziel) return; const gefuellt = proGruppe.filter(z => z.length); if (!gefuellt.length) { ziel.innerHTML = "" + "Noch kein Auslöser – die Automatik würde nie starten."; return; } // Das Geruest nur einmal bauen. Wuerde es der Takt jedes Mal ersetzen, // finge die Atembewegung des Punktes alle acht Sekunden von vorn an - ein // Zucken, das aussieht wie ein Fehler. if (!ziel.querySelector(".punkt")) { ziel.innerHTML = ""; ziel.appendChild(document.createElement("span")).className = "punkt atmet"; ziel.appendChild(document.createElement("span")).className = "small text-body-secondary"; ziel.appendChild(segmente([])); } // Bei mehreren Gruppen zaehlt die, die am weitesten ist: es genuegt ja, // dass eine von ihnen vollstaendig zutrifft. let beste = gefuellt[0]; let bestZahl = -1; gefuellt.forEach(z => { const n = z.filter(x => x.kennt && x.erfuellt).length; if (n > bestZahl) { bestZahl = n; beste = z; } }); const alle = beste.every(x => x.kennt && x.erfuellt); const offen = beste.some(x => !x.kennt); const punkt = ziel.querySelector(".punkt"); punkt.className = "punkt atmet" + (alle ? " an" : ""); const text = ziel.querySelector("span.small"); text.textContent = alle && !offen ? "alles erfüllt" : "gerade " + bestZahl + " von " + beste.length + (gefuellt.length > 1 ? " in der besten Gruppe" : ""); text.title = offen ? "Zeitbedingungen sind nicht mitgezählt – die entscheidet der Runner." : ""; segmenteFuellen(ziel.querySelector(".auto-segmente"), beste); } // --- Aktionen ------------------------------------------------------------ function renderActions() { const ziel = document.getElementById("actionList"); ziel.innerHTML = ""; autoModel.actions.forEach((aktion, ai) => ziel.appendChild(actionRow(ai, aktion))); } function actionRow(ai, aktion) { // Dasselbe Raster wie die Bedingungszeile, nur mit anderen Spalten: // Sinnbild, was geschaltet wird, die Werte dazu, Papierkorb. Damit erben // Aktionen alles mit, was drueben schon stimmt - Gehaenge beim // Ueberfahren, abwechselnde Hinterlegung, das Umbrechen auf schmalen // Schirmen. const zeile = document.createElement("div"); zeile.className = "auto-zeile tat"; const treffer = commandById(aktion.command_id); const dev = treffer ? treffer.device : null; const cmd = treffer ? treffer.command : null; const sym = document.createElement("i"); sym.className = "bi " + symbolFuer(dev) + " auto-symbol"; zeile.appendChild(sym); const was = document.createElement("div"); was.className = "auto-was"; const geraetGewaehlt = neu => { aktion.command_id = neu.commands[0].id; aktion.params = standardParams(neu.commands[0]); renderActions(); }; const auswahl = autoDevices.filter(d => d.commands.length); if (!dev) { // Wie in der Bedingungszeile: solange kein Geraet feststeht, gibt es nur // eine Handlung, und die bekommt die ganze Breite. const waehlen = geraeteWaehler(auswahl, 0, geraetGewaehlt, "feld-als-text auto-geraet"); waehlen.classList.remove("text-body-secondary"); was.appendChild(waehlen); zeile.classList.add("leer"); zeile.appendChild(was); } else { was.appendChild(geraeteWaehler(auswahl, dev.id, geraetGewaehlt, "auto-geraet")); const befehl = document.createElement("select"); befehl.className = "feld-als-text auto-messwert"; optionListe(befehl, dev.commands.map(c => ({ value: c.id, text: c.name })), aktion.command_id); befehl.onchange = () => { const neu = dev.commands.find(c => c.id === Number(befehl.value)); aktion.command_id = neu.id; aktion.params = standardParams(neu); renderActions(); }; was.appendChild(befehl); zeile.appendChild(was); // Je Parameter ein Feld - wie viele es sind, sagt das Geraet. Frueher gab // es vier feste Felder "Wert 1" bis "Wert 4", die je nach Kommando // versteckt wurden, ihren Inhalt aber trotzdem mitschickten. const werte = document.createElement("div"); werte.className = "auto-werte d-flex flex-wrap align-items-center gap-2"; if (cmd) { cmd.params.forEach(p => { const paar = document.createElement("div"); paar.className = "d-flex align-items-center gap-1"; const name = document.createElement("span"); name.className = "small text-body-secondary text-nowrap"; name.textContent = p.name; paar.appendChild(name); const feld = wertFeld(p, aktion.params[p.id] !== undefined ? aktion.params[p.id] : standardWert(p)); feld.classList.add(feld.tagName === "SELECT" ? "form-select-sm" : "form-control-sm"); feld.onchange = () => { aktion.params[p.id] = feld.value; }; paar.appendChild(feld); werte.appendChild(paar); }); } zeile.appendChild(werte); } const weg = document.createElement("button"); weg.type = "button"; weg.className = "btn btn-sm auto-weg"; weg.title = "Aktion entfernen"; weg.innerHTML = ""; weg.onclick = () => { autoModel.actions.splice(ai, 1); renderActions(); }; zeile.appendChild(weg); return zeile; } function standardParams(cmd) { const params = {}; cmd.params.forEach(p => { params[p.id] = standardWert(p); }); return params; } // --- Rahmenbedingungen --------------------------------------------------- const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; const WOCHENTAGE_LANG = ["montags", "dienstags", "mittwochs", "donnerstags", "freitags", "samstags", "sonntags"]; /** * Ein Schalter im Bootstrap-Stil: verstecktes Eingabefeld plus Etikett, die * beide direkt im .btn-group stehen muessen - ein umschliessendes
* bricht die Gruppe optisch auf. */ function schalter(ziel, art, id, name, text, titel, gedrueckt, beiKlick) { const feld = document.createElement("input"); feld.type = art; // "checkbox" oder "radio" feld.className = "btn-check"; feld.id = id; feld.autocomplete = "off"; if (name) feld.name = name; feld.checked = gedrueckt; feld.onchange = beiKlick; const etikett = document.createElement("label"); etikett.className = "btn btn-outline-secondary"; etikett.htmlFor = id; etikett.textContent = text; if (titel) etikett.title = titel; ziel.appendChild(feld); ziel.appendChild(etikett); } function renderWeekdays() { const ziel = document.getElementById("weekdayGroup"); ziel.innerHTML = ""; WOCHENTAGE.forEach((tag, i) => { schalter(ziel, "checkbox", "day" + i, null, tag, WOCHENTAGE_LANG[i], !!(autoModel.weekdays & (1 << i)), e => { if (e.target.checked) autoModel.weekdays |= (1 << i); else autoModel.weekdays &= ~(1 << i); renderRahmen(); }); }); } /** Ferien und Feiertage: nie - egal - immer. */ function renderKalendertage(zielId, auswahl, feld) { const ziel = document.getElementById(zielId); ziel.innerHTML = ""; auswahl.forEach(o => { schalter(ziel, "radio", zielId + "-" + o.value, zielId, o.label, o.title, autoModel[feld] === o.value, () => { autoModel[feld] = o.value; renderRahmen(); }); }); } /** * Die drei Vorlagen setzen den ganzen Tagesrahmen, nicht nur die Wochentage: * "werktags" heisst auch "nicht an Feiertagen". */ function renderTagVorlagen() { const ziel = document.getElementById("tagVorlagen"); ziel.innerHTML = ""; autoTagVorlagen.forEach(v => { const knopf = document.createElement("button"); knopf.type = "button"; // Die Vorlage, die gerade zutrifft, steht gedrueckt da. Sonst sagen die // drei Knoepfe nur, was man tun koennte, und nie, wo man steht - und // "werktags" ist immerhin drei Einstellungen auf einmal. const passt = autoModel.weekdays === v.weekdays && autoModel.on_vacation === v.on_vacation && autoModel.on_holiday === v.on_holiday; knopf.className = "btn btn-outline-secondary" + (passt ? " active" : ""); knopf.textContent = v.label; knopf.title = v.title; knopf.onclick = () => { // Kein stopPropagation noetig - und es haette auch nichts genutzt: die // Vorlagen stehen bewusst ausserhalb des Elements mit data-bs-toggle, // siehe den Kommentar an der Kopfzeile in ajax/AutoAction.php. autoModel.weekdays = v.weekdays; autoModel.on_vacation = v.on_vacation; autoModel.on_holiday = v.on_holiday; renderRahmen(); }; ziel.appendChild(knopf); }); } /** "montags, mittwochs und freitags" */ function aufzaehlung(teile) { if (teile.length < 2) return teile[0] || ""; return teile.slice(0, -1).join(", ") + " und " + teile[teile.length - 1]; } /** * Die Wochentagsmaske als Wort. Drei und mehr aufeinanderfolgende Tage * werden zusammengezogen ("montags bis freitags"), zwei nicht - "montags und * dienstags und samstags" haette sonst zwei verschiedene "und" nebeneinander. */ function tageText(maske) { if (maske === 127) return "täglich"; const laeufe = []; for (let i = 0; i < 7; i++) { if (!(maske & (1 << i))) continue; const letzter = laeufe[laeufe.length - 1]; if (letzter && letzter[1] === i - 1) letzter[1] = i; else laeufe.push([i, i]); } const teile = []; laeufe.forEach(([a, b]) => { if (b - a >= 2) teile.push(WOCHENTAGE_LANG[a] + " bis " + WOCHENTAGE_LANG[b]); else for (let i = a; i <= b; i++) teile.push(WOCHENTAGE_LANG[i]); }); return aufzaehlung(teile); } /** * Der ganze Tagesrahmen als Satz. * * Das ist der eigentliche Gewinn gegenueber den Schaltern: man muss das * Bedienelement nicht entziffern, sondern liest, was dabei herausgekommen * ist. Leerer Rueckgabewert heisst "laeuft nie" - dann steht statt des * Satzes eine Warnung da, wie beim fehlenden Ausloeser. */ function rahmenText() { const tage = autoModel.weekdays; const dazu = []; const verbote = []; if (autoModel.on_vacation === 2) dazu.push("an allen Ferientagen"); if (autoModel.on_holiday === 2) dazu.push("an allen Feiertagen"); if (autoModel.on_vacation === 0) verbote.push("in den Ferien"); if (autoModel.on_holiday === 0) verbote.push("an Feiertagen"); if (!tage && !dazu.length) return ""; let satz; if (!tage) { satz = "Läuft nur " + aufzaehlung(dazu); } else { satz = "Läuft " + tageText(tage); // Bei sieben angehakten Tagen traegt "dazu an allen Feiertagen" nichts // bei - die sind schon dabei. if (dazu.length && tage !== 127) satz += ", dazu " + aufzaehlung(dazu); } if (verbote.length) satz += ", aber nicht " + aufzaehlung(verbote); // Vorabend: derselbe Satz, nur fuer morgen gerechnet. Vorne, damit auch // "aber nicht an Feiertagen" als Folgetag gelesen wird. if (autoModel.next_day) satz = satz.replace(/^Läuft /, "Läuft am Vortag, gerechnet für den Folgetag: "); return satz + "."; } /** * Der Rahmen in einer Zeile, fuer den zusammengefalteten Zustand. * * Solange nichts von der Vorgabe abweicht, steht dort matt "täglich · rund * um die Uhr" und man liest darueber hinweg. Weicht etwas ab, wird die Zeile * hell - dann steht dort etwas, das man wissen sollte. */ function rahmenKurzText() { const teile = [tageText(autoModel.weekdays) || "an keinem Tag"]; if (autoModel.next_day) teile[0] = "Vorabend von " + teile[0]; if (autoModel.on_vacation === 0) teile.push("nicht in den Ferien"); if (autoModel.on_vacation === 2) teile.push("dazu Ferientage"); if (autoModel.on_holiday === 0) teile.push("nicht an Feiertagen"); if (autoModel.on_holiday === 2) teile.push("dazu Feiertage"); teile.push(autoModel.window_from === "00:00" && autoModel.window_to === "23:59" ? "rund um die Uhr" : autoModel.window_from + "–" + autoModel.window_to); if (autoModel.hold_secs > 0) teile.push("erst nach " + haltezeitKurz(autoModel.hold_secs)); if (autoModel.lockout_secs > 0) { teile.push("Sperre " + (autoModel.lockout_secs >= 60 ? Math.floor(autoModel.lockout_secs / 60) + " Min." : autoModel.lockout_secs + " Sek.")); } if (autoModel.once_per_day) teile.push("nur einmal am Tag"); if (autoModel.force_once) teile.push("am Ende auf jeden Fall"); return teile.join(" · "); } /** Wie haltezeitKurz() in automations.php: Minuten bis eine Stunde, dann Stunden. */ function haltezeitKurz(sekunden) { if (sekunden < 3600) return Math.floor(sekunden / 60) + " Min."; return (sekunden / 3600).toFixed(1).replace(/[.,]0$/, "").replace(".", ",") + " Std."; } function rahmenIstVorgabe() { return autoModel.weekdays === 127 && autoModel.on_vacation === 1 && autoModel.on_holiday === 1 && !autoModel.next_day && autoModel.window_from === "00:00" && autoModel.window_to === "23:59" && !autoModel.once_per_day && !autoModel.hold_secs && !autoModel.force_once; } /** Schalter neu zeichnen und die beiden Zusammenfassungen nachziehen. */ function renderRahmen() { renderTagVorlagen(); renderWeekdays(); renderKalendertage("ferienGroup", autoFerien, "on_vacation"); renderKalendertage("feiertagGroup", autoFeiertage, "on_holiday"); const text = rahmenText(); const satz = document.getElementById("rahmenSummary"); // Ohne waehlbaren Tag laeuft die Automatik nie - dann ist der Satz doch // eine Warnung und faerbt sich entsprechend. satz.className = "callout py-2 px-3 mt-0 mb-3 " + (text ? "callout-primary" : "callout-warning"); satz.innerHTML = text ? "" + text + "" : "Kein Tag ausgewählt – die Automatik würde nie laufen."; const kurz = document.getElementById("rahmenKurz"); if (kurz) { kurz.textContent = rahmenKurzText(); kurz.className = rahmenIstVorgabe() ? "text-body-secondary" : "text-body"; } } function fuelleFormular() { const sperre = document.getElementById("lockoutSecs"); optionListe(sperre, autoLockouts.map(o => ({ value: o.value, text: o.label })), autoModel.lockout_secs); document.getElementById("holdMin").value = autoModel.hold_secs / 60; document.getElementById("autoName").value = autoModel.name; // Kennt die Liste die Etage nicht, bleibt eine Auswahlliste sonst leer // stehen und schickt beim Speichern "" mit. const etage = document.getElementById("autoFloor"); etage.value = autoModel.floor; if (etage.selectedIndex < 0) { etage.selectedIndex = 0; autoModel.floor = etage.value; } document.getElementById("tSpanFrom").value = autoModel.window_from; document.getElementById("tSpanTo").value = autoModel.window_to; document.getElementById("runOnce").checked = autoModel.force_once; document.getElementById("oncePerDay").checked = autoModel.once_per_day; document.getElementById("nextDay").checked = autoModel.next_day; renderRahmen(); rahmenFelderVerdrahten(); renderConditions(); renderActions(); werteTaktStarten(); } /** Die Felder, die direkt am Formular haengen, ins Modell zurueckholen. */ function leseFormular() { autoModel.name = document.getElementById("autoName").value.trim(); autoModel.floor = document.getElementById("autoFloor").value; autoModel.window_from = document.getElementById("tSpanFrom").value; autoModel.window_to = document.getElementById("tSpanTo").value; // weekdays, on_vacation und on_holiday stehen schon im Modell: die // Schalter schreiben beim Klicken direkt hinein, weil der Satz darunter // sofort nachziehen muss. autoModel.force_once = document.getElementById("runOnce").checked; autoModel.once_per_day = document.getElementById("oncePerDay").checked; autoModel.next_day = document.getElementById("nextDay").checked; autoModel.lockout_secs = Number(document.getElementById("lockoutSecs").value); // Minuten im Feld, Sekunden im Modell - und nichts Unmoegliches: ein // leeres Feld ist 0, ein Tag ist die Obergrenze (siehe haltezeitPruefen()). const halten = Math.round(Number(document.getElementById("holdMin").value) || 0); autoModel.hold_secs = Math.min(Math.max(halten, 0), 1440) * 60; } /** * Die Felder im aufgeklappten Rahmen aendern die Kurzfassung im Kopf mit. * Ohne das stuende dort "rund um die Uhr", waehrend darunter 08:45 steht. */ function rahmenFelderVerdrahten() { ["tSpanFrom", "tSpanTo", "lockoutSecs", "holdMin", "runOnce", "oncePerDay", "nextDay"].forEach(id => { const feld = document.getElementById(id); if (feld) feld.addEventListener("change", () => { leseFormular(); renderRahmen(); }); }); // Bei der Haltezeit schon beim Tippen: aus "210" wird in der Kopfzeile // "erst nach 3,5 Std." - genau dafuer gibt man die Zahl ein. "change" // allein kaeme erst beim Verlassen des Feldes. document.getElementById("holdMin") .addEventListener("input", () => { leseFormular(); renderRahmen(); }); // Zugeklappt steht in der Kopfzeile, was gilt; aufgeklappt stehen dort die // drei Vorlagen. Beides gleichzeitig waere eine volle Zeile aus // Kleinteilen - genau die Unruhe, die der zusammengefaltete Rahmen // vermeiden soll. const koerper = document.getElementById("rahmenBody"); if (!koerper) return; const umschalten = offen => { [["rahmenKurz", offen], ["rahmenAendern", offen], ["tagVorlagen", !offen], ["rahmenZu", !offen]].forEach(([id, versteckt]) => { const el = document.getElementById(id); if (el) el.hidden = versteckt; }); }; koerper.addEventListener("show.bs.collapse", () => umschalten(true)); koerper.addEventListener("hide.bs.collapse", () => umschalten(false)); } // --- Modal --------------------------------------------------------------- function loadAutomatic() { const daten = JSON.parse(document.getElementById("autoActionData").textContent); autoDevices = daten.devices; autoLockouts = daten.lockouts; autoFerien = daten.ferien || []; autoFeiertage = daten.feiertage || []; autoTagVorlagen = daten.tagvorlagen || []; autoRooms = daten.rooms || []; const a = daten.automation; autoFloorVorher = a.floor; autoModel = { id: a.id, name: a.name, floor: a.floor, enabled: a.enabled, window_from: a.window_from, window_to: a.window_to, weekdays: a.weekdays, on_vacation: a.on_vacation, on_holiday: a.on_holiday, next_day: !!a.next_day, force_once: a.force_once, once_per_day: a.once_per_day, lockout_secs: a.lockout_secs, hold_secs: a.hold_secs || 0, groups: [], actions: a.actions.map(x => ({ command_id: x.command_id, params: Object.assign({}, x.params) })) }; // Die flache Liste aus der Datenbank wieder in Bloecke fassen. Die // Gruppennummern kommen aufsteigend und dicht, siehe saveAutomation(). const nachGruppe = new Map(); a.conditions.forEach(c => { if (!nachGruppe.has(c.group)) nachGruppe.set(c.group, []); nachGruppe.get(c.group).push({ state_id: c.state_id, operator: c.operator, value: c.value }); }); autoModel.groups = Array.from(nachGruppe.values()); // Eine neue Automatik startet mit je einer leeren Zeile, sonst steht man // vor einem leeren Formular ohne erkennbaren Anfang. if (!autoModel.groups.length) autoModel.groups = [[leereBedingung()]]; if (!autoModel.actions.length) autoModel.actions = [leereAktion()]; fuelleFormular(); } function openAutoActionModal(params) { const contentURL = "./ajax/AutoAction.php" + params; document.getElementById("modal-title").innerHTML = params.search("id=") > 0 ? "Automatik bearbeiten" : "Neue Automatik anlegen"; // Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne // Zuhoerer stellt sicher, dass nicht auch noch der Handler eines vorher // geoeffneten Modals mitfeuert. const alt = document.getElementById("modalSaveBtn"); const btn = alt.cloneNode(true); alt.replaceWith(btn); // Das Raum-Modal versteckt ihn; hier wird er gebraucht. btn.hidden = false; btn.addEventListener("click", submitAutoAction); // Der Editor braucht mehr Breite als die Ladedialoge, gibt sie aber wieder // her, damit die anderen Modals unveraendert aussehen. const dialog = document.querySelector("#modalEV .modal-dialog"); dialog.classList.add("modal-xl"); document.getElementById("modalEV").addEventListener("hidden.bs.modal", () => { dialog.classList.remove("modal-xl"); // Sonst fragte der geschlossene Editor weiter im Achtsekundentakt nach // Werten, die niemand mehr sieht. werteTaktStoppen(); }, { once: true }); const modalBodyElement = document.getElementById("modal-body"); modalBodyElement.innerHTML = loadingHTML("Wird geladen..."); modalEV.show(); fetch(contentURL, { method: "GET", headers: { "X-Requested-From-Modal": "a", "Requested-With-Ajax": "ajax" } }) .then(response => response.text()) .then(html => { modalBodyElement.innerHTML = html; loadAutomatic(); document.getElementById("btnAddGroup").addEventListener("click", () => { autoModel.groups.push([leereBedingung()]); renderConditions(); }); document.getElementById("btnAddActor").addEventListener("click", () => { autoModel.actions.push(leereAktion()); renderActions(); }); }) .catch(error => { modalBodyElement.innerHTML = "Konnte den Editor nicht laden: " + error.message; }); } function submitAutoAction() { leseFormular(); const nutzlast = { id: autoModel.id, name: autoModel.name, floor: autoModel.floor, enabled: autoModel.enabled, window_from: autoModel.window_from, window_to: autoModel.window_to, weekdays: autoModel.weekdays, on_vacation: autoModel.on_vacation, on_holiday: autoModel.on_holiday, next_day: autoModel.next_day, force_once: autoModel.force_once, once_per_day: autoModel.once_per_day, lockout_secs: autoModel.lockout_secs, hold_secs: autoModel.hold_secs, conditions: [], actions: autoModel.actions }; autoModel.groups.forEach((gruppe, gi) => { gruppe.forEach(bed => { nutzlast.conditions.push({ group: gi, state_id: bed.state_id, operator: bed.operator, value: bed.value }); }); }); automationRequest("save", nutzlast).then(antwort => { if (antwort.error) { alert(antwort.error); return; } modalEV.hide(); refreshAutomations(nutzlast.floor); // Ein Etagenwechsel verschiebt die Automatik, er kopiert sie nicht - in // der Datenbank steht weiter eine Zeile. Ohne dieses zweite Nachladen // bliebe sie aber in der Tabelle der alten Etage stehen, und genau das // sah wie eine Kopie aus. if (autoFloorVorher && autoFloorVorher !== nutzlast.floor) { refreshAutomations(autoFloorVorher); } }); } // --- Uebersicht ---------------------------------------------------------- function automationRequest(action, body) { return fetch("./ajax/AutoAction.php?action=" + action, { method: "POST", headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" }, body: JSON.stringify(body) }) .then(response => response.json()) .catch(error => ({ error: error.message })); } /** * Die Tabelle einer Etage neu holen. Ohne Etage alle, die es gibt. * * Welche das sind, sagen die Reiter - home.php baut sie aus der Raumtabelle. * Eine feste Liste hier haette beim Aussengelaende still eine Etage * ausgelassen: die Tabelle waere leer geblieben, ohne Fehlermeldung. */ function refreshAutomations(floor) { // Die Zeitleiste zeigt dieselben Automatiken - nach Speichern, Pausieren // oder Loeschen muss sie genauso nachziehen wie die Tabelle. if (typeof zeitleisteLaden === "function") zeitleisteLaden(); const etagen = floor ? [floor] : Array.from(document.querySelectorAll("#actions-tabContent .tab-pane")) .map(pane => pane.id.replace(/^actions-/, "")); etagen.forEach(etage => { const ziel = document.getElementById("actions-" + etage + "-list"); if (!ziel) return; fetch("./ajax/AutoAction.php?action=list&floor=" + etage, { method: "GET", headers: { "Requested-With-Ajax": "ajax" } }) .then(response => response.text()) .then(html => { ziel.innerHTML = html; }) .catch(() => { ziel.innerHTML = "

Liste konnte nicht geladen werden.

"; }); }); } /** * Die von einer Automatik ausgeloesten Zeilen zeigen oder verbergen. * * Sie stehen schon in der Tabelle, nur mit d-none - das spart eine zweite * Anfrage und haelt die Reihenfolge stabil, wenn zwischendurch etwas * pausiert wird. */ function toggleAutomationGroup(id, knopf) { const zeilen = Array.from(document.querySelectorAll(".auto-von-" + id)); if (!zeilen.length) return; const aufklappen = zeilen[0].classList.contains("d-none"); if (aufklappen) { zeilen.forEach(zeile => zeile.classList.remove("d-none")); } else { // Beim Zuklappen muessen auch die Enkel verschwinden. Sonst blieben sie // sichtbar stehen, ohne dass noch zu sehen waere, woran sie haengen. automationGruppeVerbergen(id); } pfeilStellen(knopf, aufklappen); } function automationGruppeVerbergen(id) { document.querySelectorAll(".auto-von-" + id).forEach(zeile => { zeile.classList.add("d-none"); const griff = zeile.querySelector("button[data-gruppe]"); if (griff) { pfeilStellen(griff, false); automationGruppeVerbergen(Number(griff.dataset.gruppe)); } }); } function pfeilStellen(knopf, offen) { const pfeil = knopf && knopf.querySelector("i"); if (pfeil) pfeil.className = offen ? "bi bi-chevron-down" : "bi bi-chevron-right"; } function toggleAutomation(id) { automationRequest("toggle", { id: id }).then(antwort => { if (antwort.error) alert(antwort.error); else refreshAutomations(null); }); } /** * Loeschen - und vorher fragen, was mit den abhaengigen Automatiken wird. * * Ohne diese Frage verschwaende mit dem Ausloeser still die Bedingung des * Nachfolgers (fk_cond_state steht auf ON DELETE CASCADE). Aus "zehn Minuten * nach dem Wecker UND es ist hell" wuerde dann ein blosses "es ist hell" - * eine andere Regel, die niemand angeordnet hat. * * Drei Antworten, deshalb kein confirm(): mitloeschen, abhaengen, abbrechen. */ function deleteAutomation(id, name) { fetch("./ajax/AutoAction.php?action=followers&id=" + id, { headers: { "Requested-With-Ajax": "ajax" } }) .then(antwort => antwort.json()) .then(daten => { const folgen = (daten && daten.followers) || []; if (!folgen.length) { if (confirm("Automatik \"" + name + "\" wirklich löschen?")) { automationLoeschen(id, "abhaengen"); } return; } frageNachfolger(id, name, folgen); }) .catch(() => { // Auch ohne Auskunft loeschbar - dann eben mit der schonenden Variante. if (confirm("Automatik \"" + name + "\" wirklich löschen?")) { automationLoeschen(id, "abhaengen"); } }); } function automationLoeschen(id, modus) { automationRequest("delete", { id: id, mode: modus }).then(antwort => { if (antwort.error) alert(antwort.error); else refreshAutomations(null); }); } /** Der Dialog mit den drei Antworten. Wird bei Bedarf gebaut. */ function frageNachfolger(id, name, folgen) { const alt = document.getElementById("modalAutoLoeschen"); if (alt) alt.remove(); const liste = folgen .map(f => "
  • " + textSicher(f.name) + (f.enabled ? "" : " (pausiert)") + "
  • ") .join(""); const wieViele = folgen.length === 1 ? "Eine Automatik wird" : folgen.length + " Automatiken werden"; const huelle = document.createElement("div"); huelle.innerHTML = ""; const knoten = huelle.firstChild; document.body.appendChild(knoten); const dialog = new bootstrap.Modal(knoten); const waehlen = modus => { dialog.hide(); automationLoeschen(id, modus); }; knoten.querySelector("#btnAutoAbhaengen").addEventListener("click", () => waehlen("abhaengen")); knoten.querySelector("#btnAutoMit").addEventListener("click", () => waehlen("mitloeschen")); knoten.addEventListener("hidden.bs.modal", () => knoten.remove()); dialog.show(); } document.addEventListener("DOMContentLoaded", () => refreshAutomations(null));