Files
Smart-Dashboard/js/solar/autoActionFuncs.js
T
adminandClaude Opus 5 4b47372015 Geraete den Raeumen zuordnen
Beim Anlegen einer Automatik denkt man in Raeumen ("im Bad soll ..."), nicht
in Geraetearten. Ein Raummodell gibt es schon - restricted/rooms.php, die
einzige Quelle fuer die Home-Ansicht -, nur fehlte die Zuordnung Geraet ->
Raum.

Die neue Spalte actors.room haelt sie als "Etage/Raum" (OG/Bad), dazu
"aussen" fuer Carport, Garage und Regensensor. Etage und Raum zusammen,
weil "Bad" allein auf drei Etagen vorkommt. Discovery fasst die Spalte nicht
an - der Upsert setzt nur type und name -, die Zuordnung ueberlebt also
jeden Suchlauf.

Zugeordnet wird ueber das Haus-Symbol in der Karte "Automatismen". Wo der
Raum aus dem MQTT-Topic hervorgeht ("Raumtemp/OG/Bad/Temp[degC]"), schlaegt
die Ansicht ihn vor und uebernimmt ihn auf Knopfdruck - das sind die
dreizehn Thermostate, ohne jedes Raten. Bei allen anderen wird bewusst nicht
geraten: "Bad Links" sagt nichts darueber, auf welcher der drei Etagen
dieses Bad liegt. Noch nicht zugeordnete Geraete stehen in der Tabelle oben.

