Files
Smart-Dashboard/js/solar/settings.js
T
adminandClaude Opus 5 3636a134d6 Grundriss aus der Datenbank: Etagen und Raeume in homeMesh
Etagen, Raeume, Kachelpositionen und Kachelwerte standen als Tabelle in
restricted/rooms.php, die Werte zusaetzlich ueberschreibbar in
solarLog.kachelWerte. Fuer ein anderes Haus haette man den Quelltext
umschreiben muessen.

Jetzt liegen sie in homeMesh.floors und homeMesh.rooms
(homeMesh_grundriss.sql). rooms.php liest sie und gibt sie in der
bisherigen Form weiter; Startseite, Menue, Editor und Einstellungen sind
unveraendert - vorher und nachher als HTML verglichen, gleich bis auf
Livewerte und die zwei neuen Felder nr und thermostat in homeRooms.

- Vorgabe einer Kachel ist jetzt allein das Thermostat (rooms.thermostat):
  Soll, Ist, Feuchte - sonst nichts. Was bisher im Code als Vorgabe stand,
  gilt damit als selbst gesetzt.
- Grundrissbild je Etage (floors.bild) statt assets/img/<code>.png.
- Standard-Etage (floors.standard) statt "OG" an vier Stellen.
- meshDb() in eigener Datei, sonst Ring zwischen rooms.php und
  automations.php.
- solarLog_kacheln.sql entfaellt; die Tabelle kachelWerte liest niemand mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:29:58 +02:00

