Files
Smart-Dashboard/js/solar/autoActionFuncs.js
T
adminandClaude Opus 5 95a61709d7 Haltezeit als Zeitfeld: Stunden und Minuten statt einer Minutenzahl
Dasselbe <input type="time"> wie von/bis daneben, nur als Dauer gelesen -
"03:30" tippt sich leichter als "210" und steht in derselben Zeile wie die
beiden Uhrzeiten darueber. Dass es keine Uhrzeit meint, sagt der Text
ringsum ("erst nach ... am Stueck"); die Kopfzeile des Rahmens schreibt es
beim Tippen aus.

Die Obergrenze ist damit 23:59 statt 24 Stunden - genau das, was ein
Zeitfeld hergibt.

Dabei aufgefallen: haltezeitKurz() rundete auf Zehntelstunden und machte aus
23:59 die Auskunft "24 Std.". Eine Haltezeit, die laenger aussieht als sie
ist, ist genau die falsche Auskunft - krumme Werte stehen jetzt als
"23 Std. 59 Min." da, halbe behalten ihr Komma.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 09:26:00 +02:00

1592 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = "<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.
//
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/**
* Wertfeld passend zum Datentyp des Messwerts oder Parameters.
* Liefert das fertige Element; der Aufrufer haengt seinen Listener an.
*/
function wertFeld(spec, wert) {
let feld;
if (spec.input === "bool") {
feld = document.createElement("select");
feld.className = "form-select";
optionListe(feld, [{ value: "true", text: "JA" }, { value: "false", text: "NEIN" }],
wert === "" ? "true" : wert);
} else if (spec.input === "select") {
// Die Auswahl kommt fertig aus dem Katalog: bei Messwerten steht der
// Klartext auch als Wert, bei Geraeteparametern die Zahl dahinter.
feld = document.createElement("select");
feld.className = "form-select";
optionListe(feld, spec.options.map(o => ({ value: o.value, text: o.label })), wert);
} else {
feld = document.createElement("input");
feld.className = "form-control";
feld.type = spec.input;
if (spec.min !== undefined && spec.min !== null) feld.min = spec.min;
if (spec.max !== undefined && spec.max !== null) feld.max = spec.max;
feld.value = wert !== "" ? wert
: spec.input === "time" ? "00:00"
: spec.input === "date" ? new Date().toISOString().split("T")[0]
: spec.input === "number" ? "0" : "";
}
return feld;
}
/** Standardwert, wenn eine Zeile auf einen anderen Messwert umgestellt wird. */
function standardWert(spec) {
if (spec.input === "bool") return "true";
if (spec.input === "select") return spec.options.length ? spec.options[0].value : "";
if (spec.input === "time") return "00:00";
if (spec.input === "date") return new Date().toISOString().split("T")[0];
if (spec.input === "number") return "0";
return "";
}
// --- Ausloeser -----------------------------------------------------------
/**
* Ein Sinnbild je Geraeteart.
*
* Gibt der Zeile einen Anker fuers Auge und spart das Wort: ein Tropfen sagt
* "Wasser", bevor man "Gartenwasser hinten" gelesen hat. Die Arten stehen so
* in actors.type, wie der Geraetesuchlauf sie hinterlaesst; was fehlt,
* bekommt den Chip - lieber ein neutrales Zeichen als ein falsches.
*/
const GERAETE_SYMBOL = {
// Alles, was beschattet, traegt dasselbe Zeichen wie im Raum-Modal und in
// der Zeitleiste (bi-beschattung, css/solar.css).
"Außenjalousie": "bi-beschattung",
"Rollladen": "bi-beschattung",
"Screen": "bi-beschattung",
"Pergola": "bi-beschattung",
"ESP32 Thermostat": "bi-thermometer-half",
"Temperatur": "bi-thermometer-half",
"Heizung": "bi-fire",
"Licht": "bi-lightbulb",
"WLED": "bi-lightbulb",
"Wechselrichter": "bi-sun",
"Solaranlage": "bi-sun",
"Stromzähler": "bi-lightning-charge",
"Meter Digitizer": "bi-speedometer2",
"go-eCharger_V4": "bi-ev-station",
"ShellyRelay_SHSW-1": "bi-toggle-on",
"Bewässerung": "bi-droplet",
"Wetter": "bi-cloud-sun",
"Regen": "bi-cloud-rain",
"Garagentor": "bi-door-open",
"Alarm": "bi-bell",
"LOGIC": "bi-clock",
"MQTT": "bi-broadcast"
};
function symbolFuer(dev) {
return (dev && GERAETE_SYMBOL[dev.type]) || "bi-cpu";
}
/**
* Datentypen, bei denen der Browser kein Urteil faellt.
*
* Ob "Uhrzeit um 07:30" gerade zutrifft, entscheidet nicht ein Vergleich
* zweier Zahlen, sondern das Nachholfenster, der Tagesrand und bei `elapsed`
* der echte Abstand seit der letzten Ausloesung - all das steht im Runner
* (bedingung_erfuellt in autoaction_runner.py). Das hier nachzubauen hiesse,
* eine zweite Wahrheit zu pflegen, die irgendwann auseinanderlaeuft.
*
* Deshalb: bei diesen Typen zeigt die Zeile den Wert, aber keinen Punkt.
* Lieber keine Aussage als eine, die manchmal falsch ist.
*/
const ZEITARTEN = ["time", "date", "datetime", "deltatime", "elapsed"];
/** Die zuletzt geholten Messwerte, nach state_id. */
let autoWerte = {};
let autoWerteTakt = null;
const WERTE_TAKT_MS = 8000;
function werteIds() {
const ids = new Set();
autoModel.groups.forEach(g => g.forEach(b => { if (b.state_id) ids.add(b.state_id); }));
return Array.from(ids);
}
/**
* Die laufenden Werte nachholen.
*
* Ein eigener, winziger Endpunkt statt des ganzen Geraetekatalogs: der waegt
* ueber hundert Kilobyte, und gebraucht werden ein paar Zahlen. Faellt die
* Anfrage aus, bleibt der letzte Stand stehen - ein Fehlerbanner mitten im
* Editor waere schlimmer als eine Zahl, die zehn Sekunden alt ist.
*/
function werteHolen() {
const ids = werteIds();
if (!ids.length) return;
fetch("./ajax/AutoAction.php?action=werte&ids=" + ids.join(","),
{ headers: { "Requested-With-Ajax": "ajax" } })
.then(a => (a.ok ? a.json() : null))
.then(a => {
if (!a || !a.werte || !autoModel) return;
autoWerte = a.werte;
// Nur nachmalen, nicht neu aufbauen. renderConditions() ersetzt den
// ganzen Block - alle acht Sekunden waeren damit eine offene
// Geraeteliste weg und der Cursor aus einem Wertfeld, in das man
// gerade tippt. Die Struktur aendert der Takt nie, nur die Zahlen.
werteZeichnen();
})
.catch(() => {});
}
function werteTaktStarten() {
werteTaktStoppen();
werteHolen();
autoWerteTakt = setInterval(werteHolen, WERTE_TAKT_MS);
}
function werteTaktStoppen() {
if (autoWerteTakt) clearInterval(autoWerteTakt);
autoWerteTakt = null;
}
/**
* Was sagt dieser Messwert gerade, und trifft die Bedingung damit zu?
*
* `kennt` ist falsch, wenn wir es nicht entscheiden koennen - siehe
* ZEITARTEN oben - oder wenn noch gar kein Wert vorliegt.
*/
function zustandVon(state, bed) {
const roh = autoWerte[bed.state_id];
const wert = roh ? roh.value : (state ? state.value : null);
const einheit = (roh && roh.unit) || (state && state.unit) || "";
if (wert === null || wert === undefined || wert === "") {
return { text: "", kennt: false };
}
const text = wertText(state, wert) + (einheit ? " " + einheit : "");
if (!state || ZEITARTEN.indexOf(state.type) >= 0) {
return { text: text, kennt: false };
}
return { text: text, kennt: true, erfuellt: vergleicht(state, wert, bed) };
}
/**
* Ein Rohwert so schreiben, wie ihn das Wertfeld daneben auch schreibt.
*
* In der Datenbank steht "false", im Auswahlfeld daneben steht "NEIN" - und
* "gerade false" neben "= NEIN" liest sich, als waeren es zwei verschiedene
* Dinge. Ebenso beim Dezimalpunkt: die Zahl kommt als "13.9" herein, im Rest
* der Oberflaeche steht ein Komma.
*/
function wertText(state, wert) {
const s = String(wert);
if (!state) return s;
if (state.type === "bool") {
return ["true", "1", "ja", "an"].indexOf(s.trim().toLowerCase()) >= 0 ? "JA" : "NEIN";
}
if (state.options && state.options.length) {
const treffer = state.options.find(o => String(o.value) === s);
if (treffer) return treffer.label;
}
if (state.type === "float") return s.replace(".", ",");
return s;
}
/** Der Vergleich fuer die Typen, bei denen er eindeutig ist. */
function vergleicht(state, ist, bed) {
if (state.type === "bool") {
const ja = v => ["true", "1", "ja", "an"].indexOf(String(v).trim().toLowerCase()) >= 0;
return bed.operator === "!=" ? ja(ist) !== ja(bed.value) : ja(ist) === ja(bed.value);
}
const zahl = state.type === "integer" || state.type === "float";
// Komma als Dezimaltrennzeichen kommt aus Geraeten, die deutsch formatieren.
const a = zahl ? Number(String(ist).replace(",", ".")) : String(ist);
const b = zahl ? Number(String(bed.value).replace(",", ".")) : String(bed.value);
if (zahl && (Number.isNaN(a) || Number.isNaN(b))) return false;
switch (bed.operator) {
case "=": return a === b;
case "!=": return a !== b;
case ">": return a > b;
case "<": return a < b;
case ">=": return a >= b;
case "<=": return a <= b;
default: return false;
}
}
/** Die Balkenreihe, ein Segment je Bedingung. */
function segmenteFuellen(reihe, zustaende) {
reihe.innerHTML = "";
zustaende.forEach(z => {
const s = document.createElement("span");
s.className = "auto-segment" + (z.kennt && z.erfuellt ? " an" : z.kennt ? "" : " offen");
s.title = z.kennt ? (z.erfuellt ? "trifft zu" : "trifft gerade nicht zu")
: "hängt am Zeitpunkt das entscheidet der Runner";
reihe.appendChild(s);
});
}
function segmente(zustaende) {
const reihe = document.createElement("span");
reihe.className = "auto-segmente d-inline-flex gap-1";
segmenteFuellen(reihe, zustaende);
return reihe;
}
/** Wie eine Bedingung gerade dasteht - fuer eine ganze Gruppe. */
function zustaendeVon(gruppe) {
return gruppe.map(bed => {
const treffer = stateById(bed.state_id);
return zustandVon(treffer ? treffer.state : null, bed);
});
}
/**
* Die laufenden Zahlen nachziehen, ohne etwas neu zu bauen.
*
* Angefasst wird nur, was sich wirklich aendert: der Wert neben jeder
* Bedingung, ihr Punkt, die Balkenreihen und der Stand im Kopf des Bandes.
* Alles andere - Auswahlfelder, Eingaben, eine offene Geraeteliste - bleibt
* unberuehrt stehen.
*/
function werteZeichnen() {
const ziel = document.getElementById("condBlocks");
if (!ziel || !autoModel) return;
const proGruppe = [];
autoModel.groups.forEach((gruppe, gi) => {
const zustaende = zustaendeVon(gruppe);
proGruppe.push(zustaende);
const block = ziel.querySelector('[data-gruppe="' + gi + '"]');
if (!block) return;
gruppe.forEach((bed, bi) => {
const zeile = block.querySelector('.auto-zeile[data-bed="' + bi + '"]');
if (!zeile) return;
zeileZeichnen(zeile, zustaende[bi]);
});
const leiste = block.querySelector(".auto-segmente");
if (leiste) segmenteFuellen(leiste, zustaende);
});
renderStand(proGruppe);
}
/** Wert und Punkt einer einzelnen Zeile. */
function zeileZeichnen(zeile, zustand) {
const jetzt = zeile.querySelector(".wert-jetzt");
if (jetzt) {
jetzt.className = "wert-jetzt" + (zustand.kennt && zustand.erfuellt ? " erfuellt" : "");
jetzt.innerHTML = "gerade <b>" + textSicher(zustand.text) + "</b>";
}
const punkt = zeile.querySelector(".punkt");
if (punkt) {
punkt.className = "punkt" + (zustand.kennt && zustand.erfuellt ? " an" : "");
punkt.title = zustand.kennt
? (zustand.erfuellt ? "trifft zu" : "trifft gerade nicht zu")
: "hängt am Zeitpunkt das entscheidet der Runner";
punkt.style.borderStyle = zustand.kennt ? "" : "dashed";
}
}
/**
* Zeichnet alle UND-Bloecke. Jeder Block hat sein eigenes "noch eine
* Bedingung", denn mit einem einzigen Knopf ganz unten kaeme man immer nur
* an den letzten Block heran und koennte eine bestehende Alternative nie
* mehr ergaenzen.
*/
function renderConditions() {
const ziel = document.getElementById("condBlocks");
if (!ziel) return;
ziel.innerHTML = "";
const proGruppe = [];
autoModel.groups.forEach((gruppe, gi) => {
if (gi > 0) {
const trenner = document.createElement("div");
trenner.className = "d-flex align-items-center my-2 text-body-secondary";
trenner.innerHTML = "<hr class='flex-grow-1 m-0'>"
+ "<span class='px-3 card-title mb-0 fs-6'>oder</span>"
+ "<hr class='flex-grow-1 m-0'>";
ziel.appendChild(trenner);
}
const block = document.createElement("div");
block.className = "border rounded-3 p-1";
// Kennung, damit werteZeichnen() die Zeile spaeter wiederfindet, ohne
// sie neu zu bauen.
block.dataset.gruppe = gi;
const zustaende = zustaendeVon(gruppe);
proGruppe.push(zustaende);
if (gruppe.length > 1) {
const kopf = document.createElement("div");
kopf.className = "d-flex align-items-center gap-2 px-2 pt-1 small text-body-secondary";
kopf.innerHTML = "<span>alles davon</span><span class='flex-grow-1'></span>";
kopf.appendChild(segmente(zustaende));
block.appendChild(kopf);
}
gruppe.forEach((bed, bi) => block.appendChild(conditionRow(gi, bi, bed, zustaende[bi])));
const add = document.createElement("button");
add.type = "button";
add.className = "btn btn-link btn-sm text-body-secondary text-decoration-none";
add.innerHTML = "<i class='bi bi-plus-lg'></i> noch eine Bedingung";
add.onclick = () => { gruppe.push(leereBedingung()); renderConditions(); };
block.appendChild(add);
ziel.appendChild(block);
});
renderStand(proGruppe);
}
function conditionRow(gi, bi, bed, zustand) {
const zeile = document.createElement("div");
zeile.className = "auto-zeile";
zeile.dataset.bed = bi;
const treffer = stateById(bed.state_id);
const dev = treffer ? treffer.device : null;
const state = treffer ? treffer.state : null;
const sym = document.createElement("i");
sym.className = "bi " + symbolFuer(dev) + " auto-symbol";
zeile.appendChild(sym);
// Geraet klein und matt ueber dem Messwert: es beantwortet "woher", nicht
// "was". Frueher standen beide als gleich breite Kaesten nebeneinander,
// und "Gartenwasser hinten" brach dabei jede zweite Zeile auf doppelte
// Hoehe um.
const was = document.createElement("div");
was.className = "auto-was";
const geraetGewaehlt = neu => {
bed.state_id = neu.states[0].id;
bed.operator = neu.states[0].operators[0].value;
bed.value = standardWert(neu.states[0]);
renderConditions();
};
const auswahl = autoDevices.filter(d => d.states.length);
if (!dev) {
// Solange kein Geraet feststeht, gibt es genau eine sinnvolle Handlung -
// und die bekommt die ganze Zeile.
//
// Vorher standen hier zwei Zeilen: ein klein geschriebenes "Gerät wählen"
// und darunter ein totes Auswahlfeld "Messwert". Wer auf das Feld klickte
// - und das ist das groessere, auffaelligere von beiden -, bekam keine
// Reaktion und musste selbst darauf kommen, dass die matte Zeile darueber
// die eigentliche Schaltflaeche ist. Ein Bedienelement, das auf Klick
// nichts tut, ist schlechter als gar keines.
const waehlen = geraeteWaehler(auswahl, 0, geraetGewaehlt,
"feld-als-text auto-geraet");
waehlen.classList.remove("text-body-secondary");
was.appendChild(waehlen);
zeile.classList.add("leer");
zeile.appendChild(was);
} else {
was.appendChild(geraeteWaehler(auswahl, dev.id, geraetGewaehlt, "auto-geraet"));
const messwert = document.createElement("select");
messwert.className = "feld-als-text auto-messwert";
optionListe(messwert, dev.states.map(s => ({ value: s.id, text: s.name })), bed.state_id);
messwert.onchange = () => {
const neu = dev.states.find(s => s.id === Number(messwert.value));
bed.state_id = neu.id;
bed.operator = neu.operators[0].value;
bed.value = standardWert(neu);
renderConditions();
};
was.appendChild(messwert);
zeile.appendChild(was);
}
if (state) {
const op = document.createElement("select");
op.className = "form-select form-select-sm text-center auto-op";
optionListe(op, state.operators.map(o => ({ value: o.value, text: o.label })), bed.operator);
op.onchange = () => { bed.operator = op.value; renderConditions(); };
zeile.appendChild(op);
// Wert und Einheit in einem Feld: die Einheit ist keine Eingabe und
// brauchte trotzdem einen eigenen Kasten in der Reihe.
const wertfeld = wertFeld(state, bed.value);
wertfeld.classList.add(wertfeld.tagName === "SELECT" ? "form-select-sm" : "form-control-sm");
wertfeld.onchange = () => { bed.value = wertfeld.value; renderConditions(); };
if (state.unit) {
const huelle = document.createElement("div");
huelle.className = "input-group input-group-sm auto-wert";
huelle.appendChild(wertfeld);
const einheit = document.createElement("span");
einheit.className = "input-group-text";
einheit.textContent = state.unit;
huelle.appendChild(einheit);
zeile.appendChild(huelle);
} else {
wertfeld.classList.add("auto-wert");
zeile.appendChild(wertfeld);
}
const jetzt = document.createElement("span");
jetzt.className = "wert-jetzt" + (zustand.kennt && zustand.erfuellt ? " erfuellt" : "");
jetzt.innerHTML = "gerade <b>" + textSicher(zustand.text) + "</b>";
zeile.appendChild(jetzt);
const punkt = document.createElement("span");
punkt.className = "punkt" + (zustand.kennt && zustand.erfuellt ? " an" : "");
punkt.title = zustand.kennt ? (zustand.erfuellt ? "trifft zu" : "trifft gerade nicht zu")
: "haengt am Zeitpunkt das entscheidet der Runner";
if (!zustand.kennt) punkt.style.borderStyle = "dashed";
zeile.appendChild(punkt);
}
const weg = document.createElement("button");
weg.type = "button";
weg.className = "btn btn-sm auto-weg";
weg.title = "Bedingung entfernen";
weg.innerHTML = "<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;
}
/**
* Der Stand im Kopf des Bandes.
*
* Steht dort, wo frueher der Satz stand, der die Regel Wort fuer Wort
* nacherzaehlte - fuenf fette Zeilen, die genau das wiederholten, was
* darueber schon zu sehen war. Diese Zeile beantwortet stattdessen die
* Frage, die man wirklich hat: warum laeuft die Automatik gerade nicht?
*/
function renderStand(proGruppe) {
const ziel = document.getElementById("condStand");
if (!ziel) return;
const gefuellt = proGruppe.filter(z => z.length);
if (!gefuellt.length) {
ziel.innerHTML = "<span class='small text-warning'>"
+ "Noch kein Auslöser die Automatik würde nie starten.</span>";
return;
}
// Das Geruest nur einmal bauen. Wuerde es der Takt jedes Mal ersetzen,
// finge die Atembewegung des Punktes alle acht Sekunden von vorn an - ein
// Zucken, das aussieht wie ein Fehler.
if (!ziel.querySelector(".punkt")) {
ziel.innerHTML = "";
ziel.appendChild(document.createElement("span")).className = "punkt atmet";
ziel.appendChild(document.createElement("span")).className = "small text-body-secondary";
ziel.appendChild(segmente([]));
}
// Bei mehreren Gruppen zaehlt die, die am weitesten ist: es genuegt ja,
// dass eine von ihnen vollstaendig zutrifft.
let beste = gefuellt[0];
let bestZahl = -1;
gefuellt.forEach(z => {
const n = z.filter(x => x.kennt && x.erfuellt).length;
if (n > bestZahl) { bestZahl = n; beste = z; }
});
const alle = beste.every(x => x.kennt && x.erfuellt);
const offen = beste.some(x => !x.kennt);
const punkt = ziel.querySelector(".punkt");
punkt.className = "punkt atmet" + (alle ? " an" : "");
const text = ziel.querySelector("span.small");
text.textContent = alle && !offen
? "alles erfüllt"
: "gerade " + bestZahl + " von " + beste.length
+ (gefuellt.length > 1 ? " in der besten Gruppe" : "");
text.title = offen
? "Zeitbedingungen sind nicht mitgezählt die entscheidet der Runner."
: "";
segmenteFuellen(ziel.querySelector(".auto-segmente"), beste);
}
// --- Aktionen ------------------------------------------------------------
function renderActions() {
const ziel = document.getElementById("actionList");
ziel.innerHTML = "";
autoModel.actions.forEach((aktion, ai) => ziel.appendChild(actionRow(ai, aktion)));
}
function actionRow(ai, aktion) {
// Dasselbe Raster wie die Bedingungszeile, nur mit anderen Spalten:
// Sinnbild, was geschaltet wird, die Werte dazu, Papierkorb. Damit erben
// Aktionen alles mit, was drueben schon stimmt - Gehaenge beim
// Ueberfahren, abwechselnde Hinterlegung, das Umbrechen auf schmalen
// Schirmen.
const zeile = document.createElement("div");
zeile.className = "auto-zeile tat";
const treffer = commandById(aktion.command_id);
const dev = treffer ? treffer.device : null;
const cmd = treffer ? treffer.command : null;
const sym = document.createElement("i");
sym.className = "bi " + symbolFuer(dev) + " auto-symbol";
zeile.appendChild(sym);
const was = document.createElement("div");
was.className = "auto-was";
const geraetGewaehlt = neu => {
aktion.command_id = neu.commands[0].id;
aktion.params = standardParams(neu.commands[0]);
renderActions();
};
const auswahl = autoDevices.filter(d => d.commands.length);
if (!dev) {
// Wie in der Bedingungszeile: solange kein Geraet feststeht, gibt es nur
// eine Handlung, und die bekommt die ganze Breite.
const waehlen = geraeteWaehler(auswahl, 0, geraetGewaehlt,
"feld-als-text auto-geraet");
waehlen.classList.remove("text-body-secondary");
was.appendChild(waehlen);
zeile.classList.add("leer");
zeile.appendChild(was);
} else {
was.appendChild(geraeteWaehler(auswahl, dev.id, geraetGewaehlt, "auto-geraet"));
const befehl = document.createElement("select");
befehl.className = "feld-als-text auto-messwert";
optionListe(befehl, dev.commands.map(c => ({ value: c.id, text: c.name })), aktion.command_id);
befehl.onchange = () => {
const neu = dev.commands.find(c => c.id === Number(befehl.value));
aktion.command_id = neu.id;
aktion.params = standardParams(neu);
renderActions();
};
was.appendChild(befehl);
zeile.appendChild(was);
// Je Parameter ein Feld - wie viele es sind, sagt das Geraet. Frueher gab
// es vier feste Felder "Wert 1" bis "Wert 4", die je nach Kommando
// versteckt wurden, ihren Inhalt aber trotzdem mitschickten.
const werte = document.createElement("div");
werte.className = "auto-werte d-flex flex-wrap align-items-center gap-2";
if (cmd) {
cmd.params.forEach(p => {
const paar = document.createElement("div");
paar.className = "d-flex align-items-center gap-1";
const name = document.createElement("span");
name.className = "small text-body-secondary text-nowrap";
name.textContent = p.name;
paar.appendChild(name);
const feld = wertFeld(p, aktion.params[p.id] !== undefined
? aktion.params[p.id] : standardWert(p));
feld.classList.add(feld.tagName === "SELECT" ? "form-select-sm" : "form-control-sm");
feld.onchange = () => { aktion.params[p.id] = feld.value; };
paar.appendChild(feld);
werte.appendChild(paar);
});
}
zeile.appendChild(werte);
}
const weg = document.createElement("button");
weg.type = "button";
weg.className = "btn btn-sm auto-weg";
weg.title = "Aktion entfernen";
weg.innerHTML = "<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"];
const WOCHENTAGE_LANG = ["montags", "dienstags", "mittwochs", "donnerstags",
"freitags", "samstags", "sonntags"];
/**
* Ein Schalter im Bootstrap-Stil: verstecktes Eingabefeld plus Etikett, die
* beide direkt im .btn-group stehen muessen - ein umschliessendes <div>
* bricht die Gruppe optisch auf.
*/
function schalter(ziel, art, id, name, text, titel, gedrueckt, beiKlick) {
const feld = document.createElement("input");
feld.type = art; // "checkbox" oder "radio"
feld.className = "btn-check";
feld.id = id;
feld.autocomplete = "off";
if (name) feld.name = name;
feld.checked = gedrueckt;
feld.onchange = beiKlick;
const etikett = document.createElement("label");
etikett.className = "btn btn-outline-secondary";
etikett.htmlFor = id;
etikett.textContent = text;
if (titel) etikett.title = titel;
ziel.appendChild(feld);
ziel.appendChild(etikett);
}
function renderWeekdays() {
const ziel = document.getElementById("weekdayGroup");
ziel.innerHTML = "";
WOCHENTAGE.forEach((tag, i) => {
schalter(ziel, "checkbox", "day" + i, null, tag, WOCHENTAGE_LANG[i],
!!(autoModel.weekdays & (1 << i)), e => {
if (e.target.checked) autoModel.weekdays |= (1 << i);
else autoModel.weekdays &= ~(1 << i);
renderRahmen();
});
});
}
/** Ferien und Feiertage: nie - egal - immer. */
function renderKalendertage(zielId, auswahl, feld) {
const ziel = document.getElementById(zielId);
ziel.innerHTML = "";
auswahl.forEach(o => {
schalter(ziel, "radio", zielId + "-" + o.value, zielId, o.label, o.title,
autoModel[feld] === o.value, () => {
autoModel[feld] = o.value;
renderRahmen();
});
});
}
/**
* Die drei Vorlagen setzen den ganzen Tagesrahmen, nicht nur die Wochentage:
* "werktags" heisst auch "nicht an Feiertagen".
*/
function renderTagVorlagen() {
const ziel = document.getElementById("tagVorlagen");
ziel.innerHTML = "";
autoTagVorlagen.forEach(v => {
const knopf = document.createElement("button");
knopf.type = "button";
// Die Vorlage, die gerade zutrifft, steht gedrueckt da. Sonst sagen die
// drei Knoepfe nur, was man tun koennte, und nie, wo man steht - und
// "werktags" ist immerhin drei Einstellungen auf einmal.
const passt = autoModel.weekdays === v.weekdays
&& autoModel.on_vacation === v.on_vacation
&& autoModel.on_holiday === v.on_holiday;
knopf.className = "btn btn-outline-secondary" + (passt ? " active" : "");
knopf.textContent = v.label;
knopf.title = v.title;
knopf.onclick = () => {
// Kein stopPropagation noetig - und es haette auch nichts genutzt: die
// Vorlagen stehen bewusst ausserhalb des Elements mit data-bs-toggle,
// siehe den Kommentar an der Kopfzeile in ajax/AutoAction.php.
autoModel.weekdays = v.weekdays;
autoModel.on_vacation = v.on_vacation;
autoModel.on_holiday = v.on_holiday;
renderRahmen();
};
ziel.appendChild(knopf);
});
}
/** "montags, mittwochs und freitags" */
function aufzaehlung(teile) {
if (teile.length < 2) return teile[0] || "";
return teile.slice(0, -1).join(", ") + " und " + teile[teile.length - 1];
}
/**
* Die Wochentagsmaske als Wort. Drei und mehr aufeinanderfolgende Tage
* werden zusammengezogen ("montags bis freitags"), zwei nicht - "montags und
* dienstags und samstags" haette sonst zwei verschiedene "und" nebeneinander.
*/
function tageText(maske) {
if (maske === 127) return "täglich";
const laeufe = [];
for (let i = 0; i < 7; i++) {
if (!(maske & (1 << i))) continue;
const letzter = laeufe[laeufe.length - 1];
if (letzter && letzter[1] === i - 1) letzter[1] = i;
else laeufe.push([i, i]);
}
const teile = [];
laeufe.forEach(([a, b]) => {
if (b - a >= 2) teile.push(WOCHENTAGE_LANG[a] + " bis " + WOCHENTAGE_LANG[b]);
else for (let i = a; i <= b; i++) teile.push(WOCHENTAGE_LANG[i]);
});
return aufzaehlung(teile);
}
/**
* Der ganze Tagesrahmen als Satz.
*
* Das ist der eigentliche Gewinn gegenueber den Schaltern: man muss das
* Bedienelement nicht entziffern, sondern liest, was dabei herausgekommen
* ist. Leerer Rueckgabewert heisst "laeuft nie" - dann steht statt des
* Satzes eine Warnung da, wie beim fehlenden Ausloeser.
*/
function rahmenText() {
const tage = autoModel.weekdays;
const dazu = [];
const verbote = [];
if (autoModel.on_vacation === 2) dazu.push("an allen Ferientagen");
if (autoModel.on_holiday === 2) dazu.push("an allen Feiertagen");
if (autoModel.on_vacation === 0) verbote.push("in den Ferien");
if (autoModel.on_holiday === 0) verbote.push("an Feiertagen");
if (!tage && !dazu.length) return "";
let satz;
if (!tage) {
satz = "Läuft nur " + aufzaehlung(dazu);
} else {
satz = "Läuft " + tageText(tage);
// Bei sieben angehakten Tagen traegt "dazu an allen Feiertagen" nichts
// bei - die sind schon dabei.
if (dazu.length && tage !== 127) satz += ", dazu " + aufzaehlung(dazu);
}
if (verbote.length) satz += ", aber nicht " + aufzaehlung(verbote);
// Vorabend: derselbe Satz, nur fuer morgen gerechnet. Vorne, damit auch
// "aber nicht an Feiertagen" als Folgetag gelesen wird.
if (autoModel.next_day) satz = satz.replace(/^Läuft /, "Läuft am Vortag, gerechnet für den Folgetag: ");
return satz + ".";
}
/**
* Der Rahmen in einer Zeile, fuer den zusammengefalteten Zustand.
*
* Solange nichts von der Vorgabe abweicht, steht dort matt "täglich · rund
* um die Uhr" und man liest darueber hinweg. Weicht etwas ab, wird die Zeile
* hell - dann steht dort etwas, das man wissen sollte.
*/
function rahmenKurzText() {
const teile = [tageText(autoModel.weekdays) || "an keinem Tag"];
if (autoModel.next_day) teile[0] = "Vorabend von " + teile[0];
if (autoModel.on_vacation === 0) teile.push("nicht in den Ferien");
if (autoModel.on_vacation === 2) teile.push("dazu Ferientage");
if (autoModel.on_holiday === 0) teile.push("nicht an Feiertagen");
if (autoModel.on_holiday === 2) teile.push("dazu Feiertage");
teile.push(autoModel.window_from === "00:00" && autoModel.window_to === "23:59"
? "rund um die Uhr"
: autoModel.window_from + "" + autoModel.window_to);
if (autoModel.hold_secs > 0) teile.push("erst nach " + haltezeitKurz(autoModel.hold_secs));
if (autoModel.lockout_secs > 0) {
teile.push("Sperre " + (autoModel.lockout_secs >= 60
? Math.floor(autoModel.lockout_secs / 60) + " Min."
: autoModel.lockout_secs + " Sek."));
}
if (autoModel.once_per_day) teile.push("nur einmal am Tag");
if (autoModel.force_once) teile.push("am Ende auf jeden Fall");
return teile.join(" · ");
}
/**
* Haltezeit und Zeitfeld ineinander umrechnen.
*
* Das Feld ist ein gewoehnliches <input type="time">, sein Wert also "03:30".
* Gemeint ist keine Uhrzeit, sondern eine Dauer - deshalb hier zwei eigene
* Funktionen und nicht die Behandlung von window_from/window_to, die den Text
* unveraendert durchreichen.
*/
function haltezeitAlsUhr(sekunden) {
const min = Math.round(sekunden / 60);
return String(Math.floor(min / 60)).padStart(2, "0") + ":"
+ String(min % 60).padStart(2, "0");
}
/** Leer oder unsinnig ist 0; die Obergrenze ist 23:59, siehe haltezeitPruefen(). */
function haltezeitAusUhr(text) {
const teile = String(text || "").split(":");
const sek = ((Number(teile[0]) || 0) * 60 + (Number(teile[1]) || 0)) * 60;
return Math.min(Math.max(sek, 0), 86340);
}
/** Wie haltezeitKurz() in automations.php - dort steht, warum nicht gerundet wird. */
function haltezeitKurz(sekunden) {
const minuten = Math.floor(sekunden / 60);
if (minuten < 60) return minuten + " Min.";
const stunden = Math.floor(minuten / 60);
const rest = minuten % 60;
if (rest === 0) return stunden + " Std.";
if (rest === 30) return stunden + ",5 Std.";
return stunden + " Std. " + rest + " Min.";
}
function rahmenIstVorgabe() {
return autoModel.weekdays === 127
&& autoModel.on_vacation === 1
&& autoModel.on_holiday === 1
&& !autoModel.next_day
&& autoModel.window_from === "00:00"
&& autoModel.window_to === "23:59"
&& !autoModel.once_per_day
&& !autoModel.hold_secs
&& !autoModel.force_once;
}
/** Schalter neu zeichnen und die beiden Zusammenfassungen nachziehen. */
function renderRahmen() {
renderTagVorlagen();
renderWeekdays();
renderKalendertage("ferienGroup", autoFerien, "on_vacation");
renderKalendertage("feiertagGroup", autoFeiertage, "on_holiday");
const text = rahmenText();
const satz = document.getElementById("rahmenSummary");
// Ohne waehlbaren Tag laeuft die Automatik nie - dann ist der Satz doch
// eine Warnung und faerbt sich entsprechend.
satz.className = "callout py-2 px-3 mt-0 mb-3 "
+ (text ? "callout-primary" : "callout-warning");
satz.innerHTML = text
? "<strong>" + text + "</strong>"
: "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("holdZeit").value = haltezeitAlsUhr(autoModel.hold_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;
document.getElementById("nextDay").checked = autoModel.next_day;
renderRahmen();
rahmenFelderVerdrahten();
renderConditions();
renderActions();
werteTaktStarten();
}
/** Die Felder, die direkt am Formular haengen, ins Modell zurueckholen. */
function leseFormular() {
autoModel.name = document.getElementById("autoName").value.trim();
autoModel.floor = document.getElementById("autoFloor").value;
autoModel.window_from = document.getElementById("tSpanFrom").value;
autoModel.window_to = document.getElementById("tSpanTo").value;
// weekdays, on_vacation und on_holiday stehen schon im Modell: die
// Schalter schreiben beim Klicken direkt hinein, weil der Satz darunter
// sofort nachziehen muss.
autoModel.force_once = document.getElementById("runOnce").checked;
autoModel.once_per_day = document.getElementById("oncePerDay").checked;
autoModel.next_day = document.getElementById("nextDay").checked;
autoModel.lockout_secs = Number(document.getElementById("lockoutSecs").value);
autoModel.hold_secs = haltezeitAusUhr(document.getElementById("holdZeit").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", "holdZeit",
"runOnce", "oncePerDay", "nextDay"].forEach(id => {
const feld = document.getElementById(id);
if (feld) feld.addEventListener("change", () => { leseFormular(); renderRahmen(); });
});
// Bei der Haltezeit schon beim Tippen: aus "03:30" wird in der Kopfzeile
// "erst nach 3,5 Std.". "change" allein kaeme erst beim Verlassen des
// Feldes, und ein Zeitfeld verlaesst man ungern, um nachzusehen.
document.getElementById("holdZeit")
.addEventListener("input", () => { leseFormular(); renderRahmen(); });
// Zugeklappt steht in der Kopfzeile, was gilt; aufgeklappt stehen dort die
// drei Vorlagen. Beides gleichzeitig waere eine volle Zeile aus
// Kleinteilen - genau die Unruhe, die der zusammengefaltete Rahmen
// vermeiden soll.
const koerper = document.getElementById("rahmenBody");
if (!koerper) return;
const umschalten = offen => {
[["rahmenKurz", offen], ["rahmenAendern", offen],
["tagVorlagen", !offen], ["rahmenZu", !offen]].forEach(([id, versteckt]) => {
const el = document.getElementById(id);
if (el) el.hidden = versteckt;
});
};
koerper.addEventListener("show.bs.collapse", () => umschalten(true));
koerper.addEventListener("hide.bs.collapse", () => umschalten(false));
}
// --- Modal ---------------------------------------------------------------
function loadAutomatic() {
const daten = JSON.parse(document.getElementById("autoActionData").textContent);
autoDevices = daten.devices;
autoLockouts = daten.lockouts;
autoFerien = daten.ferien || [];
autoFeiertage = daten.feiertage || [];
autoTagVorlagen = daten.tagvorlagen || [];
autoRooms = daten.rooms || [];
const a = daten.automation;
autoFloorVorher = a.floor;
autoModel = {
id: a.id,
name: a.name,
floor: a.floor,
enabled: a.enabled,
window_from: a.window_from,
window_to: a.window_to,
weekdays: a.weekdays,
on_vacation: a.on_vacation,
on_holiday: a.on_holiday,
next_day: !!a.next_day,
force_once: a.force_once,
once_per_day: a.once_per_day,
lockout_secs: a.lockout_secs,
hold_secs: a.hold_secs || 0,
groups: [],
actions: a.actions.map(x => ({ command_id: x.command_id, params: Object.assign({}, x.params) }))
};
// Die flache Liste aus der Datenbank wieder in Bloecke fassen. Die
// Gruppennummern kommen aufsteigend und dicht, siehe saveAutomation().
const nachGruppe = new Map();
a.conditions.forEach(c => {
if (!nachGruppe.has(c.group)) nachGruppe.set(c.group, []);
nachGruppe.get(c.group).push({ state_id: c.state_id, operator: c.operator, value: c.value });
});
autoModel.groups = Array.from(nachGruppe.values());
// Eine neue Automatik startet mit je einer leeren Zeile, sonst steht man
// vor einem leeren Formular ohne erkennbaren Anfang.
if (!autoModel.groups.length) autoModel.groups = [[leereBedingung()]];
if (!autoModel.actions.length) autoModel.actions = [leereAktion()];
fuelleFormular();
}
function openAutoActionModal(params) {
const contentURL = "./ajax/AutoAction.php" + params;
document.getElementById("modal-title").innerHTML =
params.search("id=") > 0 ? "Automatik bearbeiten" : "Neue Automatik anlegen";
// Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne
// Zuhoerer stellt sicher, dass nicht auch noch der Handler eines vorher
// geoeffneten Modals mitfeuert.
const alt = document.getElementById("modalSaveBtn");
const btn = alt.cloneNode(true);
alt.replaceWith(btn);
// Das Raum-Modal versteckt ihn; hier wird er gebraucht.
btn.hidden = false;
btn.addEventListener("click", submitAutoAction);
// Der Editor braucht mehr Breite als die Ladedialoge, gibt sie aber wieder
// her, damit die anderen Modals unveraendert aussehen.
const dialog = document.querySelector("#modalEV .modal-dialog");
dialog.classList.add("modal-xl");
document.getElementById("modalEV").addEventListener("hidden.bs.modal", () => {
dialog.classList.remove("modal-xl");
// Sonst fragte der geschlossene Editor weiter im Achtsekundentakt nach
// Werten, die niemand mehr sieht.
werteTaktStoppen();
}, { once: true });
const modalBodyElement = document.getElementById("modal-body");
modalBodyElement.innerHTML = loadingHTML("Wird geladen...");
modalEV.show();
fetch(contentURL, {
method: "GET",
headers: { "X-Requested-From-Modal": "a", "Requested-With-Ajax": "ajax" }
})
.then(response => response.text())
.then(html => {
modalBodyElement.innerHTML = html;
loadAutomatic();
document.getElementById("btnAddGroup").addEventListener("click", () => {
autoModel.groups.push([leereBedingung()]);
renderConditions();
});
document.getElementById("btnAddActor").addEventListener("click", () => {
autoModel.actions.push(leereAktion());
renderActions();
});
})
.catch(error => {
modalBodyElement.innerHTML = "Konnte den Editor nicht laden: " + error.message;
});
}
function submitAutoAction() {
leseFormular();
const nutzlast = {
id: autoModel.id,
name: autoModel.name,
floor: autoModel.floor,
enabled: autoModel.enabled,
window_from: autoModel.window_from,
window_to: autoModel.window_to,
weekdays: autoModel.weekdays,
on_vacation: autoModel.on_vacation,
on_holiday: autoModel.on_holiday,
next_day: autoModel.next_day,
force_once: autoModel.force_once,
once_per_day: autoModel.once_per_day,
lockout_secs: autoModel.lockout_secs,
hold_secs: autoModel.hold_secs,
conditions: [],
actions: autoModel.actions
};
autoModel.groups.forEach((gruppe, gi) => {
gruppe.forEach(bed => {
nutzlast.conditions.push({ group: gi, state_id: bed.state_id, operator: bed.operator, value: bed.value });
});
});
automationRequest("save", nutzlast).then(antwort => {
if (antwort.error) {
alert(antwort.error);
return;
}
modalEV.hide();
refreshAutomations(nutzlast.floor);
// Ein Etagenwechsel verschiebt die Automatik, er kopiert sie nicht - in
// der Datenbank steht weiter eine Zeile. Ohne dieses zweite Nachladen
// bliebe sie aber in der Tabelle der alten Etage stehen, und genau das
// sah wie eine Kopie aus.
if (autoFloorVorher && autoFloorVorher !== nutzlast.floor) {
refreshAutomations(autoFloorVorher);
}
});
}
// --- Uebersicht ----------------------------------------------------------
function automationRequest(action, body) {
return fetch("./ajax/AutoAction.php?action=" + action, {
method: "POST",
headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" },
body: JSON.stringify(body)
})
.then(response => response.json())
.catch(error => ({ error: error.message }));
}
/**
* Die Tabelle einer Etage neu holen. Ohne Etage alle, die es gibt.
*
* Welche das sind, sagen die Reiter - home.php baut sie aus der Raumtabelle.
* Eine feste Liste hier haette beim Aussengelaende still eine Etage
* ausgelassen: die Tabelle waere leer geblieben, ohne Fehlermeldung.
*/
function refreshAutomations(floor) {
// Die Zeitleiste zeigt dieselben Automatiken - nach Speichern, Pausieren
// oder Loeschen muss sie genauso nachziehen wie die Tabelle.
if (typeof zeitleisteLaden === "function") zeitleisteLaden();
const etagen = floor ? [floor]
: Array.from(document.querySelectorAll("#actions-tabContent .tab-pane"))
.map(pane => pane.id.replace(/^actions-/, ""));
etagen.forEach(etage => {
const ziel = document.getElementById("actions-" + etage + "-list");
if (!ziel) return;
fetch("./ajax/AutoAction.php?action=list&floor=" + etage, {
method: "GET",
headers: { "Requested-With-Ajax": "ajax" }
})
.then(response => response.text())
.then(html => { ziel.innerHTML = html; })
.catch(() => { ziel.innerHTML = "<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));