From 149cdb46e1242f377aaebafe01faf6caa40ffa4f Mon Sep 17 00:00:00 2001 From: "m0@nas" Date: Wed, 16 Sep 2026 16:11:32 +0200 Subject: [PATCH] Skoda: Ladefenster im neuen Stil, Ladeziel einstellbar Laden oeffnet jetzt ein Fenster wie auf der Solarseite: Ring mit Ladestand und Ladeziel, ein Knopf zum Starten oder Stoppen, das Ladeziel in Stufen von 50 bis 100 Prozent. skodaCmd.php kennt dafuer den Befehl ladeziel (PUT charging/limit) und prueft die Stufe, bevor ein Befehl aus dem Kontingent draufgeht. Gesendet wird erst, wenn eine Weile keine andere Stufe gewaehlt wurde; bis das Fahrzeug das Ziel meldet, steht die eigene Wahl im Fenster. Ohne Kabel ist der Start gesperrt. Co-Authored-By: Claude Opus 5 --- ajax/skodaCmd.php | 29 +++++-- js/solar/skodaMQTT.js | 184 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 5 deletions(-) diff --git a/ajax/skodaCmd.php b/ajax/skodaCmd.php index b820fa6..701b7d2 100644 --- a/ajax/skodaCmd.php +++ b/ajax/skodaCmd.php @@ -3,8 +3,9 @@ * Befehle an das Fahrzeug. * * Aufruf per POST: befehl=laden_start | laden_stop | klima_start | klima_stop - * | lueftung_start | lueftung_stop + * | lueftung_start | lueftung_stop | ladeziel * temperatur= (nur bei klima_start) + * prozent=50..100 in Zehnern (nur bei ladeziel) * * Warum über den Server und nicht unmittelbar aus dem Browser? Der API- * Schlüssel darf die Seite nicht verlassen. Er steht in skoda.conf neben @@ -62,6 +63,8 @@ const BEFEHLE = [ "klima_stop" => ["pfad" => "air-conditioning/stop", "text" => "Klimatisierung gestoppt"], "lueftung_start" => ["pfad" => "active-ventilation/start", "text" => "Lüftung gestartet"], "lueftung_stop" => ["pfad" => "active-ventilation/stop", "text" => "Lüftung gestoppt"], + // Die API nimmt das Ladeziel per PUT, alle anderen Befehle per POST. + "ladeziel" => ["pfad" => "charging/limit", "methode" => "PUT", "text" => "Ladeziel gesetzt"], ]; /* ------------------------------------------------------------------ Zugang */ @@ -79,6 +82,17 @@ if (!isset(BEFEHLE[$name])) { } $befehl = BEFEHLE[$name]; +// Das Ladeziel vor dem Kontingent pruefen: eine falsche Zahl soll keinen der +// vier Befehle je Stunde kosten. Laut Spec nehmen die Fahrzeuge +// "typischerweise" 50 bis 100 in Zehnern und lehnen alles andere ab. +$prozent = null; +if ($name === "ladeziel") { + $prozent = isset($_POST["prozent"]) ? intval($_POST["prozent"]) : 0; + if ($prozent < 50 || $prozent > 100 || $prozent % 10 !== 0) { + antwort(400, false, "Das Ladeziel geht von 50 bis 100 Prozent in Zehnerschritten."); + } +} + $konfig = skodaBefehlsSchluessel(); if (!$konfig) { antwort(503, false, "Kein API-Schlüssel hinterlegt - unter Einstellungen " @@ -94,6 +108,11 @@ if (!budgetFrei(SKODA_BEFEHLE)) { /* ---------------------------------------------------------------- Ausführen */ $rumpf = null; +$text = $befehl["text"]; +if ($name === "ladeziel") { + $rumpf = ["targetStateOfChargeInPercent" => $prozent]; + $text = "Ladeziel " . $prozent . " % gesetzt"; +} if ($name === "klima_start") { // Die Zieltemperatur kommt aus dem Formular und geht in einen Rumpf, den // die API auswertet - deshalb hart begrenzen statt nur zu vertrauen. @@ -102,11 +121,11 @@ if ($name === "klima_start") { $rumpf = ["targetTemperature" => ["value" => $grad, "unit" => "CELSIUS"]]; } -list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf); +list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf, $befehl["methode"] ?? "POST"); if ($status === 202) { skodaAbrufAnstossen(); - antwort(200, true, $befehl["text"] . ". Das Fahrzeug führt den Befehl aus; " + antwort(200, true, $text . ". Das Fahrzeug führt den Befehl aus; " . "in der Anzeige erscheint es in einer halben Minute."); } antwort(200, false, fehlertext($status, $inhalt)); @@ -170,7 +189,7 @@ function budgetFrei($grenze) } /** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */ -function senden($konfig, $pfad, $rumpf) +function senden($konfig, $pfad, $rumpf, $methode = "POST") { $url = SKODA_API . rawurlencode($konfig["vin"]) . "/" . $pfad; $kopf = [ @@ -179,7 +198,7 @@ function senden($konfig, $pfad, $rumpf) ]; $ch = curl_init($url); $optionen = [ - CURLOPT_POST => true, + CURLOPT_CUSTOMREQUEST => $methode, CURLOPT_RETURNTRANSFER => true, CURLOPT_USERAGENT => SKODA_KENNUNG, CURLOPT_TIMEOUT => 20, diff --git a/js/solar/skodaMQTT.js b/js/solar/skodaMQTT.js index 8f427c3..1b758d2 100644 --- a/js/solar/skodaMQTT.js +++ b/js/solar/skodaMQTT.js @@ -632,6 +632,7 @@ function zeichneLive() { zeichneAbzeichen(w); zeichneFahrt(w); zeichneLadequelle(w); + zeichneLadefenster(w); } /* ------------------------------------------------------------- Diagramme */ @@ -1448,6 +1449,10 @@ function koppleSteuerung() { /** Das Modal mit den Befehlen eines Bereichs füllen und zeigen. */ function zeigeSteuerung(bereich) { + if (bereich === "laden") { + zeigeLadefenster(); + return; + } const b = STEUERUNG[bereich]; if (!b || !steuerModal) return; @@ -1530,6 +1535,185 @@ function verdrahteBefehle(bereich) { }); } +/* ------------------------------------------------------------ Ladefenster */ + +/* + * Laden im Stil der Bedienfenster der Solarseite (.lade in css/solar.css): + * links der Ring mit Ladestand und Ladeziel und der eine grosse Knopf zum + * Starten oder Stoppen, rechts die Wahl des Ladeziels. + * + * Jeder Befehl kostet eine der vier Anfragen je Stunde (skodaCmd.php). Das + * Ladeziel wird deshalb nicht bei jedem Antippen geschickt, sondern erst, + * wenn eine Weile keine andere Stufe mehr gewaehlt wurde. Bis das Fahrzeug + * das neue Ziel meldet - das dauert bis zum naechsten Abruf -, steht die + * eigene Wahl im Fenster und ist als "wird übernommen" gekennzeichnet. + */ +const LADEZIELE = [50, 60, 70, 80, 90, 100]; +const LADEZIEL_WARTEN_MS = 1500; +const LADEZIEL_HALTEN_MS = 15 * 60 * 1000; +let ladezielWunsch = null; // { prozent, seit, gesendet } +let ladezielUhr = null; + +function zeigeLadefenster() { + if (!steuerModal) return; + document.getElementById("modal-title").textContent = "Laden"; + const speichern = document.getElementById("modalSaveBtn"); + if (speichern) speichern.style.display = "none"; + + const S = "\u2009"; + document.getElementById("modal-body").innerHTML = + '
' + + '' + + '
' + + '
' + + '

Ladeziel

' + + '
' + + LADEZIELE.map(p => '').join("") + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + + const fenster = $("skodaLadefenster"); + fenster.querySelectorAll("[data-ziel]").forEach(k => k.addEventListener("click", () => { + ladezielWunsch = { prozent: Number(k.dataset.ziel), seit: Date.now(), gesendet: false }; + zeichneLadefenster(liveWerte()); + clearTimeout(ladezielUhr); + ladezielUhr = setTimeout(ladezielSenden, LADEZIEL_WARTEN_MS); + })); + fenster.querySelector("[data-sl=start]").addEventListener("click", () => { + const w = liveWerte(); + skodaBefehl(w.laedt ? "laden_stop" : "laden_start", {}); + }); + ladefensterMeldung(""); + zeichneLadefenster(liveWerte()); + steuerModal.show(); +} + +function ladefensterMeldung(text, art) { + const m = document.querySelector("#skodaLadefenster [data-sl=meldung]"); + if (!m) return; + m.className = "lade-meldung" + (art ? " " + art : ""); + m.textContent = text || "Jeder Befehl ist einer von vier je Stunde. Was das Fahrzeug übernimmt, erscheint beim nächsten Abruf."; +} + +async function ladezielSenden() { + if (!ladezielWunsch || ladezielWunsch.gesendet) return; + const w = liveWerte(); + // Schon so eingestellt: nichts schicken, keinen Befehl verbrauchen. + if (w.ziel === ladezielWunsch.prozent) { + ladezielWunsch = null; + zeichneLadefenster(w); + return; + } + ladezielWunsch.gesendet = true; + const ok = await skodaBefehl("ladeziel", { prozent: ladezielWunsch.prozent }); + if (!ok) ladezielWunsch = null; + zeichneLadefenster(liveWerte()); +} + +/** Einen Befehl schicken; waehrenddessen ist das Fenster gesperrt. */ +async function skodaBefehl(befehl, extra) { + const fenster = $("skodaLadefenster"); + if (!fenster) return false; + const knoepfe = fenster.querySelectorAll("button"); + fenster.dataset.sendet = "1"; + knoepfe.forEach(k => { k.disabled = true; }); + ladefensterMeldung("Befehl wird gesendet …", "warte"); + let ok = false; + try { + const antwort = await fetch("ajax/skodaCmd.php", { + method: "POST", body: new URLSearchParams(Object.assign({ befehl: befehl }, extra)), cache: "no-store" + }); + const d = await antwort.json(); + ok = !!d.ok; + ladefensterMeldung(d.text, d.ok ? "gut" : "fehler"); + // Der Zustand aendert sich im Fahrzeug, nicht in der Tabelle - der + // Abruf danach bringt ihn. + if (d.ok) setTimeout(ladeLive, 20000); + } catch (e) { + ladefensterMeldung("Der Server hat nicht geantwortet.", "fehler"); + } finally { + delete fenster.dataset.sendet; + knoepfe.forEach(k => { k.disabled = false; }); + zeichneLadefenster(liveWerte()); + } + return ok; +} + +/** Das offene Ladefenster mit den Live-Werten nachziehen. */ +function zeichneLadefenster(w) { + const fenster = $("skodaLadefenster"); + if (!fenster) return; + const feld = name => fenster.querySelector("[data-sl=" + name + "]"); + const S = "\u2009"; + + // Die eigene Wahl gilt, bis das Fahrzeug sie meldet - oder zu lange nicht. + if (ladezielWunsch && (w.ziel === ladezielWunsch.prozent || Date.now() - ladezielWunsch.seit > LADEZIEL_HALTEN_MS)) { + ladezielWunsch = null; + } + const ziel = ladezielWunsch ? ladezielWunsch.prozent : w.ziel; + const soc = w.soc !== null && Number.isFinite(w.soc) ? w.soc : null; + + fenster.querySelector(".lade-ist").style.strokeDasharray = (soc === null ? 0 : Math.max(0, Math.min(100, soc))) + " 100"; + fenster.querySelector(".lade-ziel").style.strokeDasharray = (ziel ? ziel : 0) + " 100"; + feld("soc").textContent = soc === null ? "–" : nf(soc) + S + "%"; + feld("ringZiel").textContent = ziel ? "Ziel " + ziel + S + "%" : ""; + + const zustand = LADEZUSTAND[w.zustand]; + const pille = feld("zustand"); + pille.textContent = zustand ? zustand[0] : ""; + pille.className = "lade-pille" + (w.laedt ? " an" : w.zustand === "CHARGING_INTERRUPTED" ? " warn" : ""); + + feld("gross").innerHTML = w.laedt && w.kw + ? nf(w.kw, 1) + S + "kWlädt" + : (w.km === null ? "–" : nf(w.km) + S + "kmReichweite"); + let fertig = ""; + if (w.laedt && w.restMin > 0) { + const um = new Date(Date.now() + w.restMin * 60000); + fertig = "fertig um " + String(um.getHours()).padStart(2, "0") + ":" + String(um.getMinutes()).padStart(2, "0"); + } + feld("zeile1").textContent = w.laedt ? (w.km === null ? "" : nf(w.km) + S + "km Reichweite") : ""; + feld("zeile2").textContent = fertig; + + // Ohne Kabel lehnt das Fahrzeug den Start ab - und der Versuch kostete + // trotzdem einen der vier Befehle. + const start = feld("start"); + const ohneKabel = !w.laedt && w.zustand === "CONNECT_CABLE"; + start.classList.toggle("laeuft", w.laedt); + start.classList.toggle("aus", ohneKabel); + if (!fenster.dataset.sendet) start.disabled = ohneKabel; + feld("startText").textContent = w.laedt ? "Laden stoppen" : ohneKabel ? "Kabel anstecken" : "Laden starten"; + feld("erklaerung").textContent = w.zustand === "CONNECT_CABLE" + ? "Kein Kabel angesteckt. Das Ladeziel lässt sich trotzdem schon einstellen." + : "Startet oder stoppt das Laden am Fahrzeug. Die Wallbox bleibt davon unberührt – sie gibt weiter frei, was sie freigibt."; + + fenster.querySelectorAll("[data-ziel]").forEach(k => + k.setAttribute("aria-checked", String(Number(k.dataset.ziel) === ziel))); + feld("zielHinweis").textContent = ladezielWunsch + ? (ladezielWunsch.gesendet ? "wird vom Fahrzeug übernommen …" : "wird gleich geschickt …") + : (ziel ? "Das Fahrzeug hört bei " + ziel + S + "% auf zu laden." : "Das Fahrzeug meldet noch kein Ladeziel."); +} + /* ------------------------------------------------------------------ Start */ function alleLaden() {