Skoda: Schluessel in den Einstellungen, Ladungen am Stueck, Wallbox in kW
- neuer Reiter "Fahrzeug": API-Schluessel eintragen und pruefen, ohne dass ein Wert je an den Browser zurueckgeht (restricted/skodaKeys.php) - ajax/skoda.php trennt Ladungen am Zustand statt an 30 Minuten Pause und rahmt sie mit der Zeile davor und danach ein - solarLog_skoda_ladepunkte.sql: Wallbox-Verlauf waehrend einer Ladung - Kachelformat "leistung_kw" fuer Quellen, die schon Kilowatt melden - Steckersymbol versteht evPlug als 1/0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -156,8 +156,12 @@ const homeSVG = {
|
||||
// Solarseite fuer dieselben Zahlen benutzt - eine eigene Regel hier
|
||||
// haette bedeutet, dass dieselbe Wallbox an zwei Stellen verschieden
|
||||
// gerundet dasteht.
|
||||
if (wert.format === "leistung") {
|
||||
const zahl = Number(text);
|
||||
//
|
||||
// "leistung_kw" ist dasselbe fuer Quellen, die schon Kilowatt melden -
|
||||
// die Wallboxen im SolarManager. Umgerechnet wird hier in Watt, damit
|
||||
// es eine Regel fuer beide bleibt.
|
||||
if (wert.format === "leistung" || wert.format === "leistung_kw") {
|
||||
const zahl = Number(text) * (wert.format === "leistung_kw" ? 1000 : 1);
|
||||
if (text === "" || isNaN(zahl)) return null;
|
||||
// Ersetzt wird nur das Trennzeichen: auf der Kachel steht ein
|
||||
// schmales Leerzeichen, im Rest der Anwendung ein gewoehnliches.
|
||||
|
||||
+269
-8
@@ -316,7 +316,7 @@ function kachelQuelle(wert) {
|
||||
* und aus drei Kuerzeln wuerden drei Zeilen.
|
||||
*/
|
||||
function kachelWertKurz(wert, kachel) {
|
||||
const einheit = wert.format === "leistung" ? "W/kW" : (wert.einheit || "");
|
||||
const einheit = istLeistung(wert) ? "W/kW" : (wert.einheit || "");
|
||||
const treffer = wert.wechselrichter ? null : katalogTreffer(wert);
|
||||
let text;
|
||||
|
||||
@@ -568,20 +568,29 @@ function kachelWertZeile(wert, i, kachel) {
|
||||
|
||||
+ '<div class="kw-einheit"><input type="text" class="form-control form-control-sm"'
|
||||
+ ' data-feld="einheit" placeholder="Einheit" maxlength="8"'
|
||||
+ (wert.format === "leistung" ? " disabled" : "")
|
||||
+ (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"'
|
||||
+ (wert.format === "leistung" ? " disabled" : "")
|
||||
+ (istLeistung(wert) ? " disabled" : "")
|
||||
+ ' value="' + (wert.stellen === undefined ? "" : wert.stellen) + '"></div>'
|
||||
|
||||
+ '<div class="kw-symbol">' + kachelSymbolFeld(wert.icon || "") + "</div>"
|
||||
|
||||
+ '<div class="kw-schalter">'
|
||||
+ '<label title="Leistung: skaliert selbst zwischen W und kW">'
|
||||
+ '<input type="checkbox" class="form-check-input" data-feld="format"'
|
||||
+ (wert.format === "leistung" ? " checked" : "") + "> kW</label>"
|
||||
// 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"'
|
||||
@@ -594,6 +603,11 @@ function kachelWertZeile(wert, i, kachel) {
|
||||
+ "</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");
|
||||
@@ -639,8 +653,9 @@ function kachelWertLesen(feldZeile) {
|
||||
const pfad = (hol("pfad") || "").trim();
|
||||
if (pfad) wert.pfad = pfad;
|
||||
}
|
||||
if (hol("format")) {
|
||||
wert.format = "leistung";
|
||||
const format = hol("format");
|
||||
if (format === "leistung" || format === "leistung_kw") {
|
||||
wert.format = format;
|
||||
} else {
|
||||
const einheit = (hol("einheit") || "").trim();
|
||||
if (einheit) wert.einheit = einheit;
|
||||
@@ -1130,3 +1145,249 @@ 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();
|
||||
});
|
||||
|
||||
+36
-1
@@ -649,7 +649,10 @@ function baueKurve() {
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
// "nearest" statt "index": Wallbox-Verlauf und Fahrzeugmeldungen haben
|
||||
// verschiedene Zeitpunkte, der n-te Wert der einen Reihe gehört nicht zum
|
||||
// n-ten der anderen.
|
||||
interaction: { mode: "nearest", axis: "x", intersect: false },
|
||||
scales: {
|
||||
// 'timestack' ist die Zeitachse, die diese Seite mitbringt
|
||||
// (js/chartjs-scale-timestack.min.js). Chart.js' eingebautes 'time'
|
||||
@@ -1151,6 +1154,38 @@ async function ladeKurve() {
|
||||
// Date-Objekten - die nimmt sie stillschweigend nicht an und zeigt
|
||||
// stattdessen den heutigen Tag.
|
||||
const zeit = (p) => new Date(String(p.datetime).replace(" ", "T")).getTime();
|
||||
|
||||
// Mit Wallbox-Verlauf (skoda_ladepunkte): die Leistung im Minutentakt,
|
||||
// der Ladestand dazwischen nach der geflossenen Energie aufgefüllt, und
|
||||
// die echten Meldungen des Fahrzeugs als Punkte darauf - damit sichtbar
|
||||
// bleibt, was gemessen und was abgeleitet ist. Das Fahrzeug meldet beim
|
||||
// Laden mitunter nur einmal in der Stunde; eine Linie durch diese Punkte
|
||||
// allein erfände den Verlauf dazwischen.
|
||||
if (d.verlauf && d.verlauf.length) {
|
||||
const imBild = (m) => zeit(m) >= zeit(d.verlauf[0]) - 600000
|
||||
&& zeit(m) <= zeit(d.verlauf[d.verlauf.length - 1]) + 600000;
|
||||
const meldungen = (d.meldungen || []).filter(imBild);
|
||||
kurveChart.data.datasets = [
|
||||
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
|
||||
borderWidth: 2, pointRadius: 0, fill: "origin", tension: 0,
|
||||
data: d.verlauf.map(p => ({ x: zeit(p), y: +p.kw })) },
|
||||
{ label: "Ladestand, aufgefüllt", yAxisID: "y1", borderColor: "#f0b429", borderWidth: 2,
|
||||
borderDash: [5, 4], pointRadius: 0, tension: 0, spanGaps: false,
|
||||
data: d.verlauf.map(p => ({ x: zeit(p), y: p.soc === null ? null : +p.soc })) },
|
||||
{ label: "Ladestand laut Fahrzeug", yAxisID: "y1", showLine: false,
|
||||
borderColor: "#f0b429", backgroundColor: "#f0b429", pointRadius: 4, pointHoverRadius: 6,
|
||||
data: meldungen.map(m => ({ x: zeit(m), y: +m.soc })) },
|
||||
{ label: "Leistung laut Fahrzeug", yAxisID: "y", showLine: false,
|
||||
borderColor: "#7aa7c7", backgroundColor: "#7aa7c7", pointRadius: 3, pointStyle: "rectRot",
|
||||
data: meldungen.filter(m => m.chg_kw !== null && m.chg_state === "CHARGING")
|
||||
.map(m => ({ x: zeit(m), y: +m.chg_kw })) }
|
||||
];
|
||||
kurveChart.update();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ohne Wallbox-Verlauf - Ladungen von vor der Aufzeichnung - wie bisher
|
||||
// aus den Zeilen der Fahrzeugtabelle.
|
||||
kurveChart.data.datasets = [
|
||||
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
|
||||
borderWidth: 2, pointRadius: 0, fill: "origin", tension: .25,
|
||||
|
||||
@@ -339,7 +339,14 @@ const solarSVG = {
|
||||
} else {
|
||||
setDisplay("heatEG", "none");
|
||||
}
|
||||
if (plugev != "no car") {
|
||||
// solarManager/evPlug kommt als 1 oder 0, wie evPlugOG. Frueher kam
|
||||
// hier der Text der Wallbox an ("no car", "charging", ...); Number()
|
||||
// machte daraus NaN, und NaN != "no car" ist immer wahr - das
|
||||
// Steckersymbol stand dauerhaft da. Den Text weiter zu verstehen
|
||||
// kostet eine Zeile und haelt die Anzeige richtig, egal ob der
|
||||
// Manager schon neu gestartet ist oder nicht.
|
||||
const plugText = mqttData["solarManager/evPlug"];
|
||||
if (isNaN(plugev) ? (plugText !== undefined && plugText !== "no car") : plugev > 0) {
|
||||
setDisplay("evPlug", "");
|
||||
} else {
|
||||
setDisplay("evPlug", "none");
|
||||
|
||||
Reference in New Issue
Block a user