/** * 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. */ let autoModel = null; let autoDevices = []; let autoLockouts = []; // --- 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; } /** Erster Messwert des ersten Geraets - Vorbelegung fuer eine neue Zeile. */ function ersterMesswert() { const dev = autoDevices.find(d => d.states.length > 0); return dev ? dev.states[0] : null; } function ersterBefehl() { const dev = autoDevices.find(d => d.commands.length > 0); return dev ? dev.commands[0] : null; } // --- Bausteine fuer die Oberflaeche -------------------------------------- function optionListe(select, eintraege, gewaehlt) { select.innerHTML = ""; eintraege.forEach(e => { const opt = document.createElement("option"); opt.value = e.value; opt.text = e.text; if (String(e.value) === String(gewaehlt)) opt.selected = true; select.appendChild(opt); }); } /** * 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 ----------------------------------------------------------- /** * Zeichnet alle UND-Bloecke. Jeder Block hat sein eigenes "+ 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"); ziel.innerHTML = ""; 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 p-2"; gruppe.forEach((bed, bi) => { if (bi > 0) { const und = document.createElement("div"); und.className = "small fw-bold text-body-secondary ms-1 my-1"; und.textContent = "und"; block.appendChild(und); } block.appendChild(conditionRow(gi, bi, bed)); }); const add = document.createElement("button"); add.type = "button"; add.className = "btn btn-outline-success btn-sm mt-2"; add.innerHTML = " Bedingung"; add.onclick = () => { const state = ersterMesswert(); if (!state) return; gruppe.push({ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }); renderConditions(); }; block.appendChild(add); ziel.appendChild(block); }); renderSummary(); } function conditionRow(gi, bi, bed) { const zeile = document.createElement("div"); zeile.className = "input-group"; const treffer = stateById(bed.state_id); const dev = treffer ? treffer.device : null; const state = treffer ? treffer.state : null; // Geraet const geraet = document.createElement("select"); geraet.className = "form-select"; optionListe(geraet, autoDevices.filter(d => d.states.length) .map(d => ({ value: d.id, text: d.name })), dev ? dev.id : ""); geraet.onchange = () => { const neu = deviceById(Number(geraet.value)); if (!neu || !neu.states.length) return; bed.state_id = neu.states[0].id; bed.operator = neu.states[0].operators[0].value; bed.value = standardWert(neu.states[0]); renderConditions(); }; zeile.appendChild(geraet); // Messwert - nur zeigen, wenn das Geraet mehr als einen hat if (dev && dev.states.length > 1) { const messwert = document.createElement("select"); messwert.className = "form-select"; optionListe(messwert, dev.states.map(s => ({ value: s.id, text: s.name + (s.value ? " (= " + s.value + ")" : "") })), 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(); }; zeile.appendChild(messwert); } if (state) { // Operator. Eine Auswahlliste statt des frueheren Umschalt-Knopfes: der // zeigte nur seinen aktuellen Zustand, welche Vergleiche es sonst noch // gibt, fand man erst durch Klicken heraus. const op = document.createElement("select"); // Feste, schmale Breite: der Pfeil der Auswahlliste braucht Platz, sonst // schiebt er das Zeichen aus dem sichtbaren Bereich. op.className = "form-select flex-grow-0 text-center"; op.style.flex = "0 0 5.5rem"; optionListe(op, state.operators.map(o => ({ value: o.value, text: o.label })), bed.operator); op.onchange = () => { bed.operator = op.value; renderSummary(); }; zeile.appendChild(op); const wert = wertFeld(state, bed.value); wert.onchange = () => { bed.value = wert.value; renderSummary(); }; zeile.appendChild(wert); if (state.unit) { const einheit = document.createElement("span"); einheit.className = "input-group-text"; einheit.textContent = state.unit; zeile.appendChild(einheit); } } const weg = document.createElement("button"); weg.type = "button"; weg.className = "btn btn-outline-danger"; 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; } /** * Derselbe Satz, den auch die Uebersicht und der Runner benutzen. Er steht * unter den Bloecken, damit man die Klammerung nicht aus der Optik erschliessen * muss, sondern schwarz auf weiss liest. */ function conditionText() { const teile = autoModel.groups.filter(g => g.length).map(gruppe => { const text = gruppe.map(bed => { const treffer = stateById(bed.state_id); if (!treffer) return "?"; let s = treffer.device.name + ": " + treffer.state.name + " " + operatorLabel(treffer.state, bed.operator) + " " + bed.value; if (treffer.state.unit) s += " " + treffer.state.unit; return s; }).join(" und "); return (autoModel.groups.length > 1 && gruppe.length > 1) ? "(" + text + ")" : text; }); return teile.join(" oder "); } function renderSummary() { const text = conditionText(); document.getElementById("condSummary").innerHTML = text ? "Löst aus, wenn " + text.replace(/&/g, "&").replace(/." : "Noch kein Auslöser – die Automatik würde nie starten."; } // --- Aktionen ------------------------------------------------------------ function renderActions() { const ziel = document.getElementById("actionList"); ziel.innerHTML = ""; autoModel.actions.forEach((aktion, ai) => ziel.appendChild(actionRow(ai, aktion))); } function actionRow(ai, aktion) { const zeile = document.createElement("div"); zeile.className = "input-group mb-2"; const treffer = commandById(aktion.command_id); const dev = treffer ? treffer.device : null; const cmd = treffer ? treffer.command : null; const geraet = document.createElement("select"); geraet.className = "form-select"; optionListe(geraet, autoDevices.filter(d => d.commands.length) .map(d => ({ value: d.id, text: d.name })), dev ? dev.id : ""); geraet.onchange = () => { const neu = deviceById(Number(geraet.value)); if (!neu || !neu.commands.length) return; aktion.command_id = neu.commands[0].id; aktion.params = standardParams(neu.commands[0]); renderActions(); }; zeile.appendChild(geraet); if (dev && dev.commands.length > 1) { const befehl = document.createElement("select"); befehl.className = "form-select"; 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(); }; zeile.appendChild(befehl); } // 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. if (cmd) { cmd.params.forEach(p => { const beschriftung = document.createElement("span"); beschriftung.className = "input-group-text"; beschriftung.textContent = p.name; zeile.appendChild(beschriftung); const wert = wertFeld(p, aktion.params[p.id] !== undefined ? aktion.params[p.id] : standardWert(p)); wert.onchange = () => { aktion.params[p.id] = wert.value; }; zeile.appendChild(wert); }); } const weg = document.createElement("button"); weg.type = "button"; weg.className = "btn btn-outline-danger"; 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"]; function renderWeekdays() { const ziel = document.getElementById("weekdayGroup"); ziel.innerHTML = ""; WOCHENTAGE.forEach((tag, i) => { const feld = document.createElement("div"); feld.className = "input-group-text"; feld.innerHTML = "" + ""; feld.querySelector("input").onchange = e => { if (e.target.checked) autoModel.weekdays |= (1 << i); else autoModel.weekdays &= ~(1 << i); }; ziel.appendChild(feld); }); } function fuelleFormular() { const sperre = document.getElementById("lockoutSecs"); optionListe(sperre, autoLockouts.map(o => ({ value: o.value, text: o.label })), autoModel.lockout_secs); 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("actFerien").checked = autoModel.on_vacation; document.getElementById("actFeier").checked = autoModel.on_holiday; document.getElementById("runOnce").checked = autoModel.force_once; renderWeekdays(); renderConditions(); renderActions(); } /** 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; autoModel.on_vacation = document.getElementById("actFerien").checked; autoModel.on_holiday = document.getElementById("actFeier").checked; autoModel.force_once = document.getElementById("runOnce").checked; autoModel.lockout_secs = Number(document.getElementById("lockoutSecs").value); } // --- Modal --------------------------------------------------------------- function loadAutomatic() { const daten = JSON.parse(document.getElementById("autoActionData").textContent); autoDevices = daten.devices; autoLockouts = daten.lockouts; const a = daten.automation; 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, force_once: a.force_once, lockout_secs: a.lockout_secs, 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 einer leeren Bedingung und einer Aktion, // sonst steht man vor einem leeren Formular ohne erkennbaren Anfang. if (!autoModel.groups.length) { const state = ersterMesswert(); if (state) { autoModel.groups = [[{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]]; } } if (!autoModel.actions.length) { const cmd = ersterBefehl(); if (cmd) autoModel.actions = [{ command_id: cmd.id, params: standardParams(cmd) }]; } 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); 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"), { 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", () => { const state = ersterMesswert(); if (!state) return; autoModel.groups.push([{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]); renderConditions(); }); document.getElementById("btnAddActor").addEventListener("click", () => { const cmd = ersterBefehl(); if (!cmd) return; autoModel.actions.push({ command_id: cmd.id, params: standardParams(cmd) }); 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, force_once: autoModel.force_once, lockout_secs: autoModel.lockout_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); }); } // --- 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 drei. */ function refreshAutomations(floor) { const etagen = floor ? [floor] : ["UG", "EG", "OG"]; 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.

"; }); }); } function toggleAutomation(id) { automationRequest("toggle", { id: id }).then(antwort => { if (antwort.error) alert(antwort.error); else refreshAutomations(null); }); } function deleteAutomation(id, name) { if (!confirm("Automatik \"" + name + "\" wirklich löschen?")) return; automationRequest("delete", { id: id }).then(antwort => { if (antwort.error) alert(antwort.error); else refreshAutomations(null); }); } document.addEventListener("DOMContentLoaded", () => refreshAutomations(null));