Der Geraetesuchlauf legt "Benachrichtigungen" an (benachrichtigung_module.py), ein Kommando je Kanal. Damit kann jede Automatik melden, ohne dass Editor, Zeitleiste oder Datenbank etwas davon wissen muessten. Fuer die Meldung aufs Handy braucht es keine App: Der Browser meldet sich unter Einstellungen -> Meldungen selbst an (sw.js, ajax/push.php), das Abo steht in homeMesh.push_abos, verschickt wird vom Runner. Auf dem iPhone geht es erst ab der Ablage auf dem Home-Bildschirm - der Reiter sagt das auch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1571 lines
64 KiB
JavaScript
1571 lines
64 KiB
JavaScript
/*
|
||
* 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.raumKey === kachel.nr);
|
||
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.
|
||
*/
|
||
// Ueber die feste Raumnummer, nicht ueber den Namen - "Bad" gibt es auf
|
||
// drei Etagen, und ein umbenannter Raum bliebe sonst ohne eigene Geraete.
|
||
const eigen = (g) => kachel && g.raumKey === kachel.nr;
|
||
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, """);
|
||
}
|
||
|
||
/* --- 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);
|
||
}
|
||
|
||
/**
|
||
* Eine Kachel von aussen aufschlagen - der Knopf "Werte" im Reiter Grundriss.
|
||
*
|
||
* Wechselt in diesen Reiter, laedt nach, falls sich der Grundriss seit dem
|
||
* letzten Laden geaendert hat (eine eben gesetzte Kachel stuende sonst nicht
|
||
* in der Liste), und oeffnet die Zeile.
|
||
*/
|
||
async function kachelOeffnen(floor, room) {
|
||
const knopf = document.getElementById("einstellung-kacheln-tab");
|
||
if (knopf && typeof bootstrap !== "undefined") new bootstrap.Tab(knopf).show();
|
||
if (window.grundrissGeaendert || !kachelModell) {
|
||
window.grundrissGeaendert = false;
|
||
await kachelnHolen();
|
||
}
|
||
const k = kachelModell && kachelSuchen(floor, room);
|
||
if (!k) return;
|
||
kachelModell.kacheln.forEach(a => { a._offen = false; });
|
||
k._offen = true;
|
||
if (!k._entwurf) k._entwurf = JSON.parse(JSON.stringify(k.werte));
|
||
zeichneKacheln();
|
||
const zeile = document.querySelector('.kachel-zeile[data-floor="' + k.floor + '"][data-room="' + k.room + '"]');
|
||
if (zeile) zeile.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
}
|
||
|
||
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();
|
||
|
||
// Nach einer Aenderung im Reiter Grundriss wissen Kacheln und Geraete nicht
|
||
// mehr, was gilt: neue Raeume fehlen, umbenannte heissen noch wie vorher.
|
||
// Nachgeladen wird beim naechsten Oeffnen - nicht sofort, sonst liefe bei
|
||
// jedem gezogenen Kachelchen eine Anfrage fuer zwei Reiter mit.
|
||
const kachelReiter = document.getElementById("einstellung-kacheln-tab");
|
||
if (kachelReiter) kachelReiter.addEventListener("shown.bs.tab", function () {
|
||
if (window.grundrissGeaendert) {
|
||
window.grundrissGeaendert = false;
|
||
window.geraeteVeraltet = true;
|
||
kachelnHolen();
|
||
}
|
||
});
|
||
const geraeteReiter = document.getElementById("einstellung-geraete-tab");
|
||
if (geraeteReiter) geraeteReiter.addEventListener("shown.bs.tab", function () {
|
||
if (window.grundrissGeaendert || window.geraeteVeraltet) {
|
||
window.geraeteVeraltet = false;
|
||
if (typeof geraeteHolen === "function") geraeteHolen();
|
||
}
|
||
});
|
||
});
|
||
|
||
|
||
/* ==========================================================================
|
||
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);
|
||
}
|
||
|
||
|
||
/* ==========================================================================
|
||
Reiter "Meldungen": Web-Push-Abos dieses und der anderen Geräte.
|
||
|
||
Das Abo gehört dem Browser. Deshalb steht hier zweierlei nebeneinander:
|
||
dieses Gerät (an- und abmelden) und die Liste aller angemeldeten Geräte
|
||
(entfernen, wenn eines weg ist). Verschickt wird nichts von hier - die
|
||
Probe geht über MQTT an den Runner, genau wie eine Automatik.
|
||
========================================================================== */
|
||
|
||
let pushDiesesGeraet = null; // Abo dieses Browsers, oder null
|
||
|
||
async function pushZeichnen() {
|
||
const ziel = document.getElementById("pushListe");
|
||
if (!ziel) return;
|
||
|
||
if (!pushMoeglich()) {
|
||
ziel.innerHTML = '<div class="alert alert-secondary mb-0">Dieser Browser kann keine '
|
||
+ "Benachrichtigungen empfangen. Auf dem iPhone geht es erst, wenn die Seite über "
|
||
+ "„Teilen ▸ Zum Home-Bildschirm“ abgelegt und von dort geöffnet wird.</div>";
|
||
return;
|
||
}
|
||
if (!pushStand.schluessel) {
|
||
ziel.innerHTML = '<div class="alert alert-warning mb-0">Auf der NAS fehlt das '
|
||
+ "Schlüsselpaar. Einmalig <code>python3 vapid_erzeugen.py</code> im SolarManager "
|
||
+ "ausführen, dann diese Seite neu laden.</div>";
|
||
return;
|
||
}
|
||
|
||
const an = !!pushDiesesGeraet;
|
||
const blockiert = Notification.permission === "denied";
|
||
const zeilen = pushStand.abos.map(a => {
|
||
const stand = a.fehler
|
||
? '<span class="text-danger">' + textSchuetzen(a.fehler) + "</span>"
|
||
: a.zuletzt ? "zuletzt zugestellt " + textSchuetzen(a.zuletzt)
|
||
: "noch nichts zugestellt";
|
||
return '<div class="push-abo border-top py-2 d-flex align-items-center gap-2 flex-wrap">'
|
||
+ '<i class="bi bi-phone text-body-secondary"></i>'
|
||
+ "<b>" + textSchuetzen(a.name || "Gerät") + "</b>"
|
||
+ '<span class="small text-body-secondary">' + textSchuetzen(a.browser) + "</span>"
|
||
+ '<span class="small text-body-secondary ms-auto">' + stand + "</span>"
|
||
+ '<button type="button" class="btn btn-outline-danger btn-sm push-weg" data-id="' + a.id
|
||
+ '" title="Dieses Gerät entfernen"><i class="bi bi-trash3"></i></button></div>';
|
||
}).join("");
|
||
|
||
ziel.innerHTML =
|
||
'<div class="d-flex flex-wrap align-items-center gap-2 mb-3">'
|
||
+ '<button type="button" class="btn ' + (an ? "btn-outline-secondary" : "btn-primary")
|
||
+ '" id="pushSchalter"' + (blockiert && !an ? " disabled" : "") + ">"
|
||
+ '<i class="bi ' + (an ? "bi-bell-slash" : "bi-bell") + ' me-1"></i>'
|
||
+ (an ? "Auf diesem Gerät abmelden" : "Auf diesem Gerät anmelden") + "</button>"
|
||
+ '<button type="button" class="btn btn-outline-primary" id="pushProbe"'
|
||
+ (pushStand.abos.length ? "" : " disabled") + '><i class="bi bi-send me-1"></i>Probe schicken</button>'
|
||
+ '<span class="small" id="pushMeldung"></span>'
|
||
+ "</div>"
|
||
+ (blockiert && !an
|
||
? '<div class="alert alert-warning py-2 small">Der Browser hat Benachrichtigungen für '
|
||
+ "diese Seite blockiert. Das lässt sich nur in seinen eigenen Website-Einstellungen "
|
||
+ "wieder erlauben.</div>"
|
||
: "")
|
||
+ (zeilen
|
||
? '<div class="push-abos">' + zeilen + "</div>"
|
||
: '<p class="text-secondary small mb-0">Noch kein Gerät angemeldet.</p>');
|
||
|
||
document.getElementById("pushSchalter").addEventListener("click", pushSchalten);
|
||
document.getElementById("pushProbe").addEventListener("click", pushProbeSchicken);
|
||
ziel.querySelectorAll(".push-weg").forEach(k => k.addEventListener("click", async () => {
|
||
pushMeldung("wird entfernt …");
|
||
try {
|
||
await pushEntfernen(Number(k.dataset.id));
|
||
pushDiesesGeraet = await pushAbo();
|
||
await pushZeichnen();
|
||
} catch (fehler) {
|
||
pushMeldung(fehler.message, "text-danger");
|
||
}
|
||
}));
|
||
}
|
||
|
||
function pushMeldung(text, klasse) {
|
||
const el = document.getElementById("pushMeldung");
|
||
if (!el) return;
|
||
el.className = "small " + (klasse || "text-body-secondary");
|
||
el.textContent = text || "";
|
||
}
|
||
|
||
async function pushSchalten() {
|
||
const an = !!pushDiesesGeraet;
|
||
pushMeldung(an ? "wird abgemeldet …" : "wird angemeldet …");
|
||
try {
|
||
if (an) await pushAbmelden();
|
||
else await pushAnmelden();
|
||
pushDiesesGeraet = await pushAbo();
|
||
await pushZeichnen();
|
||
pushMeldung(an ? "Abgemeldet." : "Angemeldet – die Probe kommt jetzt hier an.",
|
||
an ? "text-body-secondary" : "text-success");
|
||
} catch (fehler) {
|
||
pushMeldung(fehler.message, "text-danger");
|
||
}
|
||
}
|
||
|
||
async function pushProbeSchicken() {
|
||
pushMeldung("Probe unterwegs …");
|
||
try {
|
||
await pushProbe();
|
||
pushMeldung("Probe geschickt – sie geht über den Runner auf der NAS.", "text-success");
|
||
} catch (fehler) {
|
||
pushMeldung(fehler.message, "text-danger");
|
||
}
|
||
}
|
||
|
||
async function pushLaden() {
|
||
try {
|
||
await pushStandLaden();
|
||
pushDiesesGeraet = await pushAbo();
|
||
} catch (fehler) {
|
||
const ziel = document.getElementById("pushListe");
|
||
if (ziel) ziel.innerHTML = '<div class="alert alert-danger mb-0">' + textSchuetzen(fehler.message) + "</div>";
|
||
return;
|
||
}
|
||
await pushZeichnen();
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", function () {
|
||
skodaBinden();
|
||
skodaHolen();
|
||
});
|
||
|
||
function pushBinden() {
|
||
const neu = document.getElementById("pushNeuLaden");
|
||
if (neu) neu.addEventListener("click", pushLaden);
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", function () {
|
||
pushBinden();
|
||
pushLaden();
|
||
});
|