Die Einstellungen bestanden aus einer einzigen langen Spalte mit den Preistabellen. Sie bekommt Reiter, und darin zwei neue Flaechen: was die Kacheln der Startseite anzeigen, und welche Geraete es ueberhaupt gibt. Reiter gab es vorher dreimal in drei Ausfuehrungen - die Etagen ueber den Automatismen mit eigenem switchTab(), die Geraetegruppen im Raum-Modal mit eigener CSS-Klasse, und die Einstellungsseite noch einmal anders. Jetzt baut restricted/reiter.php alle drei; umgeschaltet wird von Bootstrap, den offenen Reiter merkt reiterMerken() in der Adresse. Kachelwerte: was auf einer Kachel steht, stand fest in rooms.php. Die neue Tabelle kachelWerte (solarLog_kacheln.sql) ueberschreibt das je Raum, die Maske waehlt die Messwerte aus demselben Katalog wie der Automatik-Editor - nach Etage sortiert, die Geraete des eigenen Raums oben. Fehlt die Tabelle, zeigt jede Kachel weiter ihre Vorgabe. Geraete: die Zuordnung Geraet -> Raum war ein Modal, erreichbar ueber ein Zahnrad neben den Automatismen. Sie ist eine Einstellung und steht jetzt dort, zusammen mit dem, was fehlte: seit wann ein Geraet nichts mehr meldet, in welchen Automatiken es steckt, und ein Loeschknopf. Letzterer ist noetig, weil der Suchlauf nichts loescht - er legt an und frischt auf, damit die Automatiken ihre Zustands-Ids behalten; ein abgebautes Geraet bliebe sonst fuer immer stehen. Geloescht wird nur, was in keiner Automatik vorkommt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
819 lines
30 KiB
JavaScript
819 lines
30 KiB
JavaScript
/**
|
||
* Editor und Uebersicht fuer die Automatiken.
|
||
*
|
||
* Der Editor arbeitet auf einem Modell und zeichnet daraus die Oberflaeche -
|
||
* nicht umgekehrt. Frueher stand die erste Bedingungszeile als festes HTML im
|
||
* PHP und alle weiteren wurden im JavaScript zusammengesetzt; beide Fassungen
|
||
* liefen auseinander, und beim Speichern musste der Server aus durchnummerierten
|
||
* Feldnamen wieder eine Struktur raten.
|
||
*
|
||
* Modell:
|
||
* autoModel.groups = [ [ {state_id, operator, value}, ... ], ... ]
|
||
* eine innere Liste ist ein UND-Block, die aeussere
|
||
* verknuepft die Bloecke mit ODER
|
||
* autoModel.actions = [ {command_id, params: {parameterID: wert}}, ... ]
|
||
*
|
||
* Der Geraetekatalog kommt zusammen mit dem Datensatz im selben Dokument
|
||
* (siehe ajax/AutoAction.php), es gibt also keine Nachladerei je Zeile.
|
||
*/
|
||
|
||
let autoModel = null;
|
||
let autoDevices = [];
|
||
let autoLockouts = [];
|
||
let autoRooms = [];
|
||
// Die Etage, mit der der Editor aufgemacht wurde. Wird sie geaendert,
|
||
// braucht auch die Liste der alten Etage ein Nachladen.
|
||
let autoFloorVorher = "";
|
||
|
||
// --- Katalog-Helfer ------------------------------------------------------
|
||
|
||
function deviceById(id) {
|
||
return autoDevices.find(d => d.id === id) || null;
|
||
}
|
||
|
||
function stateById(id) {
|
||
for (const dev of autoDevices) {
|
||
const state = dev.states.find(s => s.id === id);
|
||
if (state) return { device: dev, state: state };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function commandById(id) {
|
||
for (const dev of autoDevices) {
|
||
const cmd = dev.commands.find(c => c.id === id);
|
||
if (cmd) return { device: dev, command: cmd };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function operatorLabel(state, op) {
|
||
const treffer = state.operators.find(o => o.value === op);
|
||
return treffer ? treffer.label : op;
|
||
}
|
||
|
||
/**
|
||
* Eine neue Zeile beginnt ohne Geraet.
|
||
*
|
||
* Frueher stand dort das erste Geraet mit seinem ersten Messwert - nach dem
|
||
* Geraete-Discovery also "Bad Links: Gesperrt = ", eine Bedingung, die
|
||
* niemand gemeint hat und die man erst wegklicken muss. Ohne Vorbelegung
|
||
* sagt die Zeile, was zu tun ist, und der Server lehnt eine unvollstaendige
|
||
* Bedingung ohnehin ab.
|
||
*/
|
||
function leereBedingung() {
|
||
return { state_id: 0, operator: "=", value: "" };
|
||
}
|
||
|
||
function leereAktion() {
|
||
return { command_id: 0, params: {} };
|
||
}
|
||
|
||
// --- Bausteine fuer die Oberflaeche --------------------------------------
|
||
|
||
function optionListe(select, eintraege, gewaehlt) {
|
||
select.innerHTML = "";
|
||
eintraege.forEach(e => select.appendChild(option(e, gewaehlt)));
|
||
}
|
||
|
||
function option(e, gewaehlt) {
|
||
const opt = document.createElement("option");
|
||
opt.value = e.value;
|
||
opt.text = e.text;
|
||
if (String(e.value) === String(gewaehlt)) opt.selected = true;
|
||
return opt;
|
||
}
|
||
|
||
/**
|
||
* Geraeteliste fuer die Auswahl: eine Ueberschrift je Etage, darin nach Raum
|
||
* sortiert.
|
||
*
|
||
* Beim Anlegen einer Automatik denkt man in Raeumen ("im Bad soll ..."), eine
|
||
* Ueberschrift je Raum waeren aber zwanzig Stueck - in einer Liste, die
|
||
* fuenfzehn Zeilen hoch ist, saehe man dann mehr Ueberschrift als Geraet, und
|
||
* "OG · Bad", "EG · Bad", "UG · Bad" schrieben die Etage immer wieder
|
||
* dazu. Deshalb gruppiert nur die Etage; der Raum steht klein an jeder Zeile,
|
||
* und weil danach sortiert wird, stehen die Geraete eines Raums trotzdem
|
||
* beisammen.
|
||
*
|
||
* Der Raum steht nur da, wo er von Hand vergeben wurde (Zuordnung ueber das
|
||
* Zahnrad in der Karte "Automatismen"). Solange das niemand getan hat, gaebe
|
||
* die Etage nichts her - dann bleibt es bei der Ordnung nach Geraeteart.
|
||
*/
|
||
function geraeteGruppen(geraete) {
|
||
if (!geraete.some(d => d.room)) {
|
||
return nachGeraeteart(geraete);
|
||
}
|
||
const etagen = etagenReihenfolge();
|
||
// "Ohne Raum" und "Unbekannter Raum" stehen in keiner Raumliste. Ihr
|
||
// indexOf() waere -1 und damit ganz vorne; sie gehoeren aber ans Ende.
|
||
const platz = etage => etagen.includes(etage) ? etagen.indexOf(etage) : etagen.length;
|
||
const nach = new Map();
|
||
geraete.forEach(d => {
|
||
if (!nach.has(d.floorLabel)) nach.set(d.floorLabel, []);
|
||
nach.get(d.floorLabel).push(d);
|
||
});
|
||
return Array.from(nach.keys())
|
||
.sort((a, b) => platz(a) - platz(b) || a.localeCompare(b, "de"))
|
||
.map(etage => {
|
||
const liste = nach.get(etage);
|
||
liste.sort((a, b) => raumIndex(a.room) - raumIndex(b.room)
|
||
|| a.type.localeCompare(b.type, "de")
|
||
|| a.label.localeCompare(b.label, "de"));
|
||
return { titel: etage + " (" + liste.length + ")", geraete: liste };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Reihenfolge der Etagen-Ueberschriften, wie rooms.php sie notiert - erst OG,
|
||
* dann EG, dann UG, dann Aussen.
|
||
*/
|
||
function etagenReihenfolge() {
|
||
const etagen = [];
|
||
autoRooms.forEach(r => { if (!etagen.includes(r.floor)) etagen.push(r.floor); });
|
||
return etagen;
|
||
}
|
||
|
||
/**
|
||
* Platz eines Raums in rooms.php - die Sortierung innerhalb einer Etage.
|
||
*
|
||
* Alles ohne Raum bekommt denselben letzten Platz; dort entscheidet dann die
|
||
* Geraeteart, sonst stuenden Jalousie, Steckdose und Thermostat wild
|
||
* durcheinander.
|
||
*/
|
||
function raumIndex(key) {
|
||
const i = autoRooms.findIndex(r => r.key === key);
|
||
return i < 0 ? autoRooms.length : i;
|
||
}
|
||
|
||
/**
|
||
* Was klein hinter dem Geraetenamen steht.
|
||
*
|
||
* Unter einer Etagen-Ueberschrift genuegt der Raum. In der Trefferliste einer
|
||
* Suche steht keine Ueberschrift darueber, dort muss die Etage mit. Wo kein
|
||
* Raum vergeben ist, steht die Geraeteart - die ist immer da und sagt
|
||
* wenigstens, was fuer ein Ding das ist.
|
||
*/
|
||
function zeilenZusatz(d, mitEtage) {
|
||
if (!d.roomName) return d.type;
|
||
return mitEtage ? d.roomLabel : d.roomName;
|
||
}
|
||
|
||
/**
|
||
* Worueber die Suche laeuft: Name, Raum und Geraeteart in einem Zug.
|
||
*
|
||
* Der Raum gehoert dazu, seit die Liste nach Etagen gruppiert - "bad" soll
|
||
* auch den Handtuchtrockner finden, der im Bad haengt, ohne es im Namen zu
|
||
* tragen. Ohne Zuordnung faellt der Raum weg; "Nicht zugeordnet" als
|
||
* Suchbegriff waere kein Fund, sondern ein Versehen.
|
||
*/
|
||
function suchfeld(d) {
|
||
return (d.label + " " + (d.roomName ? d.roomLabel : "") + " " + d.type).toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* Nach Geraeteart gruppieren - der Rueckfall, solange kein Raum vergeben ist.
|
||
*
|
||
* Gruppen aus einem einzigen Geraet fallen dabei unter "Einzelne" zusammen:
|
||
* fuenfzehn Ueberschriften mit je einem Eintrag waeren unuebersichtlicher als
|
||
* die flache Liste darunter.
|
||
*/
|
||
function nachGeraeteart(geraete) {
|
||
const nach = new Map();
|
||
geraete.forEach(d => {
|
||
if (!nach.has(d.type)) nach.set(d.type, []);
|
||
nach.get(d.type).push(d);
|
||
});
|
||
const gruppen = [];
|
||
const rest = [];
|
||
Array.from(nach.keys()).sort((a, b) => a.localeCompare(b, "de")).forEach(art => {
|
||
const liste = nach.get(art);
|
||
if (liste.length > 1) gruppen.push({ titel: art + " (" + liste.length + ")", geraete: liste });
|
||
else rest.push(liste[0]);
|
||
});
|
||
if (rest.length) {
|
||
rest.sort((a, b) => a.label.localeCompare(b.label, "de"));
|
||
gruppen.push({ titel: "Einzelne (" + rest.length + ")", geraete: rest });
|
||
}
|
||
return gruppen;
|
||
}
|
||
|
||
/**
|
||
* Geraeteauswahl mit Suchfeld.
|
||
*
|
||
* Bei ueber fuenfzig Geraeten hilft Tippen mehr als Blaettern - und es hilft
|
||
* quer zur Gruppierung: "bad" findet das Thermostat im Bad, die Jalousie
|
||
* "Bad Links" und alles andere, was einem Bad zugeordnet ist, "jalousie"
|
||
* findet alle elf auf einmal. Gesucht wird deshalb ueber Beschriftung, Raum
|
||
* und Geraeteart zugleich.
|
||
*
|
||
* Statt einer Auswahlliste steht ein Knopf in der Zeile; die Liste klappt
|
||
* darunter ueber die volle Breite auf. Auf dem Handy ist das mehr Platz als
|
||
* ein natives Auswahlfeld hergibt, und das Suchfeld waere darin ohnehin
|
||
* nicht unterzubringen.
|
||
*/
|
||
function geraeteWaehler(geraete, aktuelleId, beiAuswahl) {
|
||
const knopf = document.createElement("button");
|
||
knopf.type = "button";
|
||
knopf.className = "form-select text-start auto-geraet";
|
||
const dev = geraete.find(d => d.id === aktuelleId);
|
||
knopf.textContent = dev ? dev.label : "Gerät wählen …";
|
||
if (!dev) knopf.classList.add("text-body-secondary");
|
||
knopf.onclick = () => oeffneWaehler(knopf, geraete, aktuelleId, beiAuswahl);
|
||
return knopf;
|
||
}
|
||
|
||
/** Die aufgeklappte Liste - es ist immer hoechstens eine offen. */
|
||
function schliesseWaehler() {
|
||
const offen = document.querySelector(".geraete-waehler");
|
||
if (offen) offen.remove();
|
||
}
|
||
|
||
function oeffneWaehler(knopf, geraete, aktuelleId, beiAuswahl) {
|
||
const schonOffen = knopf.nextElementSibling
|
||
&& knopf.nextElementSibling.classList.contains("geraete-waehler");
|
||
schliesseWaehler();
|
||
if (schonOffen) return; // zweiter Klick schliesst wieder
|
||
|
||
const panel = document.createElement("div");
|
||
panel.className = "geraete-waehler border rounded p-2 mt-1";
|
||
const suche = document.createElement("input");
|
||
suche.type = "search";
|
||
suche.className = "form-control form-control-sm mb-2";
|
||
suche.placeholder = "Suchen – Name, Raum oder Geräteart";
|
||
const liste = document.createElement("div");
|
||
liste.className = "list-group list-group-flush";
|
||
liste.style.maxHeight = "15rem";
|
||
liste.style.overflowY = "auto";
|
||
panel.appendChild(suche);
|
||
panel.appendChild(liste);
|
||
// Hinter die ganze Zeile haengen, nicht in sie hinein: in einer
|
||
// input-group stuende die Liste sonst als weitere Spalte daneben.
|
||
knopf.closest(".input-group").after(panel);
|
||
|
||
const eintrag = (d, mitEtage) => {
|
||
const zeile = document.createElement("button");
|
||
zeile.type = "button";
|
||
// Der Zusatz steht rechts am Rand statt gleich hinter dem Namen: so
|
||
// stehen die Raeume untereinander in einer Spalte und lassen sich
|
||
// ueberfliegen, statt bei jeder Zeile woanders anzufangen.
|
||
zeile.className = "list-group-item list-group-item-action py-1"
|
||
+ " d-flex justify-content-between align-items-center gap-2"
|
||
+ (d.id === aktuelleId ? " active" : "");
|
||
zeile.innerHTML = "<span class='text-truncate'>" + textSicher(d.label) + "</span>"
|
||
+ "<small class='opacity-75 text-nowrap'>"
|
||
+ textSicher(zeilenZusatz(d, mitEtage)) + "</small>";
|
||
zeile.onclick = () => { schliesseWaehler(); beiAuswahl(d); };
|
||
return zeile;
|
||
};
|
||
|
||
const zeichne = () => {
|
||
const suchtext = suche.value.trim().toLowerCase();
|
||
liste.innerHTML = "";
|
||
const passend = geraete.filter(d => !suchtext || suchfeld(d).includes(suchtext));
|
||
if (!passend.length) {
|
||
liste.innerHTML = "<div class='p-2 small text-body-secondary'>Nichts gefunden.</div>";
|
||
return;
|
||
}
|
||
// Beim Suchen die Zwischenueberschriften weglassen: die Treffer stehen
|
||
// dann quer ueber alle Etagen. Dafuer traegt jede Zeile die Etage mit -
|
||
// "Bad" allein sagt sonst nicht, welches der drei Baeder gemeint ist.
|
||
if (suchtext) {
|
||
passend.forEach(d => liste.appendChild(eintrag(d, true)));
|
||
return;
|
||
}
|
||
geraeteGruppen(passend).forEach(g => {
|
||
const kopf = document.createElement("div");
|
||
kopf.className = "px-2 pt-2 pb-1 small fw-bold text-body-secondary";
|
||
kopf.textContent = g.titel;
|
||
liste.appendChild(kopf);
|
||
g.geraete.forEach(d => liste.appendChild(eintrag(d, false)));
|
||
});
|
||
};
|
||
|
||
suche.oninput = zeichne;
|
||
suche.onkeydown = e => {
|
||
if (e.key === "Escape") { schliesseWaehler(); knopf.focus(); }
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
const erster = liste.querySelector("button.list-group-item");
|
||
if (erster) erster.click();
|
||
}
|
||
};
|
||
zeichne();
|
||
suche.focus();
|
||
}
|
||
|
||
// Klick daneben schliesst die Liste - sonst bliebe sie stehen, wenn man es
|
||
// sich anders ueberlegt.
|
||
document.addEventListener("click", e => {
|
||
if (e.target.closest(".geraete-waehler") || e.target.closest(".form-select.text-start")) return;
|
||
schliesseWaehler();
|
||
});
|
||
|
||
function textSicher(text) {
|
||
return String(text).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
}
|
||
|
||
/**
|
||
* Wertfeld passend zum Datentyp des Messwerts oder Parameters.
|
||
* Liefert das fertige Element; der Aufrufer haengt seinen Listener an.
|
||
*/
|
||
function wertFeld(spec, wert) {
|
||
let feld;
|
||
if (spec.input === "bool") {
|
||
feld = document.createElement("select");
|
||
feld.className = "form-select";
|
||
optionListe(feld, [{ value: "true", text: "JA" }, { value: "false", text: "NEIN" }],
|
||
wert === "" ? "true" : wert);
|
||
} else if (spec.input === "select") {
|
||
// Die Auswahl kommt fertig aus dem Katalog: bei Messwerten steht der
|
||
// Klartext auch als Wert, bei Geraeteparametern die Zahl dahinter.
|
||
feld = document.createElement("select");
|
||
feld.className = "form-select";
|
||
optionListe(feld, spec.options.map(o => ({ value: o.value, text: o.label })), wert);
|
||
} else {
|
||
feld = document.createElement("input");
|
||
feld.className = "form-control";
|
||
feld.type = spec.input;
|
||
if (spec.min !== undefined && spec.min !== null) feld.min = spec.min;
|
||
if (spec.max !== undefined && spec.max !== null) feld.max = spec.max;
|
||
feld.value = wert !== "" ? wert
|
||
: spec.input === "time" ? "00:00"
|
||
: spec.input === "date" ? new Date().toISOString().split("T")[0]
|
||
: spec.input === "number" ? "0" : "";
|
||
}
|
||
return feld;
|
||
}
|
||
|
||
/** Standardwert, wenn eine Zeile auf einen anderen Messwert umgestellt wird. */
|
||
function standardWert(spec) {
|
||
if (spec.input === "bool") return "true";
|
||
if (spec.input === "select") return spec.options.length ? spec.options[0].value : "";
|
||
if (spec.input === "time") return "00:00";
|
||
if (spec.input === "date") return new Date().toISOString().split("T")[0];
|
||
if (spec.input === "number") return "0";
|
||
return "";
|
||
}
|
||
|
||
// --- Ausloeser -----------------------------------------------------------
|
||
|
||
/**
|
||
* Zeichnet alle UND-Bloecke. Jeder Block hat sein eigenes "+ Bedingung", denn
|
||
* mit einem einzigen Knopf ganz unten kaeme man immer nur an den letzten
|
||
* Block heran und koennte eine bestehende Alternative nie mehr ergaenzen.
|
||
*/
|
||
function renderConditions() {
|
||
const ziel = document.getElementById("condBlocks");
|
||
ziel.innerHTML = "";
|
||
|
||
autoModel.groups.forEach((gruppe, gi) => {
|
||
if (gi > 0) {
|
||
const trenner = document.createElement("div");
|
||
trenner.className = "d-flex align-items-center my-2 text-body-secondary";
|
||
trenner.innerHTML = "<hr class='flex-grow-1 m-0'>"
|
||
+ "<span class='px-3 small fw-bold'>ODER</span>"
|
||
+ "<hr class='flex-grow-1 m-0'>";
|
||
ziel.appendChild(trenner);
|
||
}
|
||
|
||
const block = document.createElement("div");
|
||
block.className = "border rounded p-2";
|
||
|
||
gruppe.forEach((bed, bi) => {
|
||
if (bi > 0) {
|
||
const und = document.createElement("div");
|
||
und.className = "small fw-bold text-body-secondary ms-1 my-1";
|
||
und.textContent = "und";
|
||
block.appendChild(und);
|
||
}
|
||
block.appendChild(conditionRow(gi, bi, bed));
|
||
});
|
||
|
||
const add = document.createElement("button");
|
||
add.type = "button";
|
||
add.className = "btn btn-outline-success btn-sm mt-2";
|
||
add.innerHTML = "<i class='bi bi-plus-lg'></i> Bedingung";
|
||
add.onclick = () => { gruppe.push(leereBedingung()); renderConditions(); };
|
||
block.appendChild(add);
|
||
|
||
ziel.appendChild(block);
|
||
});
|
||
|
||
renderSummary();
|
||
}
|
||
|
||
function conditionRow(gi, bi, bed) {
|
||
const zeile = document.createElement("div");
|
||
zeile.className = "input-group auto-zeile";
|
||
|
||
const treffer = stateById(bed.state_id);
|
||
const dev = treffer ? treffer.device : null;
|
||
const state = treffer ? treffer.state : null;
|
||
|
||
// Geraet
|
||
zeile.appendChild(geraeteWaehler(
|
||
autoDevices.filter(d => d.states.length), dev ? dev.id : 0,
|
||
neu => {
|
||
bed.state_id = neu.states[0].id;
|
||
bed.operator = neu.states[0].operators[0].value;
|
||
bed.value = standardWert(neu.states[0]);
|
||
renderConditions();
|
||
}));
|
||
|
||
// Messwert - nur zeigen, wenn das Geraet mehr als einen hat
|
||
if (dev && dev.states.length > 1) {
|
||
const messwert = document.createElement("select");
|
||
messwert.className = "form-select auto-messwert";
|
||
optionListe(messwert, dev.states.map(s => ({
|
||
value: s.id,
|
||
text: s.name + (s.value ? " (= " + s.value + ")" : "")
|
||
})), bed.state_id);
|
||
messwert.onchange = () => {
|
||
const neu = dev.states.find(s => s.id === Number(messwert.value));
|
||
bed.state_id = neu.id;
|
||
bed.operator = neu.operators[0].value;
|
||
bed.value = standardWert(neu);
|
||
renderConditions();
|
||
};
|
||
zeile.appendChild(messwert);
|
||
}
|
||
|
||
if (state) {
|
||
// Operator. Eine Auswahlliste statt des frueheren Umschalt-Knopfes: der
|
||
// zeigte nur seinen aktuellen Zustand, welche Vergleiche es sonst noch
|
||
// gibt, fand man erst durch Klicken heraus.
|
||
const op = document.createElement("select");
|
||
// Feste, schmale Breite: der Pfeil der Auswahlliste braucht Platz, sonst
|
||
// schiebt er das Zeichen aus dem sichtbaren Bereich.
|
||
op.className = "form-select flex-grow-0 text-center";
|
||
op.style.flex = "0 0 5.5rem";
|
||
optionListe(op, state.operators.map(o => ({ value: o.value, text: o.label })), bed.operator);
|
||
op.onchange = () => { bed.operator = op.value; renderSummary(); };
|
||
zeile.appendChild(op);
|
||
|
||
const wert = wertFeld(state, bed.value);
|
||
wert.onchange = () => { bed.value = wert.value; renderSummary(); };
|
||
zeile.appendChild(wert);
|
||
|
||
if (state.unit) {
|
||
const einheit = document.createElement("span");
|
||
einheit.className = "input-group-text";
|
||
einheit.textContent = state.unit;
|
||
zeile.appendChild(einheit);
|
||
}
|
||
}
|
||
|
||
const weg = document.createElement("button");
|
||
weg.type = "button";
|
||
weg.className = "btn btn-outline-danger";
|
||
weg.innerHTML = "<i class='bi bi-trash3'></i>";
|
||
weg.onclick = () => {
|
||
autoModel.groups[gi].splice(bi, 1);
|
||
// Ein leerer Block hat keine Bedeutung mehr und verschwindet mit.
|
||
if (!autoModel.groups[gi].length) autoModel.groups.splice(gi, 1);
|
||
renderConditions();
|
||
};
|
||
zeile.appendChild(weg);
|
||
|
||
return zeile;
|
||
}
|
||
|
||
/**
|
||
* Derselbe Satz, den auch die Uebersicht und der Runner benutzen. Er steht
|
||
* unter den Bloecken, damit man die Klammerung nicht aus der Optik erschliessen
|
||
* muss, sondern schwarz auf weiss liest.
|
||
*/
|
||
function conditionText() {
|
||
const teile = autoModel.groups
|
||
.map(g => g.filter(bed => stateById(bed.state_id)))
|
||
.filter(g => g.length)
|
||
.map(gruppe => {
|
||
const text = gruppe.map(bed => {
|
||
const treffer = stateById(bed.state_id);
|
||
let s = treffer.device.label + ": " + treffer.state.name + " "
|
||
+ operatorLabel(treffer.state, bed.operator) + " " + bed.value;
|
||
if (treffer.state.unit) s += " " + treffer.state.unit;
|
||
return s;
|
||
}).join(" und ");
|
||
return (autoModel.groups.length > 1 && gruppe.length > 1) ? "(" + text + ")" : text;
|
||
});
|
||
return teile.join(" oder ");
|
||
}
|
||
|
||
function renderSummary() {
|
||
const text = conditionText();
|
||
document.getElementById("condSummary").innerHTML = text
|
||
? "Löst aus, wenn <strong>" + text.replace(/&/g, "&").replace(/</g, "<") + "</strong>."
|
||
: "Noch kein Auslöser – die Automatik würde nie starten.";
|
||
}
|
||
|
||
// --- Aktionen ------------------------------------------------------------
|
||
|
||
function renderActions() {
|
||
const ziel = document.getElementById("actionList");
|
||
ziel.innerHTML = "";
|
||
autoModel.actions.forEach((aktion, ai) => ziel.appendChild(actionRow(ai, aktion)));
|
||
}
|
||
|
||
function actionRow(ai, aktion) {
|
||
const zeile = document.createElement("div");
|
||
zeile.className = "input-group mb-2 auto-zeile";
|
||
|
||
const treffer = commandById(aktion.command_id);
|
||
const dev = treffer ? treffer.device : null;
|
||
const cmd = treffer ? treffer.command : null;
|
||
|
||
zeile.appendChild(geraeteWaehler(
|
||
autoDevices.filter(d => d.commands.length), dev ? dev.id : 0,
|
||
neu => {
|
||
aktion.command_id = neu.commands[0].id;
|
||
aktion.params = standardParams(neu.commands[0]);
|
||
renderActions();
|
||
}));
|
||
|
||
if (dev && dev.commands.length > 1) {
|
||
const befehl = document.createElement("select");
|
||
befehl.className = "form-select auto-messwert";
|
||
optionListe(befehl, dev.commands.map(c => ({ value: c.id, text: c.name })), aktion.command_id);
|
||
befehl.onchange = () => {
|
||
const neu = dev.commands.find(c => c.id === Number(befehl.value));
|
||
aktion.command_id = neu.id;
|
||
aktion.params = standardParams(neu);
|
||
renderActions();
|
||
};
|
||
zeile.appendChild(befehl);
|
||
}
|
||
|
||
// Je Parameter ein Feld - wie viele es sind, sagt das Geraet. Frueher gab es
|
||
// vier feste Felder "Wert 1" bis "Wert 4", die je nach Kommando versteckt
|
||
// wurden, ihren Inhalt aber trotzdem mitschickten.
|
||
if (cmd) {
|
||
cmd.params.forEach(p => {
|
||
const beschriftung = document.createElement("span");
|
||
beschriftung.className = "input-group-text";
|
||
beschriftung.textContent = p.name;
|
||
zeile.appendChild(beschriftung);
|
||
|
||
const wert = wertFeld(p, aktion.params[p.id] !== undefined ? aktion.params[p.id] : standardWert(p));
|
||
wert.onchange = () => { aktion.params[p.id] = wert.value; };
|
||
zeile.appendChild(wert);
|
||
});
|
||
}
|
||
|
||
const weg = document.createElement("button");
|
||
weg.type = "button";
|
||
weg.className = "btn btn-outline-danger";
|
||
weg.innerHTML = "<i class='bi bi-trash3'></i>";
|
||
weg.onclick = () => { autoModel.actions.splice(ai, 1); renderActions(); };
|
||
zeile.appendChild(weg);
|
||
|
||
return zeile;
|
||
}
|
||
|
||
function standardParams(cmd) {
|
||
const params = {};
|
||
cmd.params.forEach(p => { params[p.id] = standardWert(p); });
|
||
return params;
|
||
}
|
||
|
||
// --- Rahmenbedingungen ---------------------------------------------------
|
||
|
||
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||
|
||
function renderWeekdays() {
|
||
const ziel = document.getElementById("weekdayGroup");
|
||
ziel.innerHTML = "";
|
||
WOCHENTAGE.forEach((tag, i) => {
|
||
const feld = document.createElement("div");
|
||
feld.className = "input-group-text";
|
||
feld.innerHTML = "<input class='form-check-input mt-0 me-1' type='checkbox' id='day" + i + "'"
|
||
+ (autoModel.weekdays & (1 << i) ? " checked" : "") + ">"
|
||
+ "<label class='form-check-label' for='day" + i + "'>" + tag + "</label>";
|
||
feld.querySelector("input").onchange = e => {
|
||
if (e.target.checked) autoModel.weekdays |= (1 << i);
|
||
else autoModel.weekdays &= ~(1 << i);
|
||
};
|
||
ziel.appendChild(feld);
|
||
});
|
||
}
|
||
|
||
function fuelleFormular() {
|
||
const sperre = document.getElementById("lockoutSecs");
|
||
optionListe(sperre, autoLockouts.map(o => ({ value: o.value, text: o.label })),
|
||
autoModel.lockout_secs);
|
||
document.getElementById("autoName").value = autoModel.name;
|
||
// Kennt die Liste die Etage nicht, bleibt eine Auswahlliste sonst leer
|
||
// stehen und schickt beim Speichern "" mit.
|
||
const etage = document.getElementById("autoFloor");
|
||
etage.value = autoModel.floor;
|
||
if (etage.selectedIndex < 0) {
|
||
etage.selectedIndex = 0;
|
||
autoModel.floor = etage.value;
|
||
}
|
||
document.getElementById("tSpanFrom").value = autoModel.window_from;
|
||
document.getElementById("tSpanTo").value = autoModel.window_to;
|
||
document.getElementById("actFerien").checked = autoModel.on_vacation;
|
||
document.getElementById("actFeier").checked = autoModel.on_holiday;
|
||
document.getElementById("runOnce").checked = autoModel.force_once;
|
||
renderWeekdays();
|
||
renderConditions();
|
||
renderActions();
|
||
}
|
||
|
||
/** Die Felder, die direkt am Formular haengen, ins Modell zurueckholen. */
|
||
function leseFormular() {
|
||
autoModel.name = document.getElementById("autoName").value.trim();
|
||
autoModel.floor = document.getElementById("autoFloor").value;
|
||
autoModel.window_from = document.getElementById("tSpanFrom").value;
|
||
autoModel.window_to = document.getElementById("tSpanTo").value;
|
||
autoModel.on_vacation = document.getElementById("actFerien").checked;
|
||
autoModel.on_holiday = document.getElementById("actFeier").checked;
|
||
autoModel.force_once = document.getElementById("runOnce").checked;
|
||
autoModel.lockout_secs = Number(document.getElementById("lockoutSecs").value);
|
||
}
|
||
|
||
// --- Modal ---------------------------------------------------------------
|
||
|
||
function loadAutomatic() {
|
||
const daten = JSON.parse(document.getElementById("autoActionData").textContent);
|
||
autoDevices = daten.devices;
|
||
autoLockouts = daten.lockouts;
|
||
autoRooms = daten.rooms || [];
|
||
|
||
const a = daten.automation;
|
||
autoFloorVorher = a.floor;
|
||
autoModel = {
|
||
id: a.id,
|
||
name: a.name,
|
||
floor: a.floor,
|
||
enabled: a.enabled,
|
||
window_from: a.window_from,
|
||
window_to: a.window_to,
|
||
weekdays: a.weekdays,
|
||
on_vacation: a.on_vacation,
|
||
on_holiday: a.on_holiday,
|
||
force_once: a.force_once,
|
||
lockout_secs: a.lockout_secs,
|
||
groups: [],
|
||
actions: a.actions.map(x => ({ command_id: x.command_id, params: Object.assign({}, x.params) }))
|
||
};
|
||
|
||
// Die flache Liste aus der Datenbank wieder in Bloecke fassen. Die
|
||
// Gruppennummern kommen aufsteigend und dicht, siehe saveAutomation().
|
||
const nachGruppe = new Map();
|
||
a.conditions.forEach(c => {
|
||
if (!nachGruppe.has(c.group)) nachGruppe.set(c.group, []);
|
||
nachGruppe.get(c.group).push({ state_id: c.state_id, operator: c.operator, value: c.value });
|
||
});
|
||
autoModel.groups = Array.from(nachGruppe.values());
|
||
|
||
// Eine neue Automatik startet mit je einer leeren Zeile, sonst steht man
|
||
// vor einem leeren Formular ohne erkennbaren Anfang.
|
||
if (!autoModel.groups.length) autoModel.groups = [[leereBedingung()]];
|
||
if (!autoModel.actions.length) autoModel.actions = [leereAktion()];
|
||
|
||
fuelleFormular();
|
||
}
|
||
|
||
function openAutoActionModal(params) {
|
||
const contentURL = "./ajax/AutoAction.php" + params;
|
||
document.getElementById("modal-title").innerHTML =
|
||
params.search("id=") > 0 ? "Automatik bearbeiten" : "Neue Automatik anlegen";
|
||
|
||
// Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne
|
||
// Zuhoerer stellt sicher, dass nicht auch noch der Handler eines vorher
|
||
// geoeffneten Modals mitfeuert.
|
||
const alt = document.getElementById("modalSaveBtn");
|
||
const btn = alt.cloneNode(true);
|
||
alt.replaceWith(btn);
|
||
// Das Raum-Modal versteckt ihn; hier wird er gebraucht.
|
||
btn.hidden = false;
|
||
btn.addEventListener("click", submitAutoAction);
|
||
|
||
// Der Editor braucht mehr Breite als die Ladedialoge, gibt sie aber wieder
|
||
// her, damit die anderen Modals unveraendert aussehen.
|
||
const dialog = document.querySelector("#modalEV .modal-dialog");
|
||
dialog.classList.add("modal-xl");
|
||
document.getElementById("modalEV").addEventListener("hidden.bs.modal",
|
||
() => dialog.classList.remove("modal-xl"), { once: true });
|
||
|
||
const modalBodyElement = document.getElementById("modal-body");
|
||
modalBodyElement.innerHTML = loadingHTML("Wird geladen...");
|
||
modalEV.show();
|
||
|
||
fetch(contentURL, {
|
||
method: "GET",
|
||
headers: { "X-Requested-From-Modal": "a", "Requested-With-Ajax": "ajax" }
|
||
})
|
||
.then(response => response.text())
|
||
.then(html => {
|
||
modalBodyElement.innerHTML = html;
|
||
loadAutomatic();
|
||
document.getElementById("btnAddGroup").addEventListener("click", () => {
|
||
autoModel.groups.push([leereBedingung()]);
|
||
renderConditions();
|
||
});
|
||
document.getElementById("btnAddActor").addEventListener("click", () => {
|
||
autoModel.actions.push(leereAktion());
|
||
renderActions();
|
||
});
|
||
})
|
||
.catch(error => {
|
||
modalBodyElement.innerHTML = "Konnte den Editor nicht laden: " + error.message;
|
||
});
|
||
}
|
||
|
||
function submitAutoAction() {
|
||
leseFormular();
|
||
const nutzlast = {
|
||
id: autoModel.id,
|
||
name: autoModel.name,
|
||
floor: autoModel.floor,
|
||
enabled: autoModel.enabled,
|
||
window_from: autoModel.window_from,
|
||
window_to: autoModel.window_to,
|
||
weekdays: autoModel.weekdays,
|
||
on_vacation: autoModel.on_vacation,
|
||
on_holiday: autoModel.on_holiday,
|
||
force_once: autoModel.force_once,
|
||
lockout_secs: autoModel.lockout_secs,
|
||
conditions: [],
|
||
actions: autoModel.actions
|
||
};
|
||
autoModel.groups.forEach((gruppe, gi) => {
|
||
gruppe.forEach(bed => {
|
||
nutzlast.conditions.push({ group: gi, state_id: bed.state_id, operator: bed.operator, value: bed.value });
|
||
});
|
||
});
|
||
|
||
automationRequest("save", nutzlast).then(antwort => {
|
||
if (antwort.error) {
|
||
alert(antwort.error);
|
||
return;
|
||
}
|
||
modalEV.hide();
|
||
refreshAutomations(nutzlast.floor);
|
||
// Ein Etagenwechsel verschiebt die Automatik, er kopiert sie nicht - in
|
||
// der Datenbank steht weiter eine Zeile. Ohne dieses zweite Nachladen
|
||
// bliebe sie aber in der Tabelle der alten Etage stehen, und genau das
|
||
// sah wie eine Kopie aus.
|
||
if (autoFloorVorher && autoFloorVorher !== nutzlast.floor) {
|
||
refreshAutomations(autoFloorVorher);
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- Uebersicht ----------------------------------------------------------
|
||
|
||
function automationRequest(action, body) {
|
||
return fetch("./ajax/AutoAction.php?action=" + action, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" },
|
||
body: JSON.stringify(body)
|
||
})
|
||
.then(response => response.json())
|
||
.catch(error => ({ error: error.message }));
|
||
}
|
||
|
||
/**
|
||
* Die Tabelle einer Etage neu holen. Ohne Etage alle, die es gibt.
|
||
*
|
||
* Welche das sind, sagen die Reiter - home.php baut sie aus der Raumtabelle.
|
||
* Eine feste Liste hier haette beim Aussengelaende still eine Etage
|
||
* ausgelassen: die Tabelle waere leer geblieben, ohne Fehlermeldung.
|
||
*/
|
||
function refreshAutomations(floor) {
|
||
const etagen = floor ? [floor]
|
||
: Array.from(document.querySelectorAll("#actions-tabContent .tab-pane"))
|
||
.map(pane => pane.id.replace(/^actions-/, ""));
|
||
etagen.forEach(etage => {
|
||
const ziel = document.getElementById("actions-" + etage + "-list");
|
||
if (!ziel) return;
|
||
fetch("./ajax/AutoAction.php?action=list&floor=" + etage, {
|
||
method: "GET",
|
||
headers: { "Requested-With-Ajax": "ajax" }
|
||
})
|
||
.then(response => response.text())
|
||
.then(html => { ziel.innerHTML = html; })
|
||
.catch(() => { ziel.innerHTML = "<p class='text-danger'>Liste konnte nicht geladen werden.</p>"; });
|
||
});
|
||
}
|
||
|
||
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));
|