Die Geraeteauswahl im Editor gruppiert danach nach Raum, in der Reihenfolge
aus rooms.php. Solange niemand zugeordnet hat, bleibt es bei der Gruppierung
nach Geraeteart - eine einzige Gruppe "Nicht zugeordnet" waere ein
Rueckschritt. Und weil auch nach der ersten Zuordnung noch viel ohne Raum
ist, wird dieser Rest weiter nach Geraeteart unterteilt ("Ohne Raum ·
Aussenjalousie (11)").

Anders als bei den Gerätearten behalten Raeume mit nur einem Geraet ihre
Ueberschrift: "OG · Bad" ist auch mit einem einzigen Thermostat genau die
Zeile, die man sucht - und mehr als eines haengt selten in einem Raum.

Die dreizehn Thermostate sind bereits zugeordnet, weil das ihre eigenen
Topics hergeben; alles andere steht auf "-" und will von Hand vergeben
werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:16:48 +02:00

802 lines
29 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.
*/
let autoModel = null;
let autoDevices = [];
let autoLockouts = [];
let autoRooms = [];
// --- Katalog-Helfer ------------------------------------------------------
function deviceById(id) {
return autoDevices.find(d => d.id === id) || null;
}
function stateById(id) {
for (const dev of autoDevices) {
const state = dev.states.find(s => s.id === id);
if (state) return { device: dev, state: state };
}
return null;
}
function commandById(id) {
for (const dev of autoDevices) {
const cmd = dev.commands.find(c => c.id === id);
if (cmd) return { device: dev, command: cmd };
}
return null;
}
function operatorLabel(state, op) {
const treffer = state.operators.find(o => o.value === op);
return treffer ? treffer.label : op;
}
/**
* Eine neue Zeile beginnt ohne Geraet.
*
* Frueher stand dort das erste Geraet mit seinem ersten Messwert - nach dem
* Geraete-Discovery also "Bad Links: Gesperrt = ", eine Bedingung, die
* niemand gemeint hat und die man erst wegklicken muss. Ohne Vorbelegung
* sagt die Zeile, was zu tun ist, und der Server lehnt eine unvollstaendige
* Bedingung ohnehin ab.
*/
function leereBedingung() {
return { state_id: 0, operator: "=", value: "" };
}
function leereAktion() {
return { command_id: 0, params: {} };
}
// --- Bausteine fuer die Oberflaeche --------------------------------------
function optionListe(select, eintraege, gewaehlt) {
select.innerHTML = "";
eintraege.forEach(e => select.appendChild(option(e, gewaehlt)));
}
function option(e, gewaehlt) {
const opt = document.createElement("option");
opt.value = e.value;
opt.text = e.text;
if (String(e.value) === String(gewaehlt)) opt.selected = true;
return opt;
}
/**
* Geraeteliste gruppiert - nach Raum, wo zugeordnet, sonst nach Geraeteart.
*
* Beim Anlegen einer Automatik denkt man in Raeumen ("im Bad soll..."), nicht
* in Geraetearten. Der Raum steht aber nur da, wo er von Hand vergeben wurde
* (siehe Zuordnung ueber das Zahnrad in der Karte "Automatismen"); solange
* das niemand getan hat, bleibt die Gruppierung nach Art - besser als eine
* einzige Gruppe "Nicht zugeordnet".
*/
function geraeteGruppen(geraete) {
if (!geraete.some(d => d.room)) {
return gruppiere(geraete, d => d.type, null, "Einzelne", true);
}
const gruppen = gruppiere(geraete, d => d.roomLabel, raumReihenfolge(),
"Nicht zugeordnet", false);
// Was noch keinen Raum hat, bleibt sonst ein einziger Eimer - solange erst
// ein Teil zugeordnet ist, waeren das die meisten Geraete. Innerhalb des
// Eimers gilt deshalb weiter die Ordnung nach Geraeteart.
const letzte = gruppen[gruppen.length - 1];
if (!letzte || !letzte.titel.startsWith("Nicht zugeordnet")) return gruppen;
gruppen.pop();
return gruppen.concat(
gruppiere(letzte.geraete, d => d.type, null, "Einzelne", true)
.map(g => ({ titel: "Ohne Raum · " + g.titel, geraete: g.geraete })));
}
/** Reihenfolge der Raum-Ueberschriften, wie in rooms.php notiert. */
function raumReihenfolge() {
return autoRooms.map(r => r.label).concat(["Nicht zugeordnet"]);
}
/**
* Nach einem Merkmal gruppieren.
*
* `einzelneSammeln` entscheidet, was mit Gruppen aus einem einzigen Geraet
* passiert. Bei Gerätearten sollen sie zusammenfallen - fuenfzehn
* Ueberschriften mit je einem Eintrag waeren unuebersichtlicher als die
* flache Liste. Bei Raeumen nicht: "OG · Bad" ist auch mit einem einzigen
* Geraet die Ueberschrift, die man sucht, und in der Wohnung haengt oft nur
* ein Thermostat je Raum.
*/
function gruppiere(geraete, merkmal, reihenfolge, restTitel, einzelneSammeln) {
const nach = new Map();
geraete.forEach(d => {
const schluessel = merkmal(d) || restTitel;
if (!nach.has(schluessel)) nach.set(schluessel, []);
nach.get(schluessel).push(d);
});
const titel = Array.from(nach.keys());
titel.sort((a, b) => {
if (reihenfolge) {
const ia = reihenfolge.indexOf(a), ib = reihenfolge.indexOf(b);
if (ia !== ib) return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
}
return a.localeCompare(b, "de");
});
const gruppen = [];
const rest = [];
titel.forEach(t => {
const liste = nach.get(t);
if (!einzelneSammeln || liste.length > 1 || t === restTitel) {
gruppen.push({ titel: t + " (" + 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: restTitel + " (" + 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 und die Jalousie
* "Bad Links", "jalousie" findet alle elf auf einmal. Gesucht wird deshalb
* ueber Beschriftung und Geraeteart zugleich.
*
* Statt einer Auswahlliste steht ein Knopf in der Zeile; die Liste klappt
* darunter ueber die volle Breite auf. Auf dem Handy ist das mehr Platz als
* ein natives Auswahlfeld hergibt, und das Suchfeld waere darin ohnehin
* nicht unterzubringen.
*/
function geraeteWaehler(geraete, aktuelleId, beiAuswahl) {
const knopf = document.createElement("button");
knopf.type = "button";
knopf.className = "form-select text-start auto-geraet";
const dev = geraete.find(d => d.id === aktuelleId);
knopf.textContent = dev ? dev.label : "Gerät wählen …";
if (!dev) knopf.classList.add("text-body-secondary");
knopf.onclick = () => oeffneWaehler(knopf, geraete, aktuelleId, beiAuswahl);
return knopf;
}
/** Die aufgeklappte Liste - es ist immer hoechstens eine offen. */
function schliesseWaehler() {
const offen = document.querySelector(".geraete-waehler");
if (offen) offen.remove();
}
function oeffneWaehler(knopf, geraete, aktuelleId, beiAuswahl) {
const schonOffen = knopf.nextElementSibling
&& knopf.nextElementSibling.classList.contains("geraete-waehler");
schliesseWaehler();
if (schonOffen) return; // zweiter Klick schliesst wieder
const panel = document.createElement("div");
panel.className = "geraete-waehler border rounded p-2 mt-1";
const suche = document.createElement("input");
suche.type = "search";
suche.className = "form-control form-control-sm mb-2";
suche.placeholder = "Suchen – Name oder Geräteart";
const liste = document.createElement("div");
liste.className = "list-group list-group-flush";
liste.style.maxHeight = "15rem";
liste.style.overflowY = "auto";
panel.appendChild(suche);
panel.appendChild(liste);
// Hinter die ganze Zeile haengen, nicht in sie hinein: in einer
// input-group stuende die Liste sonst als weitere Spalte daneben.
knopf.closest(".input-group").after(panel);
const eintrag = (d) => {
const zeile = document.createElement("button");
zeile.type = "button";
zeile.className = "list-group-item list-group-item-action py-1"
+ (d.id === aktuelleId ? " active" : "");
zeile.innerHTML = "<span>" + textSicher(d.label) + "</span>"
+ "<small class='ms-2 opacity-75'>" + textSicher(d.type) + "</small>";
zeile.onclick = () => { schliesseWaehler(); beiAuswahl(d); };
return zeile;
};
const zeichne = () => {
const suchtext = suche.value.trim().toLowerCase();
liste.innerHTML = "";
const passend = geraete.filter(d => !suchtext
|| d.label.toLowerCase().includes(suchtext)
|| d.type.toLowerCase().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 Arten, und die Art steht ohnehin an jeder Zeile.
if (suchtext) {
passend.forEach(d => liste.appendChild(eintrag(d)));
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)));
});
};
suche.oninput = zeichne;
suche.onkeydown = e => {
if (e.key === "Escape") { schliesseWaehler(); knopf.focus(); }
if (e.key === "Enter") {
e.preventDefault();
const erster = liste.querySelector("button.list-group-item");
if (erster) erster.click();
}
};
zeichne();
suche.focus();
}
// Klick daneben schliesst die Liste - sonst bliebe sie stehen, wenn man es
// sich anders ueberlegt.
document.addEventListener("click", e => {
if (e.target.closest(".geraete-waehler") || e.target.closest(".form-select.text-start")) return;
schliesseWaehler();
});
function textSicher(text) {
return String(text).replace(/&/g, "&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 -----------------------------------------------------------
/**
* Zeichnet alle UND-Bloecke. Jeder Block hat sein eigenes "+ Bedingung", denn
* mit einem einzigen Knopf ganz unten kaeme man immer nur an den letzten
* Block heran und koennte eine bestehende Alternative nie mehr ergaenzen.
*/
function renderConditions() {
const ziel = document.getElementById("condBlocks");
ziel.innerHTML = "";
autoModel.groups.forEach((gruppe, gi) => {
if (gi > 0) {
const trenner = document.createElement("div");
trenner.className = "d-flex align-items-center my-2 text-body-secondary";
trenner.innerHTML = "<hr class='flex-grow-1 m-0'>"
+ "<span class='px-3 small fw-bold'>ODER</span>"
+ "<hr class='flex-grow-1 m-0'>";
ziel.appendChild(trenner);
}
const block = document.createElement("div");
block.className = "border rounded p-2";
gruppe.forEach((bed, bi) => {
if (bi > 0) {
const und = document.createElement("div");
und.className = "small fw-bold text-body-secondary ms-1 my-1";
und.textContent = "und";
block.appendChild(und);
}
block.appendChild(conditionRow(gi, bi, bed));
});
const add = document.createElement("button");
add.type = "button";
add.className = "btn btn-outline-success btn-sm mt-2";
add.innerHTML = "<i class='bi bi-plus-lg'></i> Bedingung";
add.onclick = () => { gruppe.push(leereBedingung()); renderConditions(); };
block.appendChild(add);
ziel.appendChild(block);
});
renderSummary();
}
function conditionRow(gi, bi, bed) {
const zeile = document.createElement("div");
zeile.className = "input-group auto-zeile";
const treffer = stateById(bed.state_id);
const dev = treffer ? treffer.device : null;
const state = treffer ? treffer.state : null;
// Geraet
zeile.appendChild(geraeteWaehler(
autoDevices.filter(d => d.states.length), dev ? dev.id : 0,
neu => {
bed.state_id = neu.states[0].id;
bed.operator = neu.states[0].operators[0].value;
bed.value = standardWert(neu.states[0]);
renderConditions();
}));
// Messwert - nur zeigen, wenn das Geraet mehr als einen hat
if (dev && dev.states.length > 1) {
const messwert = document.createElement("select");
messwert.className = "form-select auto-messwert";
optionListe(messwert, dev.states.map(s => ({
value: s.id,
text: s.name + (s.value ? " (= " + s.value + ")" : "")
})), bed.state_id);
messwert.onchange = () => {
const neu = dev.states.find(s => s.id === Number(messwert.value));
bed.state_id = neu.id;
bed.operator = neu.operators[0].value;
bed.value = standardWert(neu);
renderConditions();
};
zeile.appendChild(messwert);
}
if (state) {
// Operator. Eine Auswahlliste statt des frueheren Umschalt-Knopfes: der
// zeigte nur seinen aktuellen Zustand, welche Vergleiche es sonst noch
// gibt, fand man erst durch Klicken heraus.
const op = document.createElement("select");
// Feste, schmale Breite: der Pfeil der Auswahlliste braucht Platz, sonst
// schiebt er das Zeichen aus dem sichtbaren Bereich.
op.className = "form-select flex-grow-0 text-center";
op.style.flex = "0 0 5.5rem";
optionListe(op, state.operators.map(o => ({ value: o.value, text: o.label })), bed.operator);
op.onchange = () => { bed.operator = op.value; renderSummary(); };
zeile.appendChild(op);
const wert = wertFeld(state, bed.value);
wert.onchange = () => { bed.value = wert.value; renderSummary(); };
zeile.appendChild(wert);
if (state.unit) {
const einheit = document.createElement("span");
einheit.className = "input-group-text";
einheit.textContent = state.unit;
zeile.appendChild(einheit);
}
}
const weg = document.createElement("button");
weg.type = "button";
weg.className = "btn btn-outline-danger";
weg.innerHTML = "<i class='bi bi-trash3'></i>";
weg.onclick = () => {
autoModel.groups[gi].splice(bi, 1);
// Ein leerer Block hat keine Bedeutung mehr und verschwindet mit.
if (!autoModel.groups[gi].length) autoModel.groups.splice(gi, 1);
renderConditions();
};
zeile.appendChild(weg);
return zeile;
}
/**
* Derselbe Satz, den auch die Uebersicht und der Runner benutzen. Er steht
* unter den Bloecken, damit man die Klammerung nicht aus der Optik erschliessen
* muss, sondern schwarz auf weiss liest.
*/
function conditionText() {
const teile = autoModel.groups
.map(g => g.filter(bed => stateById(bed.state_id)))
.filter(g => g.length)
.map(gruppe => {
const text = gruppe.map(bed => {
const treffer = stateById(bed.state_id);
let s = treffer.device.label + ": " + treffer.state.name + " "
+ operatorLabel(treffer.state, bed.operator) + " " + bed.value;
if (treffer.state.unit) s += " " + treffer.state.unit;
return s;
}).join(" und ");
return (autoModel.groups.length > 1 && gruppe.length > 1) ? "(" + text + ")" : text;
});
return teile.join(" oder ");
}
function renderSummary() {
const text = conditionText();
document.getElementById("condSummary").innerHTML = text
? "Löst aus, wenn <strong>" + text.replace(/&/g, "&amp;").replace(/</g, "&lt;") + "</strong>."
: "Noch kein Auslöser – die Automatik würde nie starten.";
}
// --- Aktionen ------------------------------------------------------------
function renderActions() {
const ziel = document.getElementById("actionList");
ziel.innerHTML = "";
autoModel.actions.forEach((aktion, ai) => ziel.appendChild(actionRow(ai, aktion)));
}
function actionRow(ai, aktion) {
const zeile = document.createElement("div");
zeile.className = "input-group mb-2 auto-zeile";
const treffer = commandById(aktion.command_id);
const dev = treffer ? treffer.device : null;
const cmd = treffer ? treffer.command : null;
zeile.appendChild(geraeteWaehler(
autoDevices.filter(d => d.commands.length), dev ? dev.id : 0,
neu => {
aktion.command_id = neu.commands[0].id;
aktion.params = standardParams(neu.commands[0]);
renderActions();
}));
if (dev && dev.commands.length > 1) {
const befehl = document.createElement("select");
befehl.className = "form-select auto-messwert";
optionListe(befehl, dev.commands.map(c => ({ value: c.id, text: c.name })), aktion.command_id);
befehl.onchange = () => {
const neu = dev.commands.find(c => c.id === Number(befehl.value));
aktion.command_id = neu.id;
aktion.params = standardParams(neu);
renderActions();
};
zeile.appendChild(befehl);
}
// Je Parameter ein Feld - wie viele es sind, sagt das Geraet. Frueher gab es
// vier feste Felder "Wert 1" bis "Wert 4", die je nach Kommando versteckt
// wurden, ihren Inhalt aber trotzdem mitschickten.
if (cmd) {
cmd.params.forEach(p => {
const beschriftung = document.createElement("span");
beschriftung.className = "input-group-text";
beschriftung.textContent = p.name;
zeile.appendChild(beschriftung);
const wert = wertFeld(p, aktion.params[p.id] !== undefined ? aktion.params[p.id] : standardWert(p));
wert.onchange = () => { aktion.params[p.id] = wert.value; };
zeile.appendChild(wert);
});
}
const weg = document.createElement("button");
weg.type = "button";
weg.className = "btn btn-outline-danger";
weg.innerHTML = "<i class='bi bi-trash3'></i>";
weg.onclick = () => { autoModel.actions.splice(ai, 1); renderActions(); };
zeile.appendChild(weg);
return zeile;
}
function standardParams(cmd) {
const params = {};
cmd.params.forEach(p => { params[p.id] = standardWert(p); });
return params;
}
// --- Rahmenbedingungen ---------------------------------------------------
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
function renderWeekdays() {
const ziel = document.getElementById("weekdayGroup");
ziel.innerHTML = "";
WOCHENTAGE.forEach((tag, i) => {
const feld = document.createElement("div");
feld.className = "input-group-text";
feld.innerHTML = "<input class='form-check-input mt-0 me-1' type='checkbox' id='day" + i + "'"
+ (autoModel.weekdays & (1 << i) ? " checked" : "") + ">"
+ "<label class='form-check-label' for='day" + i + "'>" + tag + "</label>";
feld.querySelector("input").onchange = e => {
if (e.target.checked) autoModel.weekdays |= (1 << i);
else autoModel.weekdays &= ~(1 << i);
};
ziel.appendChild(feld);
});
}
function fuelleFormular() {
const sperre = document.getElementById("lockoutSecs");
optionListe(sperre, autoLockouts.map(o => ({ value: o.value, text: o.label })),
autoModel.lockout_secs);
document.getElementById("autoName").value = autoModel.name;
// Kennt die Liste die Etage nicht, bleibt eine Auswahlliste sonst leer
// stehen und schickt beim Speichern "" mit.
const etage = document.getElementById("autoFloor");
etage.value = autoModel.floor;
if (etage.selectedIndex < 0) {
etage.selectedIndex = 0;
autoModel.floor = etage.value;
}
document.getElementById("tSpanFrom").value = autoModel.window_from;
document.getElementById("tSpanTo").value = autoModel.window_to;
document.getElementById("actFerien").checked = autoModel.on_vacation;
document.getElementById("actFeier").checked = autoModel.on_holiday;
document.getElementById("runOnce").checked = autoModel.force_once;
renderWeekdays();
renderConditions();
renderActions();
}
/** Die Felder, die direkt am Formular haengen, ins Modell zurueckholen. */
function leseFormular() {
autoModel.name = document.getElementById("autoName").value.trim();
autoModel.floor = document.getElementById("autoFloor").value;
autoModel.window_from = document.getElementById("tSpanFrom").value;
autoModel.window_to = document.getElementById("tSpanTo").value;
autoModel.on_vacation = document.getElementById("actFerien").checked;
autoModel.on_holiday = document.getElementById("actFeier").checked;
autoModel.force_once = document.getElementById("runOnce").checked;
autoModel.lockout_secs = Number(document.getElementById("lockoutSecs").value);
}
// --- Modal ---------------------------------------------------------------
function loadAutomatic() {
const daten = JSON.parse(document.getElementById("autoActionData").textContent);
autoDevices = daten.devices;
autoLockouts = daten.lockouts;
autoRooms = daten.rooms || [];
const a = daten.automation;
autoModel = {
id: a.id,
name: a.name,
floor: a.floor,
enabled: a.enabled,
window_from: a.window_from,
window_to: a.window_to,
weekdays: a.weekdays,
on_vacation: a.on_vacation,
on_holiday: a.on_holiday,
force_once: a.force_once,
lockout_secs: a.lockout_secs,
groups: [],
actions: a.actions.map(x => ({ command_id: x.command_id, params: Object.assign({}, x.params) }))
};
// Die flache Liste aus der Datenbank wieder in Bloecke fassen. Die
// Gruppennummern kommen aufsteigend und dicht, siehe saveAutomation().
const nachGruppe = new Map();
a.conditions.forEach(c => {
if (!nachGruppe.has(c.group)) nachGruppe.set(c.group, []);
nachGruppe.get(c.group).push({ state_id: c.state_id, operator: c.operator, value: c.value });
});
autoModel.groups = Array.from(nachGruppe.values());
// Eine neue Automatik startet mit je einer leeren Zeile, sonst steht man
// vor einem leeren Formular ohne erkennbaren Anfang.
if (!autoModel.groups.length) autoModel.groups = [[leereBedingung()]];
if (!autoModel.actions.length) autoModel.actions = [leereAktion()];
fuelleFormular();
}
function openAutoActionModal(params) {
const contentURL = "./ajax/AutoAction.php" + params;
document.getElementById("modal-title").innerHTML =
params.search("id=") > 0 ? "Automatik bearbeiten" : "Neue Automatik anlegen";
// Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne
// Zuhoerer stellt sicher, dass nicht auch noch der Handler eines vorher
// geoeffneten Modals mitfeuert.
const alt = document.getElementById("modalSaveBtn");
const btn = alt.cloneNode(true);
alt.replaceWith(btn);
btn.addEventListener("click", submitAutoAction);
// Der Editor braucht mehr Breite als die Ladedialoge, gibt sie aber wieder
// her, damit die anderen Modals unveraendert aussehen.
const dialog = document.querySelector("#modalEV .modal-dialog");
dialog.classList.add("modal-xl");
document.getElementById("modalEV").addEventListener("hidden.bs.modal",
() => dialog.classList.remove("modal-xl"), { once: true });
const modalBodyElement = document.getElementById("modal-body");
modalBodyElement.innerHTML = loadingHTML("Wird geladen...");
modalEV.show();
fetch(contentURL, {
method: "GET",
headers: { "X-Requested-From-Modal": "a", "Requested-With-Ajax": "ajax" }
})
.then(response => response.text())
.then(html => {
modalBodyElement.innerHTML = html;
loadAutomatic();
document.getElementById("btnAddGroup").addEventListener("click", () => {
autoModel.groups.push([leereBedingung()]);
renderConditions();
});
document.getElementById("btnAddActor").addEventListener("click", () => {
autoModel.actions.push(leereAktion());
renderActions();
});
})
.catch(error => {
modalBodyElement.innerHTML = "Konnte den Editor nicht laden: " + error.message;
});
}
function submitAutoAction() {
leseFormular();
const nutzlast = {
id: autoModel.id,
name: autoModel.name,
floor: autoModel.floor,
enabled: autoModel.enabled,
window_from: autoModel.window_from,
window_to: autoModel.window_to,
weekdays: autoModel.weekdays,
on_vacation: autoModel.on_vacation,
on_holiday: autoModel.on_holiday,
force_once: autoModel.force_once,
lockout_secs: autoModel.lockout_secs,
conditions: [],
actions: autoModel.actions
};
autoModel.groups.forEach((gruppe, gi) => {
gruppe.forEach(bed => {
nutzlast.conditions.push({ group: gi, state_id: bed.state_id, operator: bed.operator, value: bed.value });
});
});
automationRequest("save", nutzlast).then(antwort => {
if (antwort.error) {
alert(antwort.error);
return;
}
modalEV.hide();
refreshAutomations(nutzlast.floor);
});
}
// --- Uebersicht ----------------------------------------------------------
function automationRequest(action, body) {
return fetch("./ajax/AutoAction.php?action=" + action, {
method: "POST",
headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" },
body: JSON.stringify(body)
})
.then(response => response.json())
.catch(error => ({ error: error.message }));
}
/** Die Tabelle einer Etage neu holen. Ohne Etage alle drei. */
function refreshAutomations(floor) {
const etagen = floor ? [floor] : ["UG", "EG", "OG"];
etagen.forEach(etage => {
const ziel = document.getElementById("actions-" + etage + "-list");
if (!ziel) return;
fetch("./ajax/AutoAction.php?action=list&floor=" + etage, {
method: "GET",
headers: { "Requested-With-Ajax": "ajax" }
})
.then(response => response.text())
.then(html => { ziel.innerHTML = html; })
.catch(() => { ziel.innerHTML = "<p class='text-danger'>Liste konnte nicht geladen werden.</p>"; });
});
}
/**
* Zuordnung Geraet -> Raum. Eigenes Modal, weil es kein Teil einer einzelnen
* Automatik ist, sondern einmal fuer alle gilt.
*/
function openRoomModal() {
document.getElementById("modal-title").innerHTML = "Geräte den Räumen zuordnen";
const alt = document.getElementById("modalSaveBtn");
const btn = alt.cloneNode(true);
alt.replaceWith(btn);
btn.addEventListener("click", speichereRaeume);
const dialog = document.querySelector("#modalEV .modal-dialog");
dialog.classList.add("modal-lg");
document.getElementById("modalEV").addEventListener("hidden.bs.modal",
() => dialog.classList.remove("modal-lg"), { once: true });
const koerper = document.getElementById("modal-body");
koerper.innerHTML = loadingHTML("Wird geladen...");
modalEV.show();
fetch("./ajax/AutoAction.php?action=rooms", {
method: "GET", headers: { "Requested-With-Ajax": "ajax" }
})
.then(a => a.text())
.then(html => {
koerper.innerHTML = html;
const tipps = document.getElementById("btnRoomTipps");
if (tipps) {
tipps.addEventListener("click", () => {
koerper.querySelectorAll("select[data-tipp]").forEach(s => {
if (!s.value) s.value = s.dataset.tipp;
});
tipps.disabled = true;
});
}
})
.catch(fehler => { koerper.innerHTML = "Konnte die Zuordnung nicht laden: " + fehler.message; });
}
function speichereRaeume() {
const rooms = {};
document.querySelectorAll("#modal-body select[data-actor]").forEach(s => {
rooms[s.dataset.actor] = s.value;
});
automationRequest("saveRooms", { rooms: rooms }).then(antwort => {
if (antwort.error) alert(antwort.error);
else modalEV.hide();
});
}
function toggleAutomation(id) {
automationRequest("toggle", { id: id }).then(antwort => {
if (antwort.error) alert(antwort.error);
else refreshAutomations(null);
});
}
function deleteAutomation(id, name) {
if (!confirm("Automatik \"" + name + "\" wirklich löschen?")) return;
automationRequest("delete", { id: id }).then(antwort => {
if (antwort.error) alert(antwort.error);
else refreshAutomations(null);
});
}
document.addEventListener("DOMContentLoaded", () => refreshAutomations(null));