Gegenstueck zur Runner-Seite. Der Editor selbst braucht nichts: das
gerechnete Geraet "Automatiken" fuehrt jede Automatik als Messwert, und
der Geraetewaehler listet ohnehin Geraete und deren Messwerte. Neu sind
nur die Operatoren des Datentyps `elapsed` ("+", "ab +", "vor +") und
sein Eingabefeld.
Drei Stellen wissen trotzdem davon:
pruefeKreis() lehnt beim Speichern ab, was sich mittelbar selbst
ausloesen wuerde. Im Betrieb waere ein Kreis kaum zu bemerken - die
Sperrzeit begrenzt ihn auf eine Ausloesung je lockout_secs, und im
Protokoll sieht das aus wie eine Automatik, die halt oft laeuft.
deleteAutomation() fragt vorher. fk_cond_state steht auf ON DELETE
CASCADE, mit dem Messwert verschwaende also still die Bedingung des
Nachfolgers: aus "zehn Minuten nach dem Wecker UND es ist hell" wuerde
ein blosses "es ist hell", und der Rollladen fuehre ab morgen jeden Tag
bei Sonnenaufgang hoch. Bei der letzten Bedingung faengt der Runner das
ab, bei einer von zweien niemand. Zur Wahl stehen Abhaengen (Bedingung
raus, Nachfolger pausiert) und Mitloeschen; automatisch mitzuloeschen
waere die falsche Vorgabe.
Die Uebersicht rueckt Nachfolger unter ihren Ausloeser ein und zeigt sie
eingeklappt. Das ist nur die Darstellung - jeder Nachfolger bleibt eine
vollwertige Automatik mit eigener Pause, eigenen Rahmenbedingungen und
eigener Zeile im Protokoll. Sobald "gruppiert" auch "gehoert dazu"
hiesse, waere zu klaeren, wem die Pause gehoert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
937 lines
34 KiB
JavaScript
937 lines
34 KiB
JavaScript
/**
|
||
* 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 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) {
|
||
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 = "<span class='text-truncate'>" + textSicher(d.label) + "</span>"
|
||
+ "<small class='opacity-75 text-nowrap'>"
|
||
+ textSicher(zeilenZusatz(d, mitEtage)) + "</small>";
|
||
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 = "<div class='p-2 small text-body-secondary'>Nichts gefunden.</div>";
|
||
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, "<").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 = "<hr class='flex-grow-1 m-0'>"
|
||
+ "<span class='px-3 small fw-bold'>ODER</span>"
|
||
+ "<hr class='flex-grow-1 m-0'>";
|
||
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 = "<i class='bi bi-plus-lg'></i> 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 = "<i class='bi bi-trash3'></i>";
|
||
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 <strong>" + text.replace(/&/g, "&").replace(/</g, "<") + "</strong>."
|
||
: "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 = "<i class='bi bi-trash3'></i>";
|
||
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 = "<input class='form-check-input mt-0 me-1' type='checkbox' id='day" + i + "'"
|
||
+ (autoModel.weekdays & (1 << i) ? " checked" : "") + ">"
|
||
+ "<label class='form-check-label' for='day" + i + "'>" + tag + "</label>";
|
||
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;
|
||
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,
|
||
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);
|
||
// 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) {
|
||
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 = "<p class='text-danger'>Liste konnte nicht geladen werden.</p>"; });
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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 => "<li>" + textSicher(f.name)
|
||
+ (f.enabled ? "" : " <span class='text-body-secondary'>(pausiert)</span>")
|
||
+ "</li>")
|
||
.join("");
|
||
const wieViele = folgen.length === 1
|
||
? "Eine Automatik wird" : folgen.length + " Automatiken werden";
|
||
|
||
const huelle = document.createElement("div");
|
||
huelle.innerHTML =
|
||
"<div class='modal fade' id='modalAutoLoeschen' tabindex='-1'>"
|
||
+ "<div class='modal-dialog modal-dialog-centered'><div class='modal-content'>"
|
||
+ "<div class='modal-header'><h5 class='modal-title'>„" + textSicher(name) + "“ löschen</h5>"
|
||
+ "<button type='button' class='btn-close' data-bs-dismiss='modal'></button></div>"
|
||
+ "<div class='modal-body'><p>" + wieViele + " von dieser hier ausgelöst:</p>"
|
||
+ "<ul>" + liste + "</ul>"
|
||
+ "<p class='mb-0 text-body-secondary small'>Beim Abhängen verlieren sie nur diese "
|
||
+ "eine Bedingung. Damit sie danach nicht mit einer schwächeren Regel weiterlaufen, "
|
||
+ "werden sie pausiert.</p></div>"
|
||
+ "<div class='modal-footer'>"
|
||
+ "<button type='button' class='btn btn-secondary' data-bs-dismiss='modal'>Abbrechen</button>"
|
||
+ "<button type='button' class='btn btn-warning' id='btnAutoAbhaengen'>Abhängen und pausieren</button>"
|
||
+ "<button type='button' class='btn btn-danger' id='btnAutoMit'>Alle löschen</button>"
|
||
+ "</div></div></div></div>";
|
||
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));
|