Einstellungsseite: Reiter, Kachelwerte und Geraeteliste

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>
This commit is contained in:
2026-09-08 22:45:24 +02:00
co-authored by Claude Opus 5
parent 1b58579c22
commit 07bca5104e
15 changed files with 2058 additions and 226 deletions
-52
View File
@@ -800,58 +800,6 @@ function refreshAutomations(floor) {
});
}
/**
* 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);
// Das Raum-Modal versteckt ihn; hier wird er gebraucht.
btn.hidden = false;
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);
+33
View File
@@ -215,3 +215,36 @@ function updateWeatherCards(value) {
document.getElementById("windGust").innerHTML = value("maxgust");
document.getElementById("ambPress").innerHTML = Math.round(value("qff") * 10) / 10;
}
/**
* Offenen Reiter in der Adresse merken.
*
* Gilt fuer jede Leiste, die restricted/reiter.php mit "merken" gebaut hat -
* erkennbar an data-reiter-merken. Ohne das landet man nach jedem Neuladen
* wieder im ersten Reiter, obwohl man gerade woanders gearbeitet hat.
*
* In der Adresse steht nur das Kuerzel des Reiters (#kacheln), nicht der Name
* der Leiste: es ist eine Adresse, die man auch von Hand tippt. Zwei merkende
* Leisten auf einer Seite waeren damit nicht zu unterscheiden - dann muesste
* hier der Name davor, und die Adresse waere haesslicher als noetig.
*/
function reiterMerken() {
if (typeof bootstrap === "undefined") return;
document.querySelectorAll("[data-reiter-merken]").forEach(function (leiste) {
const knoepfe = leiste.querySelectorAll("[data-reiter]");
const gewuenscht = location.hash.replace("#", "");
knoepfe.forEach(function (knopf) {
knopf.addEventListener("shown.bs.tab", function () {
history.replaceState(null, "", "#" + knopf.dataset.reiter);
});
if (gewuenscht && knopf.dataset.reiter === gewuenscht) {
new bootstrap.Tab(knopf).show();
}
});
});
}
document.addEventListener("DOMContentLoaded", reiterMerken);
-13
View File
@@ -203,19 +203,6 @@ function removeClass(el, classNameToRemove){
// Die Etagen stehen nicht mehr im Code: was es gibt, sagen die Reiter, die
// home.php aus der Raumtabelle gebaut hat. Vorher standen OG, EG und UG hier
// sechsmal, und eine vierte Etage haette sechs neue Zeilen gebraucht.
function switchTab(newtab){
const newContent = document.getElementById(newtab);
const newTabBtn = document.getElementById(newtab+"-tab");
document.querySelectorAll("#actions-tab .nav-link").forEach(function(btn){
removeClass(btn,"active");
});
document.querySelectorAll("#actions-tabContent .tab-pane").forEach(function(pane){
removeClass(pane,"active show");
});
addClass(newTabBtn,"active");
addClass(newContent,"active show");
}
function switchFloor(floor){
if(currentFloor == floor)
+801
View File
@@ -252,3 +252,804 @@ document.addEventListener("DOMContentLoaded", function () {
preisKarteBinden(karte);
});
});
/* ==========================================================================
Reiter "Home-Kacheln": welche Messwerte auf den Kacheln stehen.
Dieselbe Arbeitsteilung wie bei den Preisen: das Modell ist die Wahrheit,
jede Eingabe schreibt hinein, gezeichnet wird daraus neu. Anders als dort
kommt es nicht mit der Seite, sondern per Abruf - die Liste haengt an den
Raeumen und aendert sich seltener als ein Preis, aber sie ist laenger.
Eine Kachel ist entweder "Standard" (keine Zeile in der Datenbank, es gilt
restricted/rooms.php) oder "selbst gesetzt". Der Unterschied steht in der
Zeile, denn er entscheidet, was beim naechsten Umbau von rooms.php
passiert: Standardkacheln ziehen mit, selbst gesetzte nicht.
========================================================================== */
let kachelModell = null; // { kacheln, symbole, topics, max, tabelleDa }
/*
* Der Katalog ist die Liste der Messwerte, die der Geraetesuchlauf kennt -
* dieselbe, aus der der Automatik-Editor seine Bedingungen baut. Ein Wert
* daraus bringt Topic, JSON-Pfad und Einheit mit; von Hand einzutippen ist
* dann nichts mehr.
*
* Gespeichert wird trotzdem nur Topic und Pfad. Der Katalog ist eine
* Eingabehilfe und keine zweite Wahrheit: verschwindet ein Geraet aus dem
* Suchlauf, zeigt die Kachel weiter, was sie zeigt.
*/
/** Der Katalogeintrag zu einem Wert - oder null, wenn er dort nicht steht. */
function katalogTreffer(wert) {
if (!kachelModell || !kachelModell.katalog || wert.wechselrichter) return null;
const pfad = wert.pfad || "";
for (let gi = 0; gi < kachelModell.katalog.length; gi++) {
const werte = kachelModell.katalog[gi].werte;
for (let wi = 0; wi < werte.length; wi++) {
if (werte[wi].topic === wert.topic && (werte[wi].pfad || "") === pfad) {
return { gi: gi, wi: wi, geraet: kachelModell.katalog[gi], wert: werte[wi] };
}
}
}
return null;
}
/**
* Welche Eingabeart eine Zeile zeigt.
*
* Beim ersten Zeichnen geraten: steht der Wert im Katalog, ist es eine
* Auswahl. Sobald der Benutzer die Art umstellt, gilt seine Wahl (_quelle) -
* sonst spraenge die Zeile beim naechsten Neuzeichnen zurueck.
*/
function kachelQuelle(wert) {
if (wert._quelle) return wert._quelle;
if (wert.wechselrichter) return "wr";
return katalogTreffer(wert) ? "sensor" : "topic";
}
/**
* Kurzform eines Wertes fuer die Zeile: das Wesentliche, nicht alles.
*
* Der Geraetename steht nur dabei, wenn das Geraet in einem anderen Raum haengt
* als die Kachel - im eigenen Raum wiederholte er nur, wo man ohnehin steht,
* und aus drei Kuerzeln wuerden drei Zeilen.
*/
function kachelWertKurz(wert, kachel) {
if (wert.wechselrichter) {
const n = wert.wechselrichter.length;
return "Wechselrichter (" + n + ")";
}
const treffer = katalogTreffer(wert);
const einheit = wert.format === "leistung" ? "W/kW" : (wert.einheit || "");
if (treffer) {
const fremd = !kachel || treffer.geraet.raum !== kachel.name;
return (fremd ? treffer.geraet.label + " · " : "") + treffer.wert.name
+ (einheit ? " " + einheit : "");
}
const topic = wert.topic || "";
const teile = topic.split("/");
let text = teile[teile.length - 1] || topic;
if (wert.pfad) text += " · " + wert.pfad;
return einheit ? text + " " + einheit : text;
}
/** Eine Kachel im Modell finden. */
function kachelSuchen(floor, room) {
return (kachelModell.kacheln || []).find(k => k.floor === floor && k.room === room);
}
/* --- Zeichnen ------------------------------------------------------------ */
function zeichneKacheln() {
const ziel = document.getElementById("kachelListe");
if (!ziel || !kachelModell) return;
if (!kachelModell.tabelleDa) {
ziel.innerHTML = '<div class="alert alert-warning mb-0">Die Tabelle '
+ '<code>kachelWerte</code> fehlt noch. Sie steht in '
+ '<code>solarLog_kacheln.sql</code> bis dahin zeigen alle Kacheln '
+ 'ihre Vorgabe, und Speichern ist nicht möglich.</div>';
return;
}
// Vorschlagsliste einmal fuer alle Topic-Felder.
let html = '<datalist id="kachelTopics">'
+ (kachelModell.topics || []).map(t => '<option value="' + textSchuetzen(t) + '">').join("")
+ "</datalist>";
let etage = null;
(kachelModell.kacheln || []).forEach(function (k) {
if (k.floor !== etage) {
if (etage !== null) html += "</div>";
etage = k.floor;
html += '<div class="kachel-etage"><div class="kachel-etage-name">'
+ textSchuetzen(k.floorLabel) + "</div>";
}
html += kachelZeile(k);
});
if (etage !== null) html += "</div>";
ziel.innerHTML = html;
}
/** Eine Raumzeile: Name, Kurzfassung der Werte, darunter der Editor. */
function kachelZeile(k) {
const werte = k._entwurf || k.werte;
const kurz = werte.length
? werte.map(w => '<span class="kachel-chip">' + textSchuetzen(kachelWertKurz(w, k))
+ "</span>").join("")
: '<span class="kachel-chip leer">ohne Messwert</span>';
let html = '<div class="kachel-zeile' + (k._offen ? " offen" : "") + '"'
+ ' data-floor="' + k.floor + '" data-room="' + k.room + '">'
+ '<button type="button" class="kachel-kopf">'
+ '<i class="bi bi-chevron-right pfeil"></i>'
+ '<span class="name">' + textSchuetzen(k.name) + "</span>"
+ '<span class="werte">' + kurz + "</span>"
+ (k.eigen ? '<span class="badge text-bg-secondary">selbst gesetzt</span>' : "")
+ "</button>";
if (k._offen) {
html += '<div class="kachel-editor">'
+ werte.map((w, i) => kachelWertZeile(w, i, k)).join("");
if (werte.length < (kachelModell.max || 3)) {
html += '<button type="button" class="btn btn-sm btn-outline-secondary kachel-plus">'
+ '<i class="bi bi-plus-lg me-1"></i>Wert hinzufügen</button>';
}
html += '<div class="kachel-fuss">'
+ '<button type="button" class="btn btn-primary btn-sm kachel-speichern">Speichern</button>'
+ '<button type="button" class="btn btn-outline-secondary btn-sm kachel-standard"'
+ (k.eigen ? "" : " disabled") + ">Standard</button>"
+ '<span class="kachel-hinweis small"></span>'
+ "</div></div>";
}
return html + "</div>";
}
/**
* Eine Wertdefinition als Formularzeile.
*
* Die Eingabeart entscheidet, welche Felder sichtbar sind: eine Auswahl aus
* dem Katalog braucht kein Topic, ein Wechselrichter keinen JSON-Pfad. Alles
* gleichzeitig anzubieten waere eine Einladung, es auch auszufuellen -
* gelesen wird immer nur eines davon.
*/
function kachelWertZeile(wert, i, kachel) {
const quelle = kachelQuelle(wert);
const katalog = (kachelModell && kachelModell.katalog) || [];
const symbole = kachelModell.symbole || {};
const symbolOptionen = Object.keys(symbole).map(function (key) {
return '<option value="' + key + '"' + ((wert.icon || "") === key ? " selected" : "")
+ ">" + textSchuetzen(symbole[key]) + "</option>";
}).join("");
// Eingabeart. "Messwert" steht nur zur Wahl, wenn es einen Katalog gibt -
// ohne Mesh-Datenbank waere es eine leere Liste.
let felder = '<div class="kw-quelle">'
+ '<select class="form-select form-select-sm" data-feld="quelle">'
+ (katalog.length ? '<option value="sensor"' + (quelle === "sensor" ? " selected" : "")
+ ">Messwert</option>" : "")
+ '<option value="topic"' + (quelle === "topic" ? " selected" : "") + ">Topic</option>"
+ '<option value="wr"' + (quelle === "wr" ? " selected" : "") + ">Wechselrichter</option>"
+ "</select></div>";
if (quelle === "sensor") {
const treffer = katalogTreffer(wert);
// Ohne Treffer beim ersten Geraet des eigenen Raums beginnen, sonst beim
// ersten ueberhaupt - ein neuer Wert soll nicht im Nachbargeschoss starten.
const ersterEigener = katalog.findIndex(
(g) => kachel && g.floorKey === kachel.floor && g.raumKey === kachel.name);
const gi = treffer ? treffer.gi : Math.max(0, ersterEigener);
const wi = treffer ? treffer.wi : -1;
/*
* Die Geraete des eigenen Raums stehen oben in einer eigenen Gruppe - das
* ist fast immer das Gesuchte. Der Rest folgt nach Etagen, denn manche
* Kachel zeigt bewusst etwas Fremdes: der Carport die Wallbox, die
* Technik den Netzanschluss. Ganz wegzulassen waere deshalb zu streng.
*
* Der Wert einer Option bleibt der Platz im vollstaendigen Katalog, nicht
* in der gefilterten Liste - sonst zeigte die Auswahl nach dem Umsortieren
* auf ein anderes Geraet.
*/
const eigen = (g) => kachel && g.floorKey === kachel.floor && g.raumKey === kachel.name;
const option = (g, index) => '<option value="' + index + '"'
+ (index === gi ? " selected" : "") + ">"
+ textSchuetzen(g.label + (g.raum && !eigen(g) ? " " + g.raum : "")) + "</option>";
let geraete = "";
const eigene = katalog.map((g, i2) => [g, i2]).filter(([g]) => eigen(g));
if (eigene.length) {
geraete += '<optgroup label="Dieser Raum">'
+ eigene.map(([g, i2]) => option(g, i2)).join("") + "</optgroup>";
}
let etage = null;
katalog.forEach(function (g, index) {
if (eigen(g)) return;
if (g.floor !== etage) {
if (etage !== null) geraete += "</optgroup>";
etage = g.floor;
geraete += '<optgroup label="' + textSchuetzen(etage || "ohne Raum") + '">';
}
geraete += option(g, index);
});
if (etage !== null) geraete += "</optgroup>";
const messwerte = (katalog[gi] || { werte: [] }).werte.map(function (w, index) {
// Der aktuelle Wert steht dabei: er beantwortet, ob man den richtigen
// Messwert erwischt hat, ohne die Kachel erst speichern zu muessen.
const jetzt = (w.wert === null || w.wert === undefined || w.wert === "")
? "" : " · " + w.wert + (w.einheit ? " " + w.einheit : "");
return '<option value="' + index + '"' + (index === wi ? " selected" : "") + ">"
+ textSchuetzen(w.name + jetzt) + "</option>";
}).join("");
felder += '<div class="kw-topic"><select class="form-select form-select-sm"'
+ ' data-feld="geraet">' + geraete + "</select></div>"
+ '<div class="kw-pfad"><select class="form-select form-select-sm" data-feld="messwert">'
+ (wi < 0 ? '<option value="-1" selected> wählen </option>' : "")
+ messwerte + "</select></div>";
} else if (quelle === "wr") {
felder += '<div class="kw-topic"><input type="text" class="form-control form-control-sm"'
+ ' data-feld="wechselrichter" placeholder="Namen, mit Komma getrennt"'
+ ' value="' + textSchuetzen((wert.wechselrichter || []).join(", ")) + '"></div>'
+ '<div class="kw-pfad"><input type="text" class="form-control form-control-sm"'
+ ' data-feld="feld" placeholder="Feld" value="' + textSchuetzen(wert.feld || "p_AC") + '"></div>';
} else {
felder += '<div class="kw-topic"><input type="text" class="form-control form-control-sm"'
+ ' data-feld="topic" list="kachelTopics" placeholder="MQTT-Topic"'
+ ' value="' + textSchuetzen(wert.topic || "") + '"></div>'
+ '<div class="kw-pfad"><input type="text" class="form-control form-control-sm"'
+ ' data-feld="pfad" placeholder="JSON-Pfad" value="' + textSchuetzen(wert.pfad || "") + '"></div>';
}
return '<div class="kachel-wert" data-i="' + i + '">' + felder
+ '<div class="kw-einheit"><input type="text" class="form-control form-control-sm"'
+ ' data-feld="einheit" placeholder="Einheit" maxlength="8"'
+ (wert.format === "leistung" ? " disabled" : "")
+ ' value="' + textSchuetzen(wert.einheit || "") + '"></div>'
+ '<div class="kw-stellen"><input type="number" class="form-control form-control-sm"'
+ ' data-feld="stellen" min="0" max="3" placeholder="0"'
+ (wert.format === "leistung" ? " disabled" : "")
+ ' value="' + (wert.stellen === undefined ? "" : wert.stellen) + '"></div>'
+ '<div class="kw-symbol"><select class="form-select form-select-sm" data-feld="icon">'
+ symbolOptionen + "</select></div>"
+ '<div class="kw-schalter">'
+ '<label title="Leistung: skaliert selbst zwischen W und kW">'
+ '<input type="checkbox" class="form-check-input" data-feld="format"'
+ (wert.format === "leistung" ? " checked" : "") + "> kW</label>"
+ '<label title="Die Zahl steht gross auf der Kachel">'
+ '<input type="checkbox" class="form-check-input" data-feld="gross"'
+ (wert.gross ? " checked" : "") + "> groß</label>"
+ '<button type="button" class="btn btn-sm btn-outline-danger kachel-weg"'
+ ' title="Wert entfernen"><i class="bi bi-trash"></i></button>'
+ "</div></div>";
}
/** Fremden Text sicher ins Markup bringen - Topics kommen aus der Datenbank. */
function textSchuetzen(s) {
const d = document.createElement("div");
d.textContent = String(s === undefined || s === null ? "" : s);
return d.innerHTML.replace(/"/g, "&quot;");
}
/* --- Bedienung ----------------------------------------------------------- */
function kachelHinweis(zeile, text, klasse) {
const feld = zeile.querySelector(".kachel-hinweis");
if (feld) {
feld.textContent = text;
feld.className = "kachel-hinweis small " + (klasse || "text-secondary");
}
}
/** Aus den Feldern einer Zeile die Wertdefinition zusammensetzen. */
function kachelWertLesen(feldZeile) {
const hol = (name) => {
const el = feldZeile.querySelector('[data-feld="' + name + '"]');
if (!el) return undefined;
return el.type === "checkbox" ? el.checked : el.value;
};
const wert = {};
const quelle = hol("quelle") || "topic";
wert._quelle = quelle; // die Wahl des Benutzers, nicht gespeichert
if (quelle === "wr") {
wert.wechselrichter = String(hol("wechselrichter") || "")
.split(",").map(t => t.trim()).filter(Boolean);
wert.feld = hol("feld") || "p_AC";
} else if (quelle === "sensor") {
// Topic und Pfad kommen aus dem Katalog, nicht aus einem Textfeld.
const g = ((kachelModell.katalog || [])[parseInt(hol("geraet"), 10)] || { werte: [] });
const w = g.werte[parseInt(hol("messwert"), 10)];
if (w) {
wert.topic = w.topic;
if (w.pfad) wert.pfad = w.pfad;
}
} else {
wert.topic = (hol("topic") || "").trim();
const pfad = (hol("pfad") || "").trim();
if (pfad) wert.pfad = pfad;
}
if (hol("format")) {
wert.format = "leistung";
} else {
const einheit = (hol("einheit") || "").trim();
if (einheit) wert.einheit = einheit;
const stellen = hol("stellen");
if (stellen !== "" && stellen !== undefined) wert.stellen = parseInt(stellen, 10);
}
const icon = hol("icon");
if (icon) wert.icon = icon;
if (hol("gross")) wert.gross = true;
return wert;
}
/** Den Entwurf einer Kachel aus allen sichtbaren Feldern nachziehen. */
function kachelEntwurfLesen(zeile, k) {
k._entwurf = Array.from(zeile.querySelectorAll(".kachel-wert")).map(kachelWertLesen);
}
async function kachelSenden(zeile, k, werte) {
kachelHinweis(zeile, "Wird gespeichert …", "text-secondary");
try {
const antwort = await fetch("ajax/settings.php?action=kacheln-save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ floor: k.floor, room: k.room, werte: werte }),
});
const d = await antwort.json();
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
k.werte = d.werte;
k.eigen = d.eigen;
delete k._entwurf;
zeichneKacheln();
// Nach dem Neuzeichnen ist die Zeile eine andere - der Hinweis gehoert
// an die neue.
const neu = document.querySelector('.kachel-zeile[data-floor="' + k.floor
+ '"][data-room="' + k.room + '"]');
if (neu && k._offen) kachelHinweis(neu, "Gespeichert.", "text-success");
} catch (e) {
kachelHinweis(zeile, e.message, "text-danger");
}
}
async function kachelnHolen() {
const ziel = document.getElementById("kachelListe");
if (!ziel) return;
try {
const antwort = await fetch("ajax/settings.php?action=kacheln", { cache: "no-store" });
const d = await antwort.json();
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
kachelModell = d;
zeichneKacheln();
} catch (e) {
ziel.innerHTML = '<div class="alert alert-danger mb-0">Kacheln nicht abrufbar: '
+ textSchuetzen(e.message) + "</div>";
}
}
function kachelnBinden() {
const liste = document.getElementById("kachelListe");
if (!liste) return;
liste.addEventListener("click", function (ereignis) {
const zeile = ereignis.target.closest(".kachel-zeile");
if (!zeile || !kachelModell) return;
const k = kachelSuchen(zeile.dataset.floor, zeile.dataset.room);
if (!k) return;
if (ereignis.target.closest(".kachel-kopf")) {
// Nur eine Kachel gleichzeitig offen: zwei Editoren nebeneinander
// fuellen den Bildschirm, und die Zeile darunter waere nicht mehr zu
// sehen. Ein Wechsel verwirft ungespeicherte Aenderungen nicht - der
// Entwurf bleibt am Modell haengen.
const wechsel = !k._offen;
kachelModell.kacheln.forEach(a => { a._offen = false; });
k._offen = wechsel;
if (wechsel && !k._entwurf) k._entwurf = JSON.parse(JSON.stringify(k.werte));
zeichneKacheln();
return;
}
if (ereignis.target.closest(".kachel-plus")) {
kachelEntwurfLesen(zeile, k);
k._entwurf.push((kachelModell.katalog || []).length
? { _quelle: "sensor", einheit: "" } // erst waehlen, dann feilen
: { topic: "", einheit: "" });
zeichneKacheln();
return;
}
if (ereignis.target.closest(".kachel-weg")) {
kachelEntwurfLesen(zeile, k);
k._entwurf.splice(parseInt(ereignis.target.closest(".kachel-wert").dataset.i, 10), 1);
zeichneKacheln();
return;
}
if (ereignis.target.closest(".kachel-speichern")) {
kachelEntwurfLesen(zeile, k);
kachelSenden(zeile, k, k._entwurf);
return;
}
if (ereignis.target.closest(".kachel-standard")) {
kachelSenden(zeile, k, null);
}
});
// Die Quelle und das Leistungsformat blenden Felder um - dafuer muss neu
// gezeichnet werden. Alles andere schreibt nur in den Entwurf.
liste.addEventListener("change", function (ereignis) {
const feld = ereignis.target.closest("[data-feld]");
const zeile = ereignis.target.closest(".kachel-zeile");
if (!feld || !zeile || !kachelModell) return;
const k = kachelSuchen(zeile.dataset.floor, zeile.dataset.room);
if (!k) return;
const name = feld.dataset.feld;
kachelEntwurfLesen(zeile, k);
/*
* Ein neu gewaehlter Messwert bringt seine Einheit mit - sonst stuende an
* einer Temperatur weiter "W". Beim Wechsel des Geraets faellt die alte
* Auswahl weg, dann ist erst einmal keiner gewaehlt.
*/
if (name === "geraet" || name === "messwert") {
const i = parseInt(feld.closest(".kachel-wert").dataset.i, 10);
const wert = k._entwurf[i];
const treffer = wert && katalogTreffer(wert);
if (treffer) {
wert.einheit = treffer.wert.einheit;
} else if (name === "geraet") {
delete wert.topic;
delete wert.pfad;
}
}
// Diese drei blenden Felder um; alles andere schreibt nur in den Entwurf.
if (name === "quelle" || name === "format" || name === "geraet" || name === "messwert") {
zeichneKacheln();
}
});
const neuLaden = document.getElementById("kachelNeuLaden");
if (neuLaden) neuLaden.addEventListener("click", kachelnHolen);
}
document.addEventListener("DOMContentLoaded", function () {
// Das Merken des offenen Reiters macht reiterMerken() in common.js - fuer
// jede Leiste der Seite, nicht nur fuer diese.
kachelnBinden();
kachelnHolen();
});
/* ==========================================================================
Reiter "Geräte": Raumzuordnung, Karteileichen.
Die Zuordnung war ein Modal auf der Startseite - erreichbar über ein
Zahnradsymbol neben den Automatismen, also genau dort, wo man sie nicht
sucht. Sie ist eine Einstellung und steht deshalb jetzt hier, zusammen mit
dem, was ohne sie fehlte: seit wann ein Gerät nichts mehr meldet, in
welchen Automatiken es steckt und der Knopf, mit dem ein abgebautes Gerät
verschwindet.
Letzterer ist nötig, weil der Suchlauf nichts löscht - er legt an und
aktualisiert, damit die Automatiken ihre Zustands-Ids behalten. Ein Gerät,
das aus dem Haus fliegt, bleibt also stehen, bis es hier jemand entfernt.
========================================================================== */
let geraeteModell = null; // { geraete, raeume }
const geraeteSieb = { text: "", nurOffen: false };
/* --- Ableitungen --------------------------------------------------------- */
/**
* Wie alt die jüngste Meldung eines Geräts ist, in Worten.
*
* "alt" heißt: lange genug her, um aufzufallen. Ein Thermostat meldet
* stündlich - drei Tage Stille sind schon viel; eine Jalousie meldet nur,
* wenn sie fährt. Deshalb faellt nur auf, was über einen Monat still ist.
*
* Geräte ganz ohne Messwerte - die Somfy-Screens etwa, die nur Befehle
* entgegennehmen - haben nichts zu melden. Bei ihnen steht ein Strich und
* keine Warnung: sonst stünde die halbe Spalte in Alarmfarbe, ohne dass
* irgendetwas fehlt.
*/
function geraetAlter(g) {
const zeitpunkt = g.gesehen;
if (!g.messwerte) return { text: "", alt: false };
if (!zeitpunkt) return { text: "nie", alt: true };
const d = new Date(String(zeitpunkt).replace(" ", "T"));
if (isNaN(d.getTime())) return { text: "", alt: false };
const tage = Math.floor((Date.now() - d.getTime()) / 86400000);
if (tage <= 0) return { text: "heute", alt: false };
if (tage === 1) return { text: "gestern", alt: false };
if (tage < 31) return { text: "vor " + tage + " Tagen", alt: tage > 30 };
const monate = Math.round(tage / 30);
return { text: "vor " + monate + " Monaten", alt: true };
}
/** Ein Gerät im Modell finden. */
function geraetSuchen(id) {
return (geraeteModell.geraete || []).find(g => g.id === id);
}
/** Was das Sieb übrig lässt. */
function geraeteGefiltert() {
const suche = geraeteSieb.text.trim().toLowerCase();
return (geraeteModell.geraete || []).filter(function (g) {
if (geraeteSieb.nurOffen && g.room) return false;
if (!suche) return true;
return (g.label + " " + g.type + " " + (g.roomLabel || "")).toLowerCase().indexOf(suche) >= 0;
});
}
/* --- Zeichnen ------------------------------------------------------------ */
function zeichneGeraete() {
const ziel = document.getElementById("geraeteListe");
if (!ziel || !geraeteModell) return;
const alle = geraeteModell.geraete || [];
const sichtbar = geraeteGefiltert();
const ohneRaum = alle.filter(g => !g.room).length;
const tipps = alle.filter(g => !g.room && g.tipp).length;
let html = '<p class="small text-body-secondary">Die Zuordnung ordnet die Geräteliste '
+ 'im Automatik-Editor und die Auswahl der Kachelwerte nach Räumen. Ein neuer '
+ 'Suchlauf legt Geräte an und frischt sie auf, löscht aber keine '
+ 'abgebaute Geräte verschwinden nur hier.</p>';
// Sieb: bei über sechzig Geräten ist Scrollen keine Suche.
html += '<div class="geraete-sieb">'
+ '<input type="search" class="form-control form-control-sm" id="geraeteSuche"'
+ ' placeholder="Gerät, Art oder Raum suchen" value="' + textSchuetzen(geraeteSieb.text) + '">'
+ '<div class="form-check form-switch mb-0">'
+ '<input class="form-check-input" type="checkbox" id="geraeteNurOffen"'
+ (geraeteSieb.nurOffen ? " checked" : "") + ">"
+ '<label class="form-check-label small" for="geraeteNurOffen">nur ohne Raum</label>'
+ "</div></div>";
if (tipps) {
// Viele Geräte tragen ihren Raum im Topic (Raumtemp/OG/Bad/Temp[degC]).
// Das von Hand nachzuklicken ist stumpfe Arbeit für den Rechner.
html += '<div class="alert alert-secondary py-2 small d-flex justify-content-between'
+ ' align-items-center gap-2 mb-2"><span>' + tipps + " Gerät(e) ohne Raum verraten ihn"
+ ' im MQTT-Topic.</span><button type="button" class="btn btn-outline-secondary'
+ ' btn-sm text-nowrap" id="geraeteTipps">Vorschläge übernehmen</button></div>';
}
html += '<div class="geraete-tafel table-responsive">'
+ '<table class="table table-sm table-hover align-middle mb-0"><thead><tr>'
+ "<th>Gerät</th>"
+ '<th class="d-none d-md-table-cell">Art</th>'
+ "<th>Raum</th>"
+ '<th class="d-none d-sm-table-cell">Gemeldet</th>'
+ '<th class="d-none d-lg-table-cell">Automatiken</th>'
+ '<th class="text-end"></th>'
+ "</tr></thead><tbody>";
html += sichtbar.length
? sichtbar.map(g => geraetZeile(g)).join("")
: '<tr><td colspan="6" class="text-body-secondary py-3">Nichts gefunden.</td></tr>';
html += "</tbody></table></div>"
+ '<div class="geraete-fuss small"><span class="text-body-secondary">'
+ alle.length + " Geräte, " + ohneRaum + " ohne Raum"
+ (sichtbar.length !== alle.length ? " · " + sichtbar.length + " angezeigt" : "")
+ '</span><span class="geraete-stand"></span></div>';
ziel.innerHTML = html;
}
/** Eine Zeile der Tafel. */
function geraetZeile(g) {
const alter = geraetAlter(g);
const benutzt = g.benutzt || [];
// Die Raumauswahl nach Etagen gruppiert - dieselbe Reihenfolge wie überall,
// sie kommt schon sortiert aus raumListe().
let auswahl = '<option value=""' + (g.room ? "" : " selected") + "></option>";
let etage = null;
(geraeteModell.raeume || []).forEach(function (rm) {
if (rm.floor !== etage) {
if (etage !== null) auswahl += "</optgroup>";
etage = rm.floor;
auswahl += '<optgroup label="' + textSchuetzen(rm.floor) + '">';
}
auswahl += '<option value="' + textSchuetzen(rm.key) + '"'
+ (g.room === rm.key ? " selected" : "") + ">"
+ textSchuetzen(rm.room) + "</option>";
});
if (etage !== null) auswahl += "</optgroup>";
// Der Papierkorb steht sechzigmal untereinander - in Rot waere die Tafel
// eine Warnleuchte. Er faerbt sich erst, wenn man ihn anfasst.
const weg = benutzt.length
? '<button type="button" class="btn btn-sm geraet-weg" disabled'
+ ' title="Steckt in einer Automatik"><i class="bi bi-trash3"></i></button>'
: '<button type="button" class="btn btn-sm geraet-weg"'
+ ' title="Gerät entfernen"><i class="bi bi-trash3"></i></button>';
return '<tr data-id="' + g.id + '"' + (g.room ? "" : ' class="ohne-raum"') + ">"
+ '<td class="name">' + textSchuetzen(g.label)
/*
* Unter der Beschriftung steht genau eine Zusatzzeile - schmal wird die
* Art dort gezeigt, weil ihre Spalte weg ist; breit die Anzahl der
* Messwerte und Kommandos, die vor dem Loeschen interessiert. Nie beides:
* auf dem Handy braeuchte das drei Zeilen und erschluege den Namen.
*/
+ '<span class="neben">'
+ '<span class="d-md-none">' + textSchuetzen(g.type) + "</span>"
+ '<span class="d-none d-md-inline">' + g.messwerte + " Messwerte · "
+ g.kommandos + " Kommandos</span></span></td>"
+ '<td class="d-none d-md-table-cell small text-body-secondary">'
+ textSchuetzen(g.type) + "</td>"
+ '<td class="raum"><select class="form-select form-select-sm" data-actor="' + g.id + '"'
+ (g.tipp ? ' data-tipp="' + textSchuetzen(g.tipp) + '"' : "") + ">"
+ auswahl + "</select></td>"
+ '<td class="d-none d-sm-table-cell small alter' + (alter.alt ? " lange" : "") + '">'
+ alter.text + "</td>"
+ '<td class="d-none d-lg-table-cell small">'
+ (benutzt.length
? '<span class="badge text-bg-secondary" title="' + textSchuetzen(benutzt.join(", ")) + '">'
+ benutzt.length + "</span>"
: '<span class="text-body-secondary"></span>')
+ "</td>"
+ '<td class="text-end">' + weg + "</td></tr>";
}
/** Kurze Rückmeldung im Fuß der Tafel; sie verblasst von selbst. */
function geraeteStand(text, klasse) {
const ziel = document.querySelector("#geraeteListe .geraete-stand");
if (!ziel) return;
ziel.className = "geraete-stand " + (klasse || "text-body-secondary");
ziel.textContent = text;
clearTimeout(geraeteStand._uhr);
if (klasse !== "text-danger") {
geraeteStand._uhr = setTimeout(() => { if (ziel.textContent === text) ziel.textContent = ""; }, 4000);
}
}
/* --- Sprechen mit dem Endpunkt ------------------------------------------- */
async function geraeteFrage(action, koerper) {
const antwort = await fetch("ajax/settings.php?action=" + action, koerper ? {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(koerper),
} : { cache: "no-store" });
const d = await antwort.json();
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
return d;
}
/**
* Eine oder mehrere Zuordnungen speichern.
*
* Gespeichert wird sofort beim Umstellen und ohne die Tafel neu zu zeichnen:
* wer sich durch sechzig Geräte arbeitet, will nicht nach jedem Klick seinen
* Platz in der Liste wiederfinden. Nur die Zeilenfarbe wandert mit.
*/
async function geraeteRaeumeSenden(zuordnung) {
const ids = Object.keys(zuordnung);
try {
await geraeteFrage("geraete-raeume", { rooms: zuordnung });
ids.forEach(function (id) {
const g = geraetSuchen(parseInt(id, 10));
if (g) {
g.room = zuordnung[id] || null;
const raum = (geraeteModell.raeume || []).find(r => r.key === g.room);
g.roomLabel = raum ? raum.label : null;
}
const zeile = document.querySelector('#geraeteListe tr[data-id="' + id + '"]');
if (zeile) zeile.classList.toggle("ohne-raum", !zuordnung[id]);
});
geraeteStand(ids.length > 1 ? ids.length + " Zuordnungen gespeichert." : "Gespeichert.",
"text-success");
} catch (e) {
geraeteStand(e.message, "text-danger");
zeichneGeraete(); // Anzeige zurück auf das, was wirklich gilt
}
}
/** Ein Gerät entfernen - endgültig, deshalb mit Nachfrage. */
async function geraetLoeschenFragen(id) {
const g = geraetSuchen(id);
if (!g) return;
if (!confirm("Gerät \"" + g.label + "\" mit " + g.messwerte + " Messwerten und "
+ g.kommandos + " Kommandos wirklich löschen?\n\n"
+ "Ein neuer Suchlauf legt es wieder an, wenn es noch antwortet.")) {
return;
}
try {
const d = await geraeteFrage("geraet-loeschen", { id: id });
geraeteModell.geraete = d.geraete;
zeichneGeraete();
geraeteStand("„" + d.geloescht + "“ entfernt.", "text-success");
} catch (e) {
geraeteStand(e.message, "text-danger");
}
}
async function geraeteHolen() {
const ziel = document.getElementById("geraeteListe");
if (!ziel) return;
try {
geraeteModell = await geraeteFrage("geraete");
zeichneGeraete();
} catch (e) {
ziel.innerHTML = '<div class="alert alert-danger mb-0">Geräte nicht abrufbar: '
+ textSchuetzen(e.message) + "</div>";
}
}
/* --- Bedienung ----------------------------------------------------------- */
function geraeteBinden() {
const liste = document.getElementById("geraeteListe");
if (!liste) return;
liste.addEventListener("change", function (ereignis) {
if (ereignis.target.id === "geraeteNurOffen") {
geraeteSieb.nurOffen = ereignis.target.checked;
zeichneGeraete();
// Der Schalter ist nach dem Neuzeichnen ein anderes Element - ohne das
// hier verlöre die Tastatur nach dem Umlegen ihren Platz.
const schalter = document.getElementById("geraeteNurOffen");
if (schalter) schalter.focus();
return;
}
const wahl = ereignis.target.closest("select[data-actor]");
if (wahl) {
const zuordnung = {};
zuordnung[wahl.dataset.actor] = wahl.value;
geraeteRaeumeSenden(zuordnung);
}
});
liste.addEventListener("input", function (ereignis) {
if (ereignis.target.id !== "geraeteSuche") return;
geraeteSieb.text = ereignis.target.value;
zeichneGeraete();
// Nach dem Neuzeichnen ist das Feld ein anderes - der Cursor gehört zurück.
const feld = document.getElementById("geraeteSuche");
if (feld) {
feld.focus();
feld.setSelectionRange(feld.value.length, feld.value.length);
}
});
liste.addEventListener("click", function (ereignis) {
if (ereignis.target.closest("#geraeteTipps")) {
// Alle offenen Vorschläge auf einmal - eine Anfrage, ein Neuzeichnen.
const zuordnung = {};
liste.querySelectorAll("select[data-tipp]").forEach(function (s) {
if (!s.value) {
s.value = s.dataset.tipp;
zuordnung[s.dataset.actor] = s.value;
}
});
if (Object.keys(zuordnung).length) {
geraeteRaeumeSenden(zuordnung).then(zeichneGeraete);
}
return;
}
const weg = ereignis.target.closest(".geraet-weg");
if (weg) {
geraetLoeschenFragen(parseInt(weg.closest("tr").dataset.id, 10));
}
});
const neuLaden = document.getElementById("geraeteNeuLaden");
if (neuLaden) neuLaden.addEventListener("click", geraeteHolen);
}
document.addEventListener("DOMContentLoaded", function () {
geraeteBinden();
geraeteHolen();
});