/**
* 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 = [];
let autoRooms = [];
// --- 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) {
const knopf = document.createElement("button");
knopf.type = "button";
knopf.className = "form-select text-start auto-geraet";
const dev = geraete.find(d => d.id === aktuelleId);
knopf.textContent = dev ? dev.label : "Gerät wählen …";
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) {
const schonOffen = knopf.nextElementSibling
&& knopf.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.
knopf.closest(".input-group").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.
document.addEventListener("click", e => {
if (e.target.closest(".geraete-waehler") || e.target.closest(".form-select.text-start")) 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 -----------------------------------------------------------
/**
* 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 = () => { gruppe.push(leereBedingung()); renderConditions(); };
block.appendChild(add);
ziel.appendChild(block);
});
renderSummary();
}
function conditionRow(gi, bi, bed) {
const zeile = document.createElement("div");
zeile.className = "input-group auto-zeile";
const treffer = stateById(bed.state_id);
const dev = treffer ? treffer.device : null;
const state = treffer ? treffer.state : null;
// Geraet
zeile.appendChild(geraeteWaehler(
autoDevices.filter(d => d.states.length), dev ? dev.id : 0,
neu => {
bed.state_id = neu.states[0].id;
bed.operator = neu.states[0].operators[0].value;
bed.value = standardWert(neu.states[0]);
renderConditions();
}));
// 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 auto-messwert";
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
.map(g => g.filter(bed => stateById(bed.state_id)))
.filter(g => g.length)
.map(gruppe => {
const text = gruppe.map(bed => {
const treffer = stateById(bed.state_id);
let s = treffer.device.label + ": " + 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 auto-zeile";
const treffer = commandById(aktion.command_id);
const dev = treffer ? treffer.device : null;
const cmd = treffer ? treffer.command : null;
zeile.appendChild(geraeteWaehler(
autoDevices.filter(d => d.commands.length), dev ? dev.id : 0,
neu => {
aktion.command_id = neu.commands[0].id;
aktion.params = standardParams(neu.commands[0]);
renderActions();
}));
if (dev && dev.commands.length > 1) {
const befehl = document.createElement("select");
befehl.className = "form-select 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();
};
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;
autoRooms = daten.rooms || [];
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 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"), { 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,
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, 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) {
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.
"; });
});
}
/**
* Zuordnung Geraet -> Raum. Eigenes Modal, weil es kein Teil einer einzelnen
* Automatik ist, sondern einmal fuer alle gilt.
*/
function openRoomModal() {
document.getElementById("modal-title").innerHTML = "Geräte den Räumen zuordnen";
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", speichereRaeume);
const dialog = document.querySelector("#modalEV .modal-dialog");
dialog.classList.add("modal-lg");
document.getElementById("modalEV").addEventListener("hidden.bs.modal",
() => dialog.classList.remove("modal-lg"), { once: true });
const koerper = document.getElementById("modal-body");
koerper.innerHTML = loadingHTML("Wird geladen...");
modalEV.show();
fetch("./ajax/AutoAction.php?action=rooms", {
method: "GET", headers: { "Requested-With-Ajax": "ajax" }
})
.then(a => a.text())
.then(html => {
koerper.innerHTML = html;
const tipps = document.getElementById("btnRoomTipps");
if (tipps) {
tipps.addEventListener("click", () => {
koerper.querySelectorAll("select[data-tipp]").forEach(s => {
if (!s.value) s.value = s.dataset.tipp;
});
tipps.disabled = true;
});
}
})
.catch(fehler => { koerper.innerHTML = "Konnte die Zuordnung nicht laden: " + fehler.message; });
}
function speichereRaeume() {
const rooms = {};
document.querySelectorAll("#modal-body select[data-actor]").forEach(s => {
rooms[s.dataset.actor] = s.value;
});
automationRequest("saveRooms", { rooms: rooms }).then(antwort => {
if (antwort.error) alert(antwort.error);
else modalEV.hide();
});
}
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));