/**
* 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 = {
"Außenjalousie": "bi-shadows",
"Rollladen": "bi-shadows",
"Screen": "bi-shadows",
"Pergola": "bi-umbrella",
"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
* 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);
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.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.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(" · ");
}
function rahmenIstVorgabe() {
return autoModel.weekdays === 127
&& autoModel.on_vacation === 1
&& autoModel.on_holiday === 1
&& autoModel.window_from === "00:00"
&& autoModel.window_to === "23:59"
&& !autoModel.once_per_day
&& !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("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;
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.lockout_secs = Number(document.getElementById("lockoutSecs").value);
}
/**
* 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", "runOnce", "oncePerDay"].forEach(id => {
const feld = document.getElementById(id);
if (feld) feld.addEventListener("change", () => { 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,
force_once: a.force_once,
once_per_day: a.once_per_day,
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");
// 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,
force_once: autoModel.force_once,
once_per_day: autoModel.once_per_day,
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 = "
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 =
"
"
+ "
"
+ ""
+ "
" + wieViele + " von dieser hier ausgelöst:
"
+ "
"
+ "
Beim Abhängen verlieren sie nur diese "
+ "eine Bedingung. Damit sie danach nicht mit einer schwächeren Regel weiterlaufen, "
+ "werden sie pausiert.
"
+ "
";
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));