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 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 16:11:32 +02:00
co-authored by Claude Opus 5
parent 05db809140
commit 149cdb46e1
2 changed files with 208 additions and 5 deletions
+24 -5
View File
@@ -3,8 +3,9 @@
* Befehle an das Fahrzeug. * Befehle an das Fahrzeug.
* *
* Aufruf per POST: befehl=laden_start | laden_stop | klima_start | klima_stop * Aufruf per POST: befehl=laden_start | laden_stop | klima_start | klima_stop
* | lueftung_start | lueftung_stop * | lueftung_start | lueftung_stop | ladeziel
* temperatur=<Grad Celsius> (nur bei klima_start) * temperatur=<Grad Celsius> (nur bei klima_start)
* prozent=50..100 in Zehnern (nur bei ladeziel)
* *
* Warum über den Server und nicht unmittelbar aus dem Browser? Der API- * 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 * 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"], "klima_stop" => ["pfad" => "air-conditioning/stop", "text" => "Klimatisierung gestoppt"],
"lueftung_start" => ["pfad" => "active-ventilation/start", "text" => "Lüftung gestartet"], "lueftung_start" => ["pfad" => "active-ventilation/start", "text" => "Lüftung gestartet"],
"lueftung_stop" => ["pfad" => "active-ventilation/stop", "text" => "Lüftung gestoppt"], "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 */ /* ------------------------------------------------------------------ Zugang */
@@ -79,6 +82,17 @@ if (!isset(BEFEHLE[$name])) {
} }
$befehl = 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(); $konfig = skodaBefehlsSchluessel();
if (!$konfig) { if (!$konfig) {
antwort(503, false, "Kein API-Schlüssel hinterlegt - unter Einstellungen " antwort(503, false, "Kein API-Schlüssel hinterlegt - unter Einstellungen "
@@ -94,6 +108,11 @@ if (!budgetFrei(SKODA_BEFEHLE)) {
/* ---------------------------------------------------------------- Ausführen */ /* ---------------------------------------------------------------- Ausführen */
$rumpf = null; $rumpf = null;
$text = $befehl["text"];
if ($name === "ladeziel") {
$rumpf = ["targetStateOfChargeInPercent" => $prozent];
$text = "Ladeziel " . $prozent . "% gesetzt";
}
if ($name === "klima_start") { if ($name === "klima_start") {
// Die Zieltemperatur kommt aus dem Formular und geht in einen Rumpf, den // Die Zieltemperatur kommt aus dem Formular und geht in einen Rumpf, den
// die API auswertet - deshalb hart begrenzen statt nur zu vertrauen. // die API auswertet - deshalb hart begrenzen statt nur zu vertrauen.
@@ -102,11 +121,11 @@ if ($name === "klima_start") {
$rumpf = ["targetTemperature" => ["value" => $grad, "unit" => "CELSIUS"]]; $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) { if ($status === 202) {
skodaAbrufAnstossen(); 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."); . "in der Anzeige erscheint es in einer halben Minute.");
} }
antwort(200, false, fehlertext($status, $inhalt)); antwort(200, false, fehlertext($status, $inhalt));
@@ -170,7 +189,7 @@ function budgetFrei($grenze)
} }
/** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */ /** 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; $url = SKODA_API . rawurlencode($konfig["vin"]) . "/" . $pfad;
$kopf = [ $kopf = [
@@ -179,7 +198,7 @@ function senden($konfig, $pfad, $rumpf)
]; ];
$ch = curl_init($url); $ch = curl_init($url);
$optionen = [ $optionen = [
CURLOPT_POST => true, CURLOPT_CUSTOMREQUEST => $methode,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => SKODA_KENNUNG, CURLOPT_USERAGENT => SKODA_KENNUNG,
CURLOPT_TIMEOUT => 20, CURLOPT_TIMEOUT => 20,
+184
View File
@@ -632,6 +632,7 @@ function zeichneLive() {
zeichneAbzeichen(w); zeichneAbzeichen(w);
zeichneFahrt(w); zeichneFahrt(w);
zeichneLadequelle(w); zeichneLadequelle(w);
zeichneLadefenster(w);
} }
/* ------------------------------------------------------------- Diagramme */ /* ------------------------------------------------------------- Diagramme */
@@ -1448,6 +1449,10 @@ function koppleSteuerung() {
/** Das Modal mit den Befehlen eines Bereichs füllen und zeigen. */ /** Das Modal mit den Befehlen eines Bereichs füllen und zeigen. */
function zeigeSteuerung(bereich) { function zeigeSteuerung(bereich) {
if (bereich === "laden") {
zeigeLadefenster();
return;
}
const b = STEUERUNG[bereich]; const b = STEUERUNG[bereich];
if (!b || !steuerModal) return; 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 =
'<div class="lade skoda-lade" id="skodaLadefenster">'
+ '<div class="lade-links">'
+ '<div class="lade-status">'
+ '<div class="lade-ring" aria-hidden="true"><svg viewBox="0 0 120 120">'
+ '<circle class="lade-spur" cx="60" cy="60" r="50" pathLength="100"/>'
+ '<circle class="lade-ziel" cx="60" cy="60" r="50" pathLength="100"/>'
+ '<circle class="lade-ist" cx="60" cy="60" r="50" pathLength="100"/>'
+ '</svg><div class="lade-ring-mitte"><b data-sl="soc"></b><span data-sl="ringZiel"></span></div></div>'
+ '<div class="lade-text">'
+ '<div class="lade-auto">Antriebsbatterie <span class="lade-pille" data-sl="zustand"></span></div>'
+ '<div class="lade-gross" data-sl="gross"></div>'
+ '<div class="lade-zeile" data-sl="zeile1"></div>'
+ '<div class="lade-zeile" data-sl="zeile2"></div>'
+ '</div>'
+ '</div>'
+ '<button type="button" class="lade-start" data-sl="start"><span class="lade-punkt"></span><span data-sl="startText">…</span></button>'
+ '<div class="lade-erklaerung" data-sl="erklaerung"></div>'
+ '</div>'
+ '<div class="lade-rechts">'
+ '<div class="lade-abschnitt">'
+ '<h3 id="skodaLadezielTitel">Ladeziel</h3>'
+ '<div class="lade-strom skoda-ziele" role="radiogroup" aria-labelledby="skodaLadezielTitel">'
+ LADEZIELE.map(p => '<button type="button" role="radio" aria-checked="false" data-ziel="' + p + '"><b>' + p + S + '%</b><span>'
+ (p === 80 ? "schonend" : p === 100 ? "Reise" : "") + '</span></button>').join("")
+ '</div>'
+ '<div class="lade-zeile" data-sl="zielHinweis"></div>'
+ '</div>'
+ '<div class="lade-meldung" data-sl="meldung" aria-live="polite"></div>'
+ '</div>'
+ '</div>';
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 + "kW<small>lädt</small>"
: (w.km === null ? "" : nf(w.km) + S + "km<small>Reichweite</small>");
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 */ /* ------------------------------------------------------------------ Start */
function alleLaden() { function alleLaden() {