1393 lines
56 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.
/*
* Einstellungsseite: Preistabellen bearbeiten.
*
* Eine Preistabelle ist eine Zeitreihe - ab einem Stichtag gilt ein Preis, bis
* der naechste Stichtag kommt. Deshalb gibt es hier nur ein Datumsfeld je
* Zeile. Wo eine Zeile endet, steht als grauer Text daneben und ergibt sich
* aus der naechsten; die juengste Zeile endet gar nicht, sie gilt weiter, bis
* ein neuer Preis eingetragen wird.
*
* Frueher stand das Ende als eigene Spalte in der Datenbank, und weil es
* eigenstaendig gepflegt wurde, konnte es dem naechsten Stichtag widersprechen:
* zwei Zeilen liefen ueber dasselbe Quartal, die Jahresstatistik zaehlte diese
* Tage doppelt. Ohne die Spalte laesst sich das nicht mehr hinschreiben.
*
* Gezeichnet wird aus dem Modell, nicht aus dem DOM: karte._modell ist die
* Wahrheit, jede Eingabe schreibt hinein und loest ein Neuzeichnen aus.
*/
// --- Datumskram ------------------------------------------------------------
/** Ein Datum "JJJJ-MM-TT" um $tage verschieben. */
function tagVersatz(iso, tage) {
const d = new Date(iso + "T00:00:00Z");
d.setUTCDate(d.getUTCDate() + tage);
return d.toISOString().slice(0, 10);
}
/** "2025-01-01" -> "01.01.2025" */
function datumLesbar(iso) {
if (!iso) return "";
const t = iso.split("-");
return t[2] + "." + t[1] + "." + t[0];
}
function heuteISO() {
const d = new Date();
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
}
// --- Modell ---------------------------------------------------------------
/** Zeilen aufsteigend nach Stichtag. */
function sortiert(zeilen) {
return zeilen.slice().sort((a, b) => (a.ab < b.ab ? -1 : a.ab > b.ab ? 1 : 0));
}
/**
* Die einzige Luecke, die es noch geben kann: vor dem aeltesten Stichtag gibt
* es keinen Preis. Messwerte, die davor liegen, fallen aus der Jahresstatistik
* heraus, weil der JOIN fuer sie keine Zeile findet. Passieren kann das, wenn
* jemand den aeltesten Zeitraum loescht oder nach hinten schiebt - deshalb
* wird die Warnung bei jedem Neuzeichnen geprueft, nicht nur beim Laden.
*/
function zeitraumWarnung(zeilen) {
const liste = sortiert(zeilen);
if (!ersteMessung || !liste.length || liste[0].ab <= ersteMessung) return [];
return ["Die Messwerte beginnen am " + datumLesbar(ersteMessung) + ", der älteste Preis"
+ " erst am " + datumLesbar(liste[0].ab) + ". Alles davor fehlt in der Statistik."];
}
// --- Zeichnen -------------------------------------------------------------
function preisZeile(karte, zeile, i, anzahl) {
const daten = preisDaten[karte.dataset.reihe];
const felder = daten.felder;
// Die Spaltenbreiten kommen aus preisSpalten() im PHP - Kopfzeile und
// Felder muessen dieselben benutzen, sonst stehen die Ueberschriften schief.
const breit = daten.spalten;
const letzte = (i === anzahl - 1);
const heute = heuteISO();
// Die juengste Zeile hat kein Ende; alle anderen enden am Tag vor der
// naechsten.
const bis = letzte ? null : tagVersatz(karte._modell[i + 1].ab, -1);
const gilt = zeile.ab <= heute && (bis === null || heute <= bis);
let html = "<div class='row g-2 align-items-center preis-zeile" + (gilt ? " preis-heute" : "")
+ "' data-i='" + i + "'>";
html += "<div class='col-6 col-xl-" + breit.ab + "'>"
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>Gültig ab</label>"
+ "<input type='date' class='form-control form-control-sm' data-feld='ab' value='" + zeile.ab + "'>"
+ "</div>";
// Die Marke "gilt heute" steht beim Zeitraum, nicht beim Papierkorb: sie
// beschreibt ihn, und die Aktionsspalte bleibt schmal genug, dass die
// Felder den Platz bekommen.
html += "<div class='col-6 col-xl-" + breit.bis + "'>"
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>Gültig bis</label>"
+ "<div class='form-control form-control-sm border-0 bg-transparent px-0"
+ " d-flex align-items-center gap-2'>"
+ "<span class='text-secondary'>" + (bis === null ? "offen" : datumLesbar(bis)) + "</span>"
+ (gilt ? "<span class='badge text-bg-primary'>gilt heute</span>" : "")
+ "</div></div>";
Object.keys(felder).forEach(function (spalte, nr) {
const feld = felder[spalte];
// Schrittweite aus den Nachkommastellen: der Energieinhalt je Liter will
// vier Stellen, ein Preis in Cent nur zwei.
const schritt = Math.pow(10, -feld.stellen).toFixed(feld.stellen);
html += "<div class='col-6 col-xl-" + breit.felder[nr] + "'>"
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>" + feld.titel + "</label>"
+ "<div class='input-group input-group-sm'>"
+ "<input type='number' step='" + schritt + "' min='0' max='" + feld.max + "'"
+ " class='form-control text-end' data-feld='" + spalte + "'"
+ " value='" + zeile[spalte] + "'>"
+ "<span class='input-group-text'>" + feld.einheit + "</span>"
+ "</div></div>";
});
html += "<div class='col-12 col-xl-" + breit.aktion + " d-flex align-items-center justify-content-end'>";
html += "<button type='button' class='btn btn-sm btn-outline-danger preis-weg' title='Zeitraum löschen'"
+ (anzahl > 1 ? "" : " disabled") + "><i class='bi bi-trash'></i></button>"
+ "</div></div>";
return html;
}
function zeichnePreise(karte) {
karte._modell = sortiert(karte._modell);
const anzahl = karte._modell.length;
const warnung = zeitraumWarnung(karte._modell);
let html = "";
if (warnung.length) {
html += "<div class='alert alert-warning py-2 small'>"
+ "<i class='bi bi-exclamation-triangle me-1'></i>"
+ warnung.join(" ") + "</div>";
}
for (let i = 0; i < anzahl; i++) {
html += preisZeile(karte, karte._modell[i], i, anzahl);
}
karte.querySelector(".preis-zeilen").innerHTML = html;
}
// --- Bedienung ------------------------------------------------------------
function preisHinweis(karte, text, klasse) {
const feld = karte.querySelector(".preis-hinweis");
feld.className = "preis-hinweis small " + (klasse || "text-secondary");
feld.textContent = text;
}
function preisNeueZeile(karte) {
const modell = karte._modell;
const vorlage = modell[modell.length - 1];
// Der neue Stichtag ist heute; ist der schon vergeben, der naechste freie
// Tag. So entsteht nie eine Zeile, die das Speichern gleich ablehnt.
let ab = heuteISO();
while (modell.some(z => z.ab === ab)) {
ab = tagVersatz(ab, 1);
}
const zeile = { ab: ab };
for (const spalte in preisDaten[karte.dataset.reihe].felder) {
zeile[spalte] = vorlage[spalte]; // Preise uebernehmen, geaendert wird meist nur einer
}
modell.push(zeile);
zeichnePreise(karte);
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
const zeilen = karte.querySelectorAll(".preis-zeile");
const feld = zeilen[zeilen.length - 1].querySelector("[data-feld='ab']");
feld.focus();
}
async function preiseSenden(karte) {
const knopf = karte.querySelector(".preis-speichern");
knopf.disabled = true;
preisHinweis(karte, "Wird gespeichert …");
try {
const antwort = await fetch("ajax/settings.php?action=save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reihe: karte.dataset.reihe, zeilen: karte._modell })
});
const daten = await antwort.json();
if (!antwort.ok) {
throw new Error(daten.error || "Fehler " + antwort.status);
}
// Mit der Antwort des Servers weiterarbeiten, nicht mit dem eigenen
// Modell: sortiert und gerundet wird dort.
karte._modell = daten.zeilen;
zeichnePreise(karte);
preisHinweis(karte, "Gespeichert " + daten.gespeichert + " Zeiträume.", "text-success");
} catch (fehler) {
preisHinweis(karte, fehler.message, "text-danger");
}
knopf.disabled = false;
}
async function preiseHolen(karte) {
preisHinweis(karte, "Wird geladen …");
try {
const antwort = await fetch("ajax/settings.php?action=list&reihe=" + encodeURIComponent(karte.dataset.reihe));
const daten = await antwort.json();
if (!antwort.ok) {
throw new Error(daten.error || "Fehler " + antwort.status);
}
karte._modell = daten.zeilen;
zeichnePreise(karte);
preisHinweis(karte, "");
} catch (fehler) {
preisHinweis(karte, fehler.message, "text-danger");
}
}
function preisKarteBinden(karte) {
karte.addEventListener("input", function (ereignis) {
const feld = ereignis.target.closest("[data-feld]");
if (!feld) return;
const zeile = karte._modell[parseInt(feld.closest(".preis-zeile").dataset.i, 10)];
if (feld.dataset.feld === "ab") {
if (!feld.value) return; // waehrend des Tippens ist das Feld kurz leer
zeile.ab = feld.value;
} else {
zeile[feld.dataset.feld] = isNaN(feld.valueAsNumber) ? 0 : feld.valueAsNumber;
}
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
});
// Ein geaenderter Stichtag verschiebt die Reihenfolge und damit die Enden
// aller Zeilen - aber erst, wenn das Feld fertig ist. Waehrend des Tippens
// neu zu zeichnen wuerde den Eingabefokus wegreissen.
karte.addEventListener("change", function (ereignis) {
if (ereignis.target.dataset.feld === "ab") {
zeichnePreise(karte);
}
});
karte.addEventListener("click", function (ereignis) {
if (ereignis.target.closest(".preis-neu")) {
preisNeueZeile(karte);
} else if (ereignis.target.closest(".preis-weg")) {
const i = parseInt(ereignis.target.closest(".preis-zeile").dataset.i, 10);
karte._modell.splice(i, 1);
zeichnePreise(karte);
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
} else if (ereignis.target.closest(".preis-zurueck")) {
preiseHolen(karte);
} else if (ereignis.target.closest(".preis-speichern")) {
preiseSenden(karte);
}
});
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".preis-karte").forEach(function (karte) {
const daten = preisDaten[karte.dataset.reihe];
if (!daten) return;
karte._modell = sortiert(daten.zeilen);
zeichnePreise(karte);
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) {
const einheit = istLeistung(wert) ? "W/kW" : (wert.einheit || "");
const treffer = wert.wechselrichter ? null : katalogTreffer(wert);
let text;
if (wert.wechselrichter) {
text = "Wechselrichter (" + wert.wechselrichter.length + ")";
} else if (treffer) {
// Das Geraet steht nur dabei, wenn es ein fremdes ist - im eigenen Raum
// waere es auf jeder Zeile dasselbe Wort.
const fremd = !kachel || treffer.geraet.raum !== kachel.name;
text = (fremd ? treffer.geraet.label + " · " : "") + treffer.wert.name
+ (einheit ? " " + einheit : "");
} else {
const topic = wert.topic || "";
const teile = topic.split("/");
text = teile[teile.length - 1] || topic;
if (wert.pfad) text += " · " + wert.pfad;
if (einheit) text += " " + einheit;
}
// Das Minus steht vorne, wie es auf der Kachel auch stuende - und zwar bei
// jeder Herkunft, deshalb erst hier und nicht in den drei Zweigen.
return wert.negativ ? "" + text : 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">Etagen und Räume '
+ 'lassen sich nicht lesen die Datenbank <code>homeMesh</code> ist nicht '
+ 'erreichbar, oder <code>homeMesh_grundriss.sql</code> fehlt noch.</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;
// Das Symbol steht auch in der Kurzfassung: es ist das, was auf der Kachel
// vor der Zahl steht, und damit der schnellste Weg zu erkennen, welcher der
// drei Werte gemeint ist.
const kurz = werte.length
? werte.map(w => '<span class="kachel-chip">'
+ (w.icon && kachelSymbolBekannt(w.icon)
? '<i class="bi bi-' + textSchuetzen(w.icon) + '"></i>' : "")
+ 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>";
}
/* --- Symbole ------------------------------------------------------------- */
/**
* Das Feld für das Symbol: eine Vorschau und ein Name.
*
* Statt einer Auswahlliste mit einer Handvoll Symbole steht hier der Name des
* Bootstrap-Icons - damit sind alle gut zweitausend möglich, ohne dass für
* jedes neue eines im Code nachgetragen werden müsste. Die üblichen stehen in
* der Vorschlagsliste obenan, mit deutscher Beschriftung; wer ein anderes
* will, tippt und bekommt den Rest angeboten (icons.getbootstrap.com).
*
* Die Vorschau ist die eigentliche Hilfe: ein Name wie "moisture" sagt nichts,
* das Symbol daneben schon.
*/
function kachelSymbolFeld(icon) {
return '<span class="kw-symbol-schau">' + kachelSymbolVorschau(icon) + "</span>"
+ '<input type="text" class="form-control form-control-sm" data-feld="icon"'
+ ' list="kachelIcons" placeholder="Symbol" autocomplete="off"'
+ ' value="' + textSchuetzen(icon) + '">';
}
/** Der Inhalt der Vorschau: das Symbol, ein Strich, oder ein Fragezeichen. */
function kachelSymbolVorschau(icon) {
if (!icon) return '<i class="bi bi-dash" title="ohne Symbol"></i>';
if (!kachelSymbolBekannt(icon)) {
return '<i class="bi bi-question-circle unbekannt" title="Dieses Symbol gibt es nicht"></i>';
}
return '<i class="bi bi-' + textSchuetzen(icon) + '" title="' + textSchuetzen(icon) + '"></i>';
}
function kachelSymbolBekannt(icon) {
if (!kachelSymbolBekannt._satz) {
kachelSymbolBekannt._satz = new Set((kachelModell.symbole || []).map(s => s.name));
}
return kachelSymbolBekannt._satz.has(icon);
}
/**
* Die Vorschlagsliste steht einmal im Dokument, nicht in jeder Zeile.
*
* Zweitausend Einträge sind rund siebzig Kilobyte Markup; bei jedem
* Neuzeichnen einer Kachel neu zu bauen waere Verschwendung. Sie haengt
* deshalb am Ende des Dokuments und ueberlebt das Neuzeichnen der Liste.
*/
function kachelIconListeSicherstellen() {
if (document.getElementById("kachelIcons")) return;
const liste = document.createElement("datalist");
liste.id = "kachelIcons";
liste.innerHTML = (kachelModell.symbole || []).map(s => '<option value="' + s.name + '"'
+ (s.titel ? ' label="' + textSchuetzen(s.titel) + '"' : "") + ">").join("");
document.body.appendChild(liste);
}
/**
* 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) || [];
// 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"'
+ (istLeistung(wert) ? " 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"'
+ (istLeistung(wert) ? " disabled" : "")
+ ' value="' + (wert.stellen === undefined ? "" : wert.stellen) + '"></div>'
+ '<div class="kw-symbol">' + kachelSymbolFeld(wert.icon || "") + "</div>"
+ '<div class="kw-schalter">'
// Leistung: die Anzeige skaliert selbst zwischen W und kW. Gewählt wird
// nur, in welcher Einheit die Quelle meldet - die Wallboxen schicken kW,
// die Zähler W. Früher ein Häkchen, das stillschweigend Watt annahm.
+ '<select class="form-select form-select-sm kw-format" data-feld="format"'
+ ' title="Leistung: in welcher Einheit die Quelle meldet. Angezeigt wird'
+ ' dann selbst in W oder kW.">'
+ '<option value=""' + (istLeistung(wert) ? "" : " selected") + '>Einheit</option>'
+ '<option value="leistung"' + (wert.format === "leistung" ? " selected" : "")
+ '>Leistung aus W</option>'
+ '<option value="leistung_kw"' + (wert.format === "leistung_kw" ? " selected" : "")
+ '>Leistung aus kW</option>'
+ "</select>"
+ '<label title="Vorzeichen umdrehen - für Zähler, die verkehrt herum'
+ ' eingebaut sind">'
+ '<input type="checkbox" class="form-check-input" data-feld="negativ"'
+ (wert.negativ ? " checked" : "") + "> negativ</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>";
}
/** Skaliert der Wert selbst zwischen W und kW? Egal, aus welcher Einheit. */
function istLeistung(wert) {
return wert.format === "leistung" || wert.format === "leistung_kw";
}
/** 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;
}
const format = hol("format");
if (format === "leistung" || format === "leistung_kw") {
wert.format = format;
} 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") || "").trim();
if (icon) wert.icon = icon;
if (hol("negativ")) wert.negativ = true;
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;
delete kachelSymbolBekannt._satz; // neue Antwort, neue Namen
kachelIconListeSicherstellen();
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();
}
});
/*
* Die Vorschau folgt dem Tippen. Neu gezeichnet wird dafuer nicht - das
* naehme dem Feld nach jedem Buchstaben den Cursor.
*/
liste.addEventListener("input", function (ereignis) {
const feld = ereignis.target.closest('[data-feld="icon"]');
if (!feld) return;
const schau = feld.parentElement.querySelector(".kw-symbol-schau");
if (schau) schau.innerHTML = kachelSymbolVorschau(feld.value.trim());
});
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 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();
});
/* ==========================================================================
Fahrzeug: die Zugangsschlüssel der MyŠkoda-Anbindung
--------------------------------------------------------------------------
Sie laufen ab. Bis hierher hieß der Tausch: per SSH auf die NAS, skoda.conf
im Editor öffnen, nicht vertippen. Das gehört in die Einstellungen wie die
Preise und die Geräte.
Was hier nie ankommt, sind die Schlüssel selbst. Die Seite steht im
Heimnetz ohne Anmeldung offen, und ein solcher Schlüssel steuert das
Fahrzeug von überall auf der Welt - Laden, Klima, Standort. Zu sehen sind
nur die letzten vier Zeichen, gerade genug, um zwei auseinanderzuhalten.
Geschrieben wird deshalb auch nur in eine Richtung: ein neuer Wert geht
hinein, ein alter kommt nie heraus.
Der zweite und dritte Schlüssel beschleunigen nichts: das Kontingent der
API gilt gemessen fürs ganze Konto. Sie sind für den Fall da, dass einer
abläuft oder widerrufen wird - dann fragt der Abruf mit dem nächsten
weiter, statt stillzustehen. Deshalb steht unter den Feldern, was gerade
gilt, und nicht, was ein weiterer Schlüssel brächte.
========================================================================== */
let skodaModell = null; // was ?action=skoda-keys liefert, ohne Werte
/* --- Zeichnen ------------------------------------------------------------ */
function zeichneSkoda() {
const ziel = document.getElementById("skodaListe");
if (!ziel || !skodaModell) return;
if (!skodaModell.da) {
ziel.innerHTML = '<div class="alert alert-warning mb-0">'
+ "Die Datei <code>" + textSchuetzen(skodaModell.datei) + "</code> gibt es nicht. "
+ "Sie gehört zum SolarManager und wird dort angelegt ohne sie läuft auch "
+ "der Abruf nicht.</div>";
return;
}
const zeilen = skodaModell.felder.map(skodaZeile).join("");
ziel.innerHTML =
'<div class="skoda-felder">' + zeilen + "</div>"
+ (skodaModell.schreibbar ? "" :
'<div class="alert alert-warning mt-3 mb-0 py-2 small">'
+ "Der Webserver darf <code>" + textSchuetzen(skodaModell.datei)
+ "</code> nicht schreiben. Eintragen lässt sich hier nichts.</div>")
+ '<div class="d-flex flex-wrap align-items-center gap-3 mt-3">'
+ '<button type="button" class="btn btn-primary" id="skodaSpeichern"'
+ (skodaModell.schreibbar ? "" : " disabled") + ">Eintragen</button>"
+ '<span class="skoda-stand small text-body-secondary"></span>'
+ "</div>"
+ skodaBilanz();
}
/** Eine Zeile: was der Schlüssel tut, ob er da ist, und ein Feld dafür. */
function skodaZeile(f) {
const kennung = "skodaFeld-" + f.feld;
const stand = f.gesetzt
? '<span class="badge text-bg-success">hinterlegt</span>'
+ ' <span class="text-body-secondary">endet auf ' + textSchuetzen(f.maske) + "</span>"
: '<span class="badge text-bg-secondary">'
+ (f.pflicht ? "fehlt" : "nicht hinterlegt") + "</span>";
// Entfernen nur, wo es etwas zu entfernen gibt und wo es ohne geht. Der
// erste Schlüssel lässt sich ersetzen, aber nicht abschaffen - ohne ihn
// steht der Abruf.
const weg = (f.gesetzt && !f.pflicht)
? '<button type="button" class="btn btn-outline-danger btn-sm skoda-weg"'
+ ' data-feld="' + textSchuetzen(f.feld) + '" title="Diesen Schlüssel entfernen">'
+ '<i class="bi bi-trash"></i></button>'
: "";
return '<div class="border-top py-3 skoda-feld" data-feld="' + textSchuetzen(f.feld) + '">'
+ '<div class="d-flex flex-wrap align-items-baseline gap-2">'
+ '<label class="fw-semibold mb-0" for="' + kennung + '">'
+ textSchuetzen(f.titel) + "</label>"
+ stand
+ "</div>"
+ '<p class="text-body-secondary small mb-2">' + textSchuetzen(f.zweck) + "</p>"
+ '<div class="input-group input-group-sm">'
+ '<input type="password" class="form-control" id="' + kennung + '"'
+ ' autocomplete="off" spellcheck="false"'
+ ' placeholder="' + (f.gesetzt ? "neuer Schlüssel leer lassen, um ihn zu behalten"
: "Schlüssel einfügen") + '">'
+ '<button type="button" class="btn btn-outline-secondary skoda-zeigen"'
+ ' title="Anzeigen"><i class="bi bi-eye"></i></button>'
+ weg
+ "</div>"
+ "</div>";
}
/** Was die hinterlegten Schlüssel einbringen - die Zahlen dahinter. */
function skodaBilanz() {
const m = skodaModell;
const teile = [];
teile.push(m.abruf + (m.abruf === 1 ? " Schlüssel" : " Schlüssel im Wechsel")
+ " für den Abruf beim Laden und während der Fahrt alle "
+ m.takt + " Sekunden");
teile.push(m.befehle + " Befehle je Stunde");
if (m.ablauf) {
teile.push("nächster Ablauf am " + datumLesbar(String(m.ablauf).slice(0, 10)));
}
return '<p class="text-body-secondary small mt-3 mb-0">'
+ textSchuetzen(teile.join(" · ")) + "<br>"
+ "Ein neuer Schlüssel gilt sofort; der Abruf liest die Datei von selbst neu."
+ "</p>";
}
function skodaStand(text, klasse) {
const ziel = document.querySelector("#skodaListe .skoda-stand");
if (!ziel) return;
ziel.className = "skoda-stand small " + (klasse || "text-body-secondary");
ziel.textContent = text;
clearTimeout(skodaStand._uhr);
if (klasse !== "text-danger") {
skodaStand._uhr = setTimeout(function () {
if (ziel.textContent === text) ziel.textContent = "";
}, 8000);
}
}
/* --- Sprechen mit dem Endpunkt ------------------------------------------- */
async function skodaFrage(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;
}
async function skodaHolen() {
const ziel = document.getElementById("skodaListe");
if (!ziel) return;
try {
skodaModell = await skodaFrage("skoda-keys");
zeichneSkoda();
} catch (e) {
ziel.innerHTML = '<div class="alert alert-danger mb-0">Schlüssel nicht abrufbar: '
+ textSchuetzen(e.message) + "</div>";
}
}
/**
* Alles Eingetragene auf einmal senden.
*
* Leere Felder bedeuten "so lassen" - sonst löschte ein Speichern ohne
* Eingabe alles, was man nicht noch einmal eintippt.
*
* Das kann dauern: der Server probiert jeden neuen Schlüssel einmal an der
* API aus, bevor er ihn hinschreibt. Deshalb der Knopf mit Wartezustand.
*/
async function skodaSenden() {
const knopf = document.getElementById("skodaSpeichern");
const felder = {};
document.querySelectorAll("#skodaListe .skoda-feld").forEach(function (zeile) {
const feld = zeile.dataset.feld;
const wert = zeile.querySelector("input").value.trim();
if (wert) felder[feld] = wert;
});
if (!Object.keys(felder).length) {
skodaStand("Kein Schlüssel eingetragen.", "text-warning");
return;
}
if (knopf) knopf.disabled = true;
skodaStand("Wird an der Škoda-API ausprobiert …");
try {
const d = await skodaFrage("skoda-keys-save", { felder: felder });
skodaModell = d.stand;
zeichneSkoda();
skodaStand((d.meldungen || []).join(" ") || "Eingetragen.", "text-success");
} catch (e) {
skodaStand(e.message, "text-danger");
if (knopf) knopf.disabled = false;
}
}
/**
* Einen Schlüssel entfernen.
*
* Mit Rückfrage: er steht danach nirgends mehr, und ein neuer kommt nur aus
* der MyŠkoda-App.
*/
async function skodaLoeschen(feld) {
if (!window.confirm(feld + " entfernen?\n\nDer Schlüssel verschwindet aus der "
+ "Konfiguration. Zurückholen lässt er sich nur über die MyŠkoda-App.")) {
return;
}
skodaStand("Wird entfernt …");
try {
const d = await skodaFrage("skoda-keys-save", { loeschen: [feld] });
skodaModell = d.stand;
zeichneSkoda();
skodaStand(feld + " entfernt.", "text-success");
} catch (e) {
skodaStand(e.message, "text-danger");
}
}
/* --- Bedienung ----------------------------------------------------------- */
function skodaBinden() {
const liste = document.getElementById("skodaListe");
if (!liste) return;
liste.addEventListener("click", function (ereignis) {
const zeigen = ereignis.target.closest(".skoda-zeigen");
if (zeigen) {
// Ein eingefügter Schlüssel ist nicht zu merken, nur zu vergleichen -
// wer ihn nicht sehen kann, sucht einen Tippfehler im Dunkeln.
const feld = zeigen.closest(".input-group").querySelector("input");
feld.type = feld.type === "password" ? "text" : "password";
zeigen.querySelector("i").className =
feld.type === "password" ? "bi bi-eye" : "bi bi-eye-slash";
return;
}
const weg = ereignis.target.closest(".skoda-weg");
if (weg) {
skodaLoeschen(weg.dataset.feld);
return;
}
if (ereignis.target.closest("#skodaSpeichern")) {
skodaSenden();
}
});
// Enter im Feld soll dasselbe tun wie der Knopf daneben.
liste.addEventListener("keydown", function (ereignis) {
if (ereignis.key === "Enter" && ereignis.target.matches(".skoda-feld input")) {
ereignis.preventDefault();
skodaSenden();
}
});
const neuLaden = document.getElementById("skodaNeuLaden");
if (neuLaden) neuLaden.addEventListener("click", skodaHolen);
}
document.addEventListener("DOMContentLoaded", function () {
skodaBinden();
skodaHolen();
});