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:
+24
-5
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user