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:
@@ -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, """);
|
||||
}
|
||||
|
||||
/* --- 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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user