Files
Smart-Dashboard/js/solar/autoActionFuncs.js
T
adminandClaude Opus 5 00f5944a7a Geraeteauswahl: nach Art gruppiert, Namen geputzt
Nach dem Discovery-Lauf stehen 51 Geraete in der Ausloeser-Liste und 40 in
der Aktions-Liste, flach und alphabetisch. Dreizehn Thermostate und elf
Jalousien sind darin vierundzwanzig gleich aussehende Zeilen.

Die Liste ist jetzt nach Geraeteart gruppiert (optgroup, das koennen auch
die Handy-Browser nativ). Arten mit nur einem Geraet bekommen keine eigene
Ueberschrift - fuenfzehn Gruppen mit je einem Eintrag waeren
unuebersichtlicher als die flache Liste -, sie sammeln sich am Ende unter
"Einzelne".

Dazu ein Namensputz fuer die Anzeige, ohne die Datenbank anzufassen: das
u_-Praefix aus den Tahoma-Beschriftungen faellt weg, ebenso eine
angehaengte einstellige Kanalnummer, und Unterstriche werden zu Leerzeichen.
Aus "u_Wozi Terrassentuer" wird "Wozi Terrassentuer", aus "Power_EG_EM_0"
wird "Power EG EM". Bewusst nur die einstellige Endung - "go-eCharger_270003"
traegt seine Seriennummer und behaelt sie.

Beschriftungen, die danach doppelt waeren, bekommen die Geraeteart dazu: die
Tahoma-Box nennt sowohl den Rollladen als auch den Sonnenenergie-Sensor
"Terasse", in der Liste untereinander waren sie nicht zu unterscheiden.
Daraus wird "Terasse (Rollladen)" und "Terasse (Licht)".

Die Beschriftung entsteht an einer Stelle und wird von Editor und
Uebersicht geteilt - sonst stuende in der Auswahlliste "Wozi Terrassentuer"
und im Satz darunter weiter "u_Wozi Terrassentuer".

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

628 lines
23 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 = [];
// --- 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;
}
/** Erster Messwert des ersten Geraets - Vorbelegung fuer eine neue Zeile. */
function ersterMesswert() {
const dev = autoDevices.find(d => d.states.length > 0);
return dev ? dev.states[0] : null;
}
function ersterBefehl() {
const dev = autoDevices.find(d => d.commands.length > 0);
return dev ? dev.commands[0] : null;
}
// --- 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 nach Geraeteart gruppiert.
*
* Bei ueber fuenfzig Geraeten ist die flache Liste der Engpass: dreizehn
* Thermostate und elf Jalousien stehen sonst als vierundzwanzig gleich
* aussehende Zeilen untereinander. Arten mit nur einem Geraet bekommen
* keine eigene Ueberschrift - fuenfzehn Gruppen mit je einem Eintrag waeren
* unuebersichtlicher als die flache Liste. Sie sammeln sich am Ende.
*/
function geraeteGruppen(geraete) {
const nachArt = new Map();
geraete.forEach(d => {
if (!nachArt.has(d.type)) nachArt.set(d.type, []);
nachArt.get(d.type).push(d);
});
const gruppen = [];
const einzelne = [];
Array.from(nachArt.keys()).sort((a, b) => a.localeCompare(b, "de")).forEach(art => {
const liste = nachArt.get(art);
if (liste.length > 1) gruppen.push({ titel: art + " (" + liste.length + ")", geraete: liste });
else einzelne.push(liste[0]);
});
if (einzelne.length) {
einzelne.sort((a, b) => a.label.localeCompare(b.label, "de"));
gruppen.push({ titel: "Einzelne", geraete: einzelne });
}
return gruppen;
}
/** Auswahlliste mit Zwischenueberschriften je Geraeteart fuellen. */
function geraeteListe(select, geraete, gewaehlt) {
select.innerHTML = "";
const gruppen = geraeteGruppen(geraete);
// Nur eine Gruppe? Dann waere die Ueberschrift blosse Zierde.
if (gruppen.length < 2) {
geraete.forEach(d => select.appendChild(option({ value: d.id, text: d.label }, gewaehlt)));
return;
}
gruppen.forEach(g => {
const kopf = document.createElement("optgroup");
kopf.label = g.titel;
g.geraete.forEach(d => kopf.appendChild(option({ value: d.id, text: d.label }, gewaehlt)));
select.appendChild(kopf);
});
}
/**
* 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 = () => {
const state = ersterMesswert();
if (!state) return;
gruppe.push({ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) });
renderConditions();
};
block.appendChild(add);
ziel.appendChild(block);
});
renderSummary();
}
function conditionRow(gi, bi, bed) {
const zeile = document.createElement("div");
zeile.className = "input-group";
const treffer = stateById(bed.state_id);
const dev = treffer ? treffer.device : null;
const state = treffer ? treffer.state : null;
// Geraet
const geraet = document.createElement("select");
geraet.className = "form-select";
geraeteListe(geraet, autoDevices.filter(d => d.states.length), dev ? dev.id : "");
geraet.onchange = () => {
const neu = deviceById(Number(geraet.value));
if (!neu || !neu.states.length) return;
bed.state_id = neu.states[0].id;
bed.operator = neu.states[0].operators[0].value;
bed.value = standardWert(neu.states[0]);
renderConditions();
};
zeile.appendChild(geraet);
// 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";
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.filter(g => g.length).map(gruppe => {
const text = gruppe.map(bed => {
const treffer = stateById(bed.state_id);
if (!treffer) return "?";
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";
const treffer = commandById(aktion.command_id);
const dev = treffer ? treffer.device : null;
const cmd = treffer ? treffer.command : null;
const geraet = document.createElement("select");
geraet.className = "form-select";
geraeteListe(geraet, autoDevices.filter(d => d.commands.length), dev ? dev.id : "");
geraet.onchange = () => {
const neu = deviceById(Number(geraet.value));
if (!neu || !neu.commands.length) return;
aktion.command_id = neu.commands[0].id;
aktion.params = standardParams(neu.commands[0]);
renderActions();
};
zeile.appendChild(geraet);
if (dev && dev.commands.length > 1) {
const befehl = document.createElement("select");
befehl.className = "form-select";
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;
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 einer leeren Bedingung und einer Aktion,
// sonst steht man vor einem leeren Formular ohne erkennbaren Anfang.
if (!autoModel.groups.length) {
const state = ersterMesswert();
if (state) {
autoModel.groups = [[{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]];
}
}
if (!autoModel.actions.length) {
const cmd = ersterBefehl();
if (cmd) autoModel.actions = [{ command_id: cmd.id, params: standardParams(cmd) }];
}
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", () => {
const state = ersterMesswert();
if (!state) return;
autoModel.groups.push([{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]);
renderConditions();
});
document.getElementById("btnAddActor").addEventListener("click", () => {
const cmd = ersterBefehl();
if (!cmd) return;
autoModel.actions.push({ command_id: cmd.id, params: standardParams(cmd) });
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>"; });
});
}
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));