diff --git a/ajax/settings.php b/ajax/settings.php
index d6eb583..83fc5be 100644
--- a/ajax/settings.php
+++ b/ajax/settings.php
@@ -10,6 +10,9 @@
* GET ?action=geraete Geraete mit Raum, Verwendung und Alter
* POST ?action=geraete-raeume {"rooms": {"12": "OG/Bad", ...}}
* POST ?action=geraet-loeschen {"id": 12}
+ * GET ?action=skoda-keys Stand der Fahrzeug-Schluessel (ohne Werte!)
+ * POST ?action=skoda-keys-save {"felder": {"API_KEY2": "..."},
+ * "loeschen": ["API_KEY3"]}
*
* Ein reiner Verteiler: geprueft und geschrieben wird in
* restricted/costs.php bzw. restricted/kacheln.php, damit Maske und Endpunkt
@@ -20,6 +23,7 @@ require_once("../helper.php");
require_once("../restricted/costs.php");
require_once("../restricted/kacheln.php");
require_once("../restricted/automations.php");
+require_once("../restricted/skodaKeys.php");
if (!checkLogin()) {
http_response_code(403);
@@ -86,11 +90,23 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$name = geraetLoeschen($body["id"] ?? 0);
jsonAntwort(["geloescht" => $name, "geraete" => geraeteListe()]);
+ case "skoda-keys-save":
+ /*
+ * Neue Schluessel eintragen. Zurueck kommt der neue Stand -
+ * aber nie ein Wert, siehe restricted/skodaKeys.php.
+ */
+ jsonAntwort(skodaSchluesselSpeichern(
+ $body["felder"] ?? [], $body["loeschen"] ?? []));
+
default:
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
}
} catch (InvalidArgumentException $e) {
jsonAntwort(["error" => $e->getMessage()], 400);
+ } catch (RuntimeException $e) {
+ // Nicht die Eingabe, sondern die Umgebung - Datei fehlt, Rechte
+ // fehlen. Die Meldung sagt schon, was zu tun ist; kein Vorspann.
+ jsonAntwort(["error" => $e->getMessage()], 500);
} catch (Throwable $e) {
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
}
@@ -114,6 +130,14 @@ if ($action === "kacheln") {
}
}
+if ($action === "skoda-keys") {
+ try {
+ jsonAntwort(skodaStand());
+ } catch (Throwable $e) {
+ jsonAntwort(["error" => "Schlüssel nicht abrufbar: " . $e->getMessage()], 500);
+ }
+}
+
if ($action === "geraete") {
try {
jsonAntwort(["geraete" => geraeteListe(), "raeume" => raumListe()]);
diff --git a/ajax/skoda.php b/ajax/skoda.php
index 7173326..edae3f2 100644
--- a/ajax/skoda.php
+++ b/ajax/skoda.php
@@ -42,54 +42,100 @@ if ($mysql->connect_errno) {
$mysql->set_charset("utf8mb4");
/*
- * Ladevorgänge aus den Einzelzeilen schneiden. Aufeinanderfolgende Zeilen mit
- * chg_state = 'CHARGING' gehören zusammen, eine Pause über 30 Minuten trennt
- * zwei Vorgänge. Wird von mehreren Abschnitten gebraucht, deshalb einmal hier.
+ * Ladevorgänge aus den Einzelzeilen schneiden. Wird von mehreren Abschnitten
+ * gebraucht, deshalb einmal hier.
+ *
+ * Eine Ladung ist eine ununterbrochene Folge von Zeilen mit chg_state =
+ * 'CHARGING'. Getrennt wird durch eine Zeile mit einem anderen Zustand, nicht
+ * durch die Zeit dazwischen: gatherSkodaData.py schreibt nur, wenn das
+ * Fahrzeug etwas Neues meldet, und beim Laden an der Wallbox tut es das
+ * mitunter nur einmal in der Stunde. Früher trennte eine Pause über 30
+ * Minuten - eine Ladung von 51 auf 80 % am 13.09.2026 zerfiel dadurch in drei
+ * Stücke mit 0, 4 und 0 Prozentpunkten. Hört das Fahrzeug auf zu laden,
+ * steht das in der nächsten Zeile; die Zeit allein trennt nur noch, wenn sie
+ * so lang ist, dass der Abruf offenbar nicht lief (LADUNG_LUECKE_S).
+ *
+ * Dazu gehören die beiden Zeilen, die die Ladung einrahmen. Die davor sagt,
+ * mit welchem Ladestand und Zählerstand sie begann - die erste CHARGING-Zeile
+ * kommt oft erst Minuten später und hat dann schon geladen. Die danach
+ * (CONNECT_CABLE, READY_FOR_CHARGING, ...) trägt den erreichten Ladestand:
+ * bei einer Ladung bis zum Ziel ist das die einzige Zeile mit 80 %.
*
* Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer
* Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
- * Steht der Zähler still, wurde auswärts geladen (Schnellladesäule); dann
- * bleibt wh null und die Kapazitätsrechnung lässt die Ladung aus.
+ * Ein Zählerstand 0 ist kein Stand, sondern ein ausgefallener Abruf der
+ * Wallbox, und bleibt außen vor. Steht der Zähler still, wurde auswärts
+ * geladen (Schnellladesäule); dann bleibt wh null und die Kapazitätsrechnung
+ * lässt die Ladung aus.
*/
+const LADUNG_LUECKE_S = 3 * 3600;
+
function sitzungenSQL($seit)
{
+ $luecke = LADUNG_LUECKE_S;
return "
SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
- MIN(soc) AS soc_von, MAX(soc) AS soc_bis,
- CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc) AS SIGNED) AS zuwachs,
+ MIN(soc_start) AS soc_von, MAX(soc) AS soc_bis,
+ CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc_start) AS SIGNED) AS zuwachs,
MAX(chg_type) AS art, MAX(target_soc) AS ziel,
- ROUND(AVG(chg_kw), 2) AS kw_mittel, ROUND(MAX(chg_kw), 2) AS kw_spitze,
- MAX(wb_wh_total) - MIN(wb_wh_total) AS wh,
- ROUND(SUM(kwh_teil), 3) AS kwh_fahrzeug,
- ROUND(AVG(pv_kw), 2) AS pv_kw, COUNT(*) AS punkte
+ ROUND(AVG(CASE WHEN laedt THEN chg_kw END), 2) AS kw_mittel,
+ ROUND(MAX(CASE WHEN laedt THEN chg_kw END), 2) AS kw_spitze,
+ COALESCE(MAX(NULLIF(wb_wh_total, 0)) - MIN(wh_start), 0) AS wh,
+ /*
+ * Fahrzeugseitige Energie nur, wenn sie lückenlos integriert werden
+ * konnte - siehe kwh_teil. Sonst NULL: eine halbe Summe sähe aus
+ * wie eine kleine Ladung und verfälschte die Kapazitätsrechnung.
+ */
+ CASE WHEN SUM(zu_lang) = 0 THEN ROUND(SUM(kwh_teil), 3) END AS kwh_fahrzeug,
+ ROUND(AVG(CASE WHEN laedt THEN pv_kw END), 2) AS pv_kw,
+ COUNT(*) AS punkte
FROM (
- SELECT s.*, SUM(s.neu) OVER (ORDER BY s.datetime) AS sitzung FROM (
- SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
- wb_wh_total, pv_kw,
- CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
- AND TIMESTAMPDIFF(MINUTE, LAG(datetime) OVER w, datetime) <= 30
- THEN 0 ELSE 1 END AS neu,
+ SELECT b.*, SUM(b.neu) OVER (ORDER BY b.datetime) AS sitzung FROM (
+ SELECT a.*,
+ a.chg_state = 'CHARGING' AS laedt,
+ (a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke) AS weiter,
+ CASE WHEN a.chg_state = 'CHARGING'
+ AND NOT (a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke)
+ THEN 1 ELSE 0 END AS neu,
+ -- Beginn aus der Zeile davor, wenn sie nah genug liegt.
+ CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
+ AND a.dt_vor <= $luecke
+ THEN LEAST(a.soc, COALESCE(a.soc_vor, a.soc))
+ ELSE a.soc END AS soc_start,
+ CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
+ AND a.dt_vor <= $luecke
+ THEN COALESCE(NULLIF(a.wh_vor, 0), NULLIF(a.wb_wh_total, 0))
+ ELSE NULLIF(a.wb_wh_total, 0) END AS wh_start,
/*
- * Fahrzeugseitige Energie: chg_kw ist die Ladeleistung, die
- * das Fahrzeug selbst meldet - gemessen HINTER dem Ladegerät.
- * Die API kennt keine Kilowattstunden, deshalb wird hier über
- * die Zeit integriert, je Schritt mit dem Mittel aus vorigem
- * und aktuellem Wert (Trapez). Beim Laden mit Wechselstrom
- * liegt die Leistung fast konstant an, da ist das genau genug.
+ * chg_kw ist die Ladeleistung, die das Fahrzeug selbst meldet -
+ * gemessen HINTER dem Ladegerät. Die API kennt keine
+ * Kilowattstunden, deshalb wird über die Zeit integriert, je
+ * Schritt mit dem Mittel aus vorigem und aktuellem Wert.
*
- * Die Bedingung hält die Summe innerhalb einer Ladung: über
- * eine Lücke von mehr als 30 Minuten hinweg wäre die Fläche
- * unter der Kurve frei erfunden.
+ * Über mehr als 30 Minuten ohne Meldung wäre die Fläche unter
+ * der Kurve erfunden: die Leistung kann in der Zeit von 4 auf
+ * 10 kW gestiegen sein, ohne dass eine Zeile davon weiß.
+ * zu_lang merkt sich das, und die Ladung bekommt dann keinen
+ * fahrzeugseitigen Wert.
*/
- CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
- AND TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime)
- BETWEEN 1 AND 1800
- THEN (COALESCE(chg_kw, 0) + COALESCE(LAG(chg_kw) OVER w, 0)) / 2
- * TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) / 3600
- ELSE 0 END AS kwh_teil
- FROM skoda WHERE datetime >= $seit
- WINDOW w AS (ORDER BY datetime)) s) t
- WHERE chg_state = 'CHARGING'
+ CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor BETWEEN 1 AND $luecke
+ THEN (COALESCE(a.chg_kw, 0) + COALESCE(a.kw_vor, 0)) / 2
+ * a.dt_vor / 3600
+ ELSE 0 END AS kwh_teil,
+ CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor > 1800
+ THEN 1 ELSE 0 END AS zu_lang
+ FROM (
+ SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
+ wb_wh_total, pv_kw,
+ LAG(chg_state) OVER w AS st_vor,
+ TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) AS dt_vor,
+ LAG(soc) OVER w AS soc_vor,
+ LAG(wb_wh_total) OVER w AS wh_vor,
+ LAG(chg_kw) OVER w AS kw_vor
+ FROM skoda WHERE datetime >= $seit
+ WINDOW w AS (ORDER BY datetime)) a) b) t
+ -- Die Ladezeilen und die eine Zeile, mit der die Ladung endet.
+ WHERE laedt OR weiter
GROUP BY sitzung";
}
@@ -167,6 +213,154 @@ function kapazitaetSchaetzen($mysql)
];
}
+/**
+ * Verlauf einer Ladung: Wallbox-Leistung dicht, Ladestand dazwischen gefüllt.
+ *
+ * Das Fahrzeug meldet beim Laden an der Wallbox mitunter nur einmal in der
+ * Stunde, die Wallbox steht in skoda_ladepunkte im Minutentakt (siehe
+ * solarLog_skoda_ladepunkte.sql). Der Ladestand zwischen zwei Meldungen wird
+ * nach der geflossenen Energie aufgefüllt, nicht nach der Zeit:
+ *
+ * soc(t) = soc_a + (soc_b - soc_a) * (wh(t) - wh_a) / (wh_b - wh_a)
+ *
+ * Das braucht weder Akkukapazität noch Wirkungsgrad. Gegengeprüft am
+ * 13.09.2026: zwischen 56 % (09:59) und 70 % (11:22) ergibt es für 10:29
+ * 59,6 % - gemeldet waren 60 %. Nach der Zeit wären es 61,1 % gewesen.
+ *
+ * Eine Meldung gilt für den Zeitpunkt, zu dem das Fahrzeug sie erfasst hat
+ * (cap_chg), nicht für den Abruf - dazwischen können Minuten mit 11 kW
+ * liegen. Der Zählerstand zu diesem Zeitpunkt kommt deshalb aus dem
+ * Wallbox-Verlauf selbst und nicht aus der Zeile.
+ *
+ * Vor der ersten und nach der letzten Meldung bleibt der Ladestand leer:
+ * hochgerechnet wird nichts.
+ *
+ * Liefert [verlauf, meldungen, wallbox]; ohne Punkte in skoda_ladepunkte
+ * zwei leere Listen - dann zeichnet die Seite wie früher aus den Zeilen.
+ */
+function ladeverlauf($mysql, $von, $bis)
+{
+ // Welche Wallbox: die, die im Zeitraum Energie abgegeben hat.
+ $stmt = $mysql->prepare(
+ "SELECT wallbox, MAX(wh_total) - MIN(wh_total) AS wh, COUNT(*) AS n
+ FROM skoda_ladepunkte
+ WHERE datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
+ GROUP BY wallbox ORDER BY wh DESC, n DESC LIMIT 1");
+ if (!$stmt) {
+ return [[], [], null]; // Tabelle fehlt noch
+ }
+ $stmt->bind_param("ss", $von, $bis);
+ $stmt->execute();
+ $wahl = $stmt->get_result()->fetch_assoc();
+ if (!$wahl) {
+ return [[], [], null];
+ }
+ $wallbox = $wahl["wallbox"];
+ $spalte = $wallbox === "og" ? "wbog_wh_total" : "wb_wh_total";
+
+ $stmt = $mysql->prepare(
+ "SELECT datetime, kw, wh_total, quelle FROM skoda_ladepunkte
+ WHERE wallbox = ? AND datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
+ ORDER BY datetime");
+ $stmt->bind_param("sss", $wallbox, $von, $bis);
+ $stmt->execute();
+ $res = $stmt->get_result();
+ $verlauf = [];
+ while ($z = $res->fetch_assoc()) {
+ $verlauf[] = [
+ "datetime" => $z["datetime"],
+ "t" => strtotime($z["datetime"]),
+ "kw" => floatval($z["kw"]),
+ "wh" => $z["wh_total"] === null ? null : intval($z["wh_total"]),
+ "quelle" => $z["quelle"],
+ ];
+ }
+ if (!$verlauf) {
+ return [[], [], null];
+ }
+
+ // Zählerstand zu einem beliebigen Zeitpunkt, aus dem Verlauf. Vor dem
+ // ersten Punkt ruhte die Wallbox - dann gilt der Stand, den die Zeile
+ // selbst trägt.
+ $zaehlerBei = function ($t, $ersatz) use ($verlauf) {
+ $vor = null;
+ foreach ($verlauf as $p) {
+ if ($p["wh"] === null) {
+ continue;
+ }
+ if ($p["t"] >= $t) {
+ if ($vor === null) {
+ return $ersatz ?: $p["wh"];
+ }
+ $spanne = $p["t"] - $vor["t"];
+ return $spanne > 0
+ ? $vor["wh"] + ($p["wh"] - $vor["wh"]) * ($t - $vor["t"]) / $spanne
+ : $p["wh"];
+ }
+ $vor = $p;
+ }
+ return $vor ? $vor["wh"] : $ersatz;
+ };
+
+ // Die Meldungen des Fahrzeugs, die den Verlauf einrahmen: die letzte vor
+ // dem ersten Punkt, alle dazwischen, die erste nach dem letzten.
+ $erster = $verlauf[0]["datetime"];
+ $letzter = end($verlauf)["datetime"];
+ $stmt = $mysql->prepare(
+ "(SELECT COALESCE(cap_chg, datetime) AS erfasst, soc, chg_kw, chg_state, $spalte AS wh
+ FROM skoda WHERE COALESCE(cap_chg, datetime) <= ? AND soc IS NOT NULL
+ ORDER BY erfasst DESC LIMIT 1)
+ UNION
+ (SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
+ FROM skoda WHERE COALESCE(cap_chg, datetime) > ? AND COALESCE(cap_chg, datetime) < ?
+ AND soc IS NOT NULL)
+ UNION
+ (SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
+ FROM skoda WHERE COALESCE(cap_chg, datetime) >= ? AND soc IS NOT NULL
+ ORDER BY 1 LIMIT 1)
+ ORDER BY erfasst");
+ $stmt->bind_param("ssss", $erster, $erster, $letzter, $letzter);
+ $stmt->execute();
+ $res = $stmt->get_result();
+ $meldungen = [];
+ while ($z = $res->fetch_assoc()) {
+ $t = strtotime($z["erfasst"]);
+ $meldungen[] = [
+ "datetime" => $z["erfasst"],
+ "t" => $t,
+ "soc" => intval($z["soc"]),
+ "chg_kw" => $z["chg_kw"] === null ? null : floatval($z["chg_kw"]),
+ "chg_state"=> $z["chg_state"],
+ "wh" => $zaehlerBei($t, intval($z["wh"])),
+ ];
+ }
+
+ // Ladestand je Punkt, zwischen den beiden Meldungen, die ihn einrahmen.
+ foreach ($verlauf as &$p) {
+ $p["soc"] = null;
+ for ($i = 1; $p["wh"] !== null && $i < count($meldungen); $i++) {
+ $a = $meldungen[$i - 1];
+ $b = $meldungen[$i];
+ if ($p["t"] < $a["t"] || $p["t"] > $b["t"]) {
+ continue;
+ }
+ $spanne = $b["wh"] - $a["wh"];
+ $p["soc"] = $spanne > 0
+ ? round($a["soc"] + ($b["soc"] - $a["soc"]) * ($p["wh"] - $a["wh"]) / $spanne, 1)
+ : floatval($a["soc"]);
+ break;
+ }
+ unset($p["t"]);
+ }
+ unset($p);
+ foreach ($meldungen as &$m) {
+ unset($m["t"], $m["wh"]);
+ }
+ unset($m);
+
+ return [$verlauf, $meldungen, $wallbox];
+}
+
/** Median eines nicht leeren Zahlenfeldes. */
function median($werte)
{
@@ -237,6 +431,9 @@ case "kurve":
while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; }
$antwort["von"] = $von;
$antwort["bis"] = $bis;
+
+ list($antwort["verlauf"], $antwort["meldungen"], $antwort["wallbox"])
+ = ladeverlauf($mysql, $von, $bis);
break;
/* ------------------------------------------------------------ gesundheit -- */
diff --git a/ajax/skodaCmd.php b/ajax/skodaCmd.php
index 5208302..b820fa6 100644
--- a/ajax/skodaCmd.php
+++ b/ajax/skodaCmd.php
@@ -14,28 +14,25 @@
* ein vorausgeladenes Bild oder einen Klick auf eine fremde Seite fernsteuerbar.
*
* ---------------------------------------------------------------------------
- * Zwei Schlüssel, zwei Kontingente
+ * Ein Kontingent für alles
*
- * Die API begrenzt auf 20 Anfragen je Stunde **und Schlüssel**, und Befehle
- * zählen genauso mit wie Abrufe. Steht in skoda.conf ein eigener
- * CMD_API_KEY, hat die Steuerung damit ihr eigenes Kontingent und nimmt dem
- * Abruf des Fahrzeugzustands nichts weg. Beide Schlüssel werden in der
- * MyŠkoda-App angelegt.
+ * Die API begrenzt auf 20 Anfragen je Stunde, und Befehle zählen genauso mit
+ * wie Abrufe. Die Doku schreibt "je Schlüssel" - gemessen stimmt das nicht,
+ * der Zähler gilt für alle Schlüssel des Kontos zusammen (die Rechnung steht
+ * bei _LIMIT in gatherSkodaData.py). Mehrere Schlüssel bringen also kein
+ * zusätzliches Kontingent.
*
- * Ohne eigenen Schlüssel greift dieses Skript auf API_KEY zurück - dann
- * teilen sich beide ein Kontingent, und weil sie in getrennten Prozessen
- * laufen und keinen gemeinsamen Zähler haben, ist es fest aufgeteilt:
+ * Abruf und Steuerung laufen in getrennten Prozessen ohne gemeinsamen Zähler,
+ * deshalb ist fest aufgeteilt:
*
- * eigener CMD_API_KEY nur API_KEY
- * Abruf 18 / Stunde 14 / Stunde
- * Befehle 15 / Stunde 4 / Stunde
- * Reserve 2 / Stunde 2 / Stunde
+ * Abruf 14 / Stunde (gatherSkodaData.py)
+ * Befehle 4 / Stunde
+ * Reserve 2 / Stunde
*
- * gatherSkodaData.py stellt sich selbst um, sobald der zweite Schlüssel in
- * skoda.conf auftaucht - dort ist nichts von Hand nachzuziehen.
- *
- * Der zweite Schlüssel ist kein Freibrief, den Abruf zu beschleunigen: die
- * Kachelrichtlinie zielt auf die Last, nicht auf die Buchführung.
+ * Ein eigener CMD_API_KEY trennt damit nicht die Kontingente, sondern nur,
+ * wer womit fragt - er lässt sich einzeln widerrufen, ohne den Abruf
+ * mitzunehmen. Welchen dieses Skript nimmt, entscheidet
+ * skodaBefehlsSchluessel(): den eigenen, wenn es ihn gibt, sonst API_KEY.
* ---------------------------------------------------------------------------
*
* Nicht angebunden ist die Standheizung. Ihr Start verlangt laut Spec die
@@ -46,24 +43,13 @@
*/
require_once("../helper.php");
+// Wo skoda.conf liegt, wie sie aussieht, welcher Schlüssel die Befehle
+// schickt und wie viele je Stunde erlaubt sind: alles dort, damit der Reiter
+// in den Einstellungen und dieser Endpunkt dieselbe Wahrheit benutzen.
+require_once("../restricted/skodaKeys.php");
header('Content-Type: application/json; charset=utf-8');
-const API_BASIS = "https://public.api.connect.skoda-auto.cz/api/v1/vehicles/";
-const KONFIG = "/volume1/homes/wagner/SolarManager/skoda.conf";
-const KENNUNG = "SmartController-SkodaSteuerung/1.0 (+https://nas.el-wa.org/smart)";
-
-/** Befehle je Stunde - siehe Kopfkommentar. Hängt davon ab, ob die
- * Steuerung einen eigenen Schlüssel hat oder ihn mit dem Abruf teilt. */
-const BEFEHLE_EIGENER_SCHLUESSEL = 15;
-const BEFEHLE_GETEILT = 4;
-
-/** Wo der Stundenzähler liegt. Muss für den Webserver beschreibbar sein. */
-const ZAEHLER_ORTE = [
- "/volume1/web/smart/tiles", // falls per synoacltool freigegeben
- "/var/services/tmp/smart-tiles", // sonst
-];
-
/*
* Die erlaubten Befehle. Der Schlüssel kommt aus dem Formular, der Pfad
* niemals - so kann über den Parameter kein beliebiger Endpunkt der API
@@ -93,18 +79,16 @@ if (!isset(BEFEHLE[$name])) {
}
$befehl = BEFEHLE[$name];
-$konfig = konfigLesen();
+$konfig = skodaBefehlsSchluessel();
if (!$konfig) {
- antwort(503, false, "Kein API-Schlüssel hinterlegt - skoda.conf fehlt.");
+ antwort(503, false, "Kein API-Schlüssel hinterlegt - unter Einstellungen "
+ . "▸ Fahrzeug lässt sich einer eintragen.");
}
-$grenze = $konfig["EIGENER"] ? BEFEHLE_EIGENER_SCHLUESSEL : BEFEHLE_GETEILT;
-if (!budgetFrei($grenze)) {
+if (!budgetFrei(SKODA_BEFEHLE)) {
antwort(429, false, "Stündliches Kontingent für Befehle erschöpft ("
- . $grenze . " je Stunde)."
- . ($konfig["EIGENER"] ? "" : " Ein eigener CMD_API_KEY in skoda.conf"
- . " würde der Steuerung ein eigenes Kontingent geben.")
- . " Später erneut versuchen.");
+ . SKODA_BEFEHLE . " je Stunde). Es teilt sich die 20 Anfragen der API"
+ . " mit dem Abruf des Fahrzeugzustands. Später erneut versuchen.");
}
/* ---------------------------------------------------------------- Ausführen */
@@ -121,8 +105,9 @@ if ($name === "klima_start") {
list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf);
if ($status === 202) {
+ skodaAbrufAnstossen();
antwort(200, true, $befehl["text"] . ". Das Fahrzeug führt den Befehl aus; "
- . "in der Anzeige erscheint es beim nächsten Abruf.");
+ . "in der Anzeige erscheint es in einer halben Minute.");
}
antwort(200, false, fehlertext($status, $inhalt));
@@ -136,39 +121,19 @@ function antwort($code, $ok, $text)
exit;
}
-/** API-Schlüssel und VIN aus skoda.conf lesen. Format: NAME=wert je Zeile. */
-function konfigLesen()
-{
- if (!is_readable(KONFIG)) {
- return null;
- }
- $werte = [];
- foreach (file(KONFIG, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $zeile) {
- $zeile = trim($zeile);
- if ($zeile === "" || $zeile[0] === "#" || strpos($zeile, "=") === false) {
- continue;
- }
- list($k, $v) = explode("=", $zeile, 2);
- $werte[strtoupper(trim($k))] = trim(trim($v), "\"'");
- }
- if (empty($werte["API_KEY"]) || empty($werte["VIN"])) {
- return null;
- }
- // Eigener Schluessel fuer die Steuerung, wenn hinterlegt - sonst der
- // gemeinsame. Welcher es wurde, entscheidet unten ueber das Budget.
- $werte["EIGENER"] = !empty($werte["CMD_API_KEY"]);
- $werte["SCHLUESSEL"] = $werte["EIGENER"] ? $werte["CMD_API_KEY"] : $werte["API_KEY"];
- return $werte;
-}
-
/**
* Stundenzähler für Befehle. Gleiches Verfahren wie die Bremse in tile.php:
* eine kleine Datei, gegen gleichzeitige Zugriffe mit flock abgesichert.
+ *
+ * Das Fenster ist fest: es beginnt mit dem ersten Befehl und läuft eine Stunde.
+ * Die API zählt gleitend, hier stehen also im Grenzfall zwei Fenster
+ * nebeneinander - garantiert sind die $grenze Befehle, mehr geht auf das Risiko
+ * eines 429 von der API selbst.
*/
function budgetFrei($grenze)
{
$ort = null;
- foreach (ZAEHLER_ORTE as $kandidat) {
+ foreach (SKODA_ARBEITSORTE as $kandidat) {
if (is_dir($kandidat) && is_writable($kandidat)) {
$ort = $kandidat;
break;
@@ -180,7 +145,7 @@ function budgetFrei($grenze)
return true;
}
- $griff = fopen($ort . "/.befehle.json", "c+");
+ $griff = fopen($ort . "/" . SKODA_ZAEHLER_DATEI, "c+");
if (!$griff || !flock($griff, LOCK_EX)) {
return true;
}
@@ -207,16 +172,16 @@ function budgetFrei($grenze)
/** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */
function senden($konfig, $pfad, $rumpf)
{
- $url = API_BASIS . rawurlencode($konfig["VIN"]) . "/" . $pfad;
+ $url = SKODA_API . rawurlencode($konfig["vin"]) . "/" . $pfad;
$kopf = [
- "X-API-Key: " . $konfig["SCHLUESSEL"],
+ "X-API-Key: " . $konfig["schluessel"],
"Accept: application/json",
];
$ch = curl_init($url);
$optionen = [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
- CURLOPT_USERAGENT => KENNUNG,
+ CURLOPT_USERAGENT => SKODA_KENNUNG,
CURLOPT_TIMEOUT => 20,
CURLOPT_CONNECTTIMEOUT => 5,
];
@@ -257,7 +222,8 @@ function fehlertext($status, $inhalt)
switch ($status) {
case 0: return "Die Škoda-API ist nicht erreichbar.";
case 400: return "Die API hat den Befehl abgelehnt." . $detail;
- case 401: return "Der API-Schlüssel ist abgelaufen - in der MyŠkoda-App erneuern.";
+ case 401: return "Der API-Schlüssel ist abgelaufen - in der MyŠkoda-App "
+ . "erneuern und unter Einstellungen ▸ Fahrzeug eintragen.";
case 403: return "Der Schlüssel darf diesen Befehl nicht ausführen." . $detail;
case 404: return "Fahrzeug nicht gefunden - stimmt die VIN in skoda.conf?";
case 422: return "Das Fahrzeug kann das gerade nicht." . $detail;
diff --git a/css/solar.css b/css/solar.css
index 2def652..b2a9d8f 100644
--- a/css/solar.css
+++ b/css/solar.css
@@ -2394,6 +2394,12 @@ input[type='range']::-webkit-slider-runnable-track {
gap: .5rem;
white-space: nowrap;
}
+.kachel-wert .kw-schalter .kw-format {
+ width: auto;
+ font-size: .78rem;
+ padding-top: .1rem;
+ padding-bottom: .1rem;
+}
.kachel-wert .kw-schalter label {
display: inline-flex;
align-items: center;
diff --git a/js/solar/homeMQTT.js b/js/solar/homeMQTT.js
index f9bca9e..6597fa4 100644
--- a/js/solar/homeMQTT.js
+++ b/js/solar/homeMQTT.js
@@ -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.
diff --git a/js/solar/settings.js b/js/solar/settings.js
index 1d88880..0513cf1 100644
--- a/js/solar/settings.js
+++ b/js/solar/settings.js
@@ -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) {
+ '
'
+ ''
+ '
' + kachelSymbolFeld(wert.icon || "") + "
"
+ '
'
- + '"
+ // 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.
+ + '"
+ '
";
}
+/** 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 = '
'
+ + "Die Datei " + textSchuetzen(skodaModell.datei) + " gibt es nicht. "
+ + "Sie gehört zum SolarManager und wird dort angelegt – ohne sie läuft auch "
+ + "der Abruf nicht.
'
+ + "Der Webserver darf " + textSchuetzen(skodaModell.datei)
+ + " nicht schreiben. Eintragen lässt sich hier nichts.
")
+ + '
'
+ + '"
+ + ''
+ + "
"
+ + 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
+ ? 'hinterlegt'
+ + ' endet auf ' + textSchuetzen(f.maske) + ""
+ : ''
+ + (f.pflicht ? "fehlt" : "nicht hinterlegt") + "";
+
+ // 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)
+ ? ''
+ : "";
+
+ return '
'
+ + '
'
+ + '"
+ + stand
+ + "
"
+ + '
' + textSchuetzen(f.zweck) + "
"
+ + '
'
+ + ''
+ + ''
+ + weg
+ + "
"
+ + "
";
+}
+
+/** 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 '
'
+ + textSchuetzen(teile.join(" · ")) + " "
+ + "Ein neuer Schlüssel gilt sofort; der Abruf liest die Datei von selbst neu."
+ + "
Schlüssel nicht abrufbar: '
+ + textSchuetzen(e.message) + "
";
+ }
+}
+
+/**
+ * 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();
+});
diff --git a/js/solar/skodaMQTT.js b/js/solar/skodaMQTT.js
index e73ef5b..8f427c3 100644
--- a/js/solar/skodaMQTT.js
+++ b/js/solar/skodaMQTT.js
@@ -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,
diff --git a/js/solar/solarMQTT.js b/js/solar/solarMQTT.js
index ce28334..77625fa 100644
--- a/js/solar/solarMQTT.js
+++ b/js/solar/solarMQTT.js
@@ -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");
diff --git a/restricted/kacheln.php b/restricted/kacheln.php
index b5e711e..481258a 100644
--- a/restricted/kacheln.php
+++ b/restricted/kacheln.php
@@ -14,7 +14,9 @@
* pfad optional: Schlüssel in einer JSON-Nachricht
* wechselrichter Liste von Namen unter solarManager/invertersN (statt topic)
* feld bei Wechselrichtern das Feld, meist p_AC
- * format "leistung" skaliert selbst zwischen W und kW
+ * format "leistung" skaliert selbst zwischen W und kW, die Quelle
+ * liefert Watt; "leistung_kw" dasselbe fuer Quellen, die
+ * schon in Kilowatt melden (die Wallboxen im SolarManager)
* einheit sonst die Einheit, die hinter der Zahl steht
* stellen Nachkommastellen (0-3)
* negativ das Vorzeichen umdrehen - fuer Zaehler, die verkehrt
@@ -43,6 +45,14 @@ require_once(__DIR__ . "/costs.php"); // solarDb()
/** Höchstens drei Werte je Kachel - mehr passt nicht auf 24 x 19 Einheiten. */
const KACHEL_MAX = 3;
+/**
+ * Die Formate, die selbst zwischen W und kW skalieren - je nachdem, in welcher
+ * Einheit die Quelle meldet. Die meisten Zähler schicken Watt; die Wallboxen
+ * im SolarManager (evPower, evPowerOG) schicken Kilowatt, und als Watt gelesen
+ * stand bei 1 kW Ladeleistung "1 W" auf der Kachel.
+ */
+const KACHEL_LEISTUNG = ["leistung", "leistung_kw"];
+
/**
* Vorschläge für das Symbol vor einer Zahl.
*
@@ -326,8 +336,8 @@ function kachelWertPruefen($wert, $nummer)
}
// Entweder das Format rechnet die Einheit selbst aus, oder sie steht da.
- if (($wert["format"] ?? "") === "leistung") {
- $sauber["format"] = "leistung";
+ if (in_array($wert["format"] ?? "", KACHEL_LEISTUNG, true)) {
+ $sauber["format"] = $wert["format"];
} else {
$einheit = trim((string)($wert["einheit"] ?? ""));
if (mb_strlen($einheit) > 8) {
diff --git a/restricted/rooms.php b/restricted/rooms.php
index de1115b..014a2bc 100644
--- a/restricted/rooms.php
+++ b/restricted/rooms.php
@@ -22,7 +22,8 @@
* restricted/kacheln.php): bis zu drei Messwerte, jeder mit
* topic (oder wechselrichter, siehe Carport), optional pfad
* (Schlüssel in einer JSON-Nachricht) und entweder format
- * ("leistung" skaliert selbst zwischen W und kW) oder dem Paar
+ * ("leistung" skaliert selbst zwischen W und kW, "leistung_kw"
+ * ebenso fuer Quellen, die schon Kilowatt melden) oder dem Paar
* einheit und stellen (Nachkommastellen). Fehlt der Eintrag, sind
* es die drei Thermostatwerte - Soll, Ist, Feuchte - wie bisher.
* Eine leere Liste heisst: kein Messwert, die Kachel trägt nur
@@ -131,7 +132,7 @@ $rooms = [
// die man sich vor dem Carport stellt: laedt gerade jemand?
["floor" => "AG", "mqtt" => "Carport OG", "id" => "carport_og", "x" => 72, "y" => 154,
"werte" => [
- ["topic" => "solarManager/evPowerOG", "format" => "leistung",
+ ["topic" => "solarManager/evPowerOG", "format" => "leistung_kw",
"icon" => "ev-station"],
]],
// Veranda und Terrasse gibt es zweimal, je Wohnpartei eine. In der
@@ -159,7 +160,7 @@ $rooms = [
// restricted/config.php. Ohne Kuerzel im Topic ist das die EG-Wallbox.
["floor" => "AG", "mqtt" => "Garage", "id" => "garage", "x" => 131, "y" => 135,
"werte" => [
- ["topic" => "solarManager/evPower", "format" => "leistung",
+ ["topic" => "solarManager/evPower", "format" => "leistung_kw",
"icon" => "ev-station"],
]],
// Der Garten ist kein Bauteil wie die anderen, aber ein Ort: hier haengen
diff --git a/restricted/settings.php b/restricted/settings.php
index abc75cb..5a3c85e 100644
--- a/restricted/settings.php
+++ b/restricted/settings.php
@@ -22,6 +22,9 @@
require_once(__DIR__ . "/costs.php");
require_once(__DIR__ . "/kacheln.php");
require_once(__DIR__ . "/reiter.php");
+// Nur wegen SKODA_APP_LINK - der Weg zu den Schlüsseln gehört neben das Feld,
+// in das sie eingetragen werden, und der Link soll nur an einer Stelle stehen.
+require_once(__DIR__ . "/skodaKeys.php");
/*
* Die Reiter. Reihenfolge ist Anzeigereihenfolge; der erste ist der offene,
@@ -31,6 +34,7 @@ $reiter = [
["id" => "preise", "titel" => "Preise", "symbol" => "bi-currency-euro"],
["id" => "kacheln", "titel" => "Home-Kacheln", "symbol" => "bi-grid-1x2"],
["id" => "geraete", "titel" => "Geräte", "symbol" => "bi-house-gear"],
+ ["id" => "fahrzeug", "titel" => "Fahrzeug", "symbol" => "bi-ev-front"],
];
// "merken": der offene Reiter steht in der Adresse und uebersteht ein
// Neuladen - siehe reiterMerken() in js/solar/common.js.
@@ -192,6 +196,51 @@ try {
= reiterFlaecheZu() ?>
+
+
+
+ = reiterFlaecheAuf("fahrzeug", $reiterOpt) ?>
+
+
+
+
+
Zugang zum Fahrzeug
+
+
+
+
+
+
+ Die Schlüssel werden in der MyŠkoda-App erzeugt, gelten nur für die
+ dort ausgewählten Fahrzeuge und laufen nach einiger Zeit ab. Ein neuer
+ wird hier eingetragen und sofort einmal ausprobiert – was die API
+ ablehnt, landet gar nicht erst in der Konfiguration.
+
+ = reiterFlaecheZu() ?>
diff --git a/restricted/skodaKeys.php b/restricted/skodaKeys.php
new file mode 100644
index 0000000..901c104
--- /dev/null
+++ b/restricted/skodaKeys.php
@@ -0,0 +1,546 @@
+ "API_KEY",
+ "titel" => "Abruf",
+ "pflicht" => true,
+ "zweck" => "Holt den Fahrzeugzustand. Ohne ihn bleibt die "
+ . "Fahrzeugseite leer.",
+ ],
+ [
+ "feld" => "API_KEY2",
+ "titel" => "Abruf, zweiter Schlüssel",
+ "pflicht" => false,
+ "zweck" => "Freiwillig, und kein dichterer Abruf: das Kontingent "
+ . "gilt gemessen fürs ganze Konto. Was er bringt, ist "
+ . "Ausfallsicherheit – läuft einer ab oder wird er "
+ . "widerrufen, fragt der Abruf mit dem nächsten weiter.",
+ ],
+ [
+ "feld" => "API_KEY3",
+ "titel" => "Abruf, dritter Schlüssel",
+ "pflicht" => false,
+ "zweck" => "Wie der zweite. Mehr als drei liest die "
+ . "Konfiguration nicht.",
+ ],
+ [
+ "feld" => "CMD_API_KEY",
+ "titel" => "Befehle",
+ "pflicht" => false,
+ "zweck" => "Für Laden, Klimatisierung und Lüftung. Ohne ihn "
+ . "schickt die Weboberfläche ihre Befehle mit dem "
+ . "ersten Schlüssel. Ein eigener lässt sich einzeln "
+ . "widerrufen, ohne den Abruf mitzunehmen.",
+ ],
+ ];
+}
+
+/* ----------------------------------------------------------------- Lesen -- */
+
+/**
+ * skoda.conf als Zuweisungen lesen. Format: NAME=wert, eine je Zeile.
+ *
+ * Liefert null, wenn die Datei fehlt oder nicht lesbar ist - das ist der
+ * normale Zustand einer Installation ohne Fahrzeug und kein Fehler.
+ */
+function skodaKonfigLesen()
+{
+ if (!is_readable(SKODA_KONFIG)) {
+ return null;
+ }
+ $zeilen = file(SKODA_KONFIG, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
+ if ($zeilen === false) {
+ return null;
+ }
+ $werte = [];
+ foreach ($zeilen as $zeile) {
+ $zeile = trim($zeile);
+ if ($zeile === "" || $zeile[0] === "#" || strpos($zeile, "=") === false) {
+ continue;
+ }
+ list($k, $v) = explode("=", $zeile, 2);
+ $werte[strtoupper(trim($k))] = trim(trim($v), "\"'");
+ }
+ return $werte;
+}
+
+/**
+ * Welcher Schlüssel die Befehle schickt und mit welcher VIN.
+ *
+ * Der eigene, wenn es ihn gibt - sonst der des Abrufs. Liefert null, solange
+ * nichts hinterlegt ist.
+ */
+function skodaBefehlsSchluessel()
+{
+ $werte = skodaKonfigLesen();
+ if (!$werte || empty($werte["API_KEY"]) || empty($werte["VIN"])) {
+ return null;
+ }
+ $eigener = !empty($werte["CMD_API_KEY"]);
+ return [
+ "vin" => $werte["VIN"],
+ "schluessel" => $eigener ? $werte["CMD_API_KEY"] : $werte["API_KEY"],
+ "eigener" => $eigener,
+ ];
+}
+
+/** Die letzten vier Zeichen, mehr darf die Maske nicht wissen. */
+function skodaMaske($wert)
+{
+ $wert = (string)$wert;
+ return strlen($wert) > 4 ? "…" . substr($wert, -4) : "…";
+}
+
+/**
+ * Wann der erste Schlüssel abläuft, laut der letzten Antwort des Fahrzeugs.
+ *
+ * gatherSkodaData.py schreibt bei jedem Abruf mit, was der Header
+ * X-API-Key-Expires-At sagt - bei mehreren Schlüsseln das früheste Datum.
+ * Genauer geht es von hier aus nicht, ohne die API zu fragen, und dafür ist
+ * die Auskunft zu unwichtig.
+ */
+function skodaAblaufAusHistorie()
+{
+ $datum = null;
+ try {
+ $mysql = new mysqli($GLOBALS["mysql_server"], $GLOBALS["mysql_solarUser"],
+ $GLOBALS["mysql_solarPass"], $GLOBALS["mysql_solarDB"]);
+ if ($mysql->connect_errno) {
+ return null;
+ }
+ $res = $mysql->query("SELECT key_expires FROM skoda "
+ . "WHERE key_expires IS NOT NULL ORDER BY id DESC LIMIT 1;");
+ if ($res && ($zeile = $res->fetch_assoc())) {
+ $datum = $zeile["key_expires"];
+ }
+ $mysql->close();
+ } catch (Throwable $e) {
+ return null; // ohne Historie eben ohne Datum
+ }
+ return $datum;
+}
+
+/**
+ * Der Stand für die Maske: was hinterlegt ist, was daraus folgt.
+ *
+ * Ohne Schlüsselwerte - siehe den Kopfkommentar.
+ */
+function skodaStand()
+{
+ $werte = skodaKonfigLesen();
+ $da = is_array($werte);
+
+ $felder = [];
+ $abruf = 0;
+ foreach (skodaFelder() as $f) {
+ $wert = $da && !empty($werte[$f["feld"]]) ? $werte[$f["feld"]] : "";
+ $f["gesetzt"] = $wert !== "";
+ $f["maske"] = $wert !== "" ? skodaMaske($wert) : "";
+ $felder[] = $f;
+ if ($wert !== "" && $f["feld"] !== "CMD_API_KEY") {
+ $abruf++;
+ }
+ }
+
+ return [
+ "datei" => SKODA_KONFIG,
+ "da" => $da,
+ "schreibbar" => $da && is_writable(SKODA_KONFIG),
+ "vin" => $da && !empty($werte["VIN"]) ? skodaMaske($werte["VIN"]) : "",
+ "felder" => $felder,
+ "abruf" => $abruf,
+ // Was gilt - die Zahlen, nach denen man sonst im Quelltext sucht.
+ "takt" => SKODA_TAKT,
+ "befehle" => SKODA_BEFEHLE,
+ "ablauf" => $da ? skodaAblaufAusHistorie() : null,
+ "link" => SKODA_APP_LINK,
+ ];
+}
+
+/* ------------------------------------------------------------- Schreiben -- */
+
+/**
+ * Prüft einen Schlüssel, indem einmal das Fahrzeug abgefragt wird.
+ *
+ * Lieber eine Anfrage jetzt als ein Tippfehler, der erst Minuten später als
+ * Zeile im Log auffällt. Sie kostet eine der 20 Anfragen, die der neue
+ * Schlüssel in seiner ersten Stunde hat - die er ohnehin frisch mitbringt.
+ *
+ * Zurück kommt, was die API sagt, und ihr Ablaufdatum: [ok, status, ablauf,
+ * text].
+ */
+function skodaSchluesselPruefen($schluessel, $vin)
+{
+ $ch = curl_init(SKODA_API . rawurlencode($vin));
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HEADER => true,
+ CURLOPT_USERAGENT => SKODA_KENNUNG,
+ CURLOPT_TIMEOUT => 10,
+ CURLOPT_CONNECTTIMEOUT => 5,
+ CURLOPT_HTTPHEADER => [
+ "X-API-Key: " . $schluessel,
+ "Accept: application/json",
+ ],
+ ]);
+ $inhalt = curl_exec($ch);
+ $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ $kopfEnde = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
+ curl_close($ch);
+
+ if ($inhalt === false) {
+ return ["ok" => false, "status" => 0, "ablauf" => null,
+ "text" => "Die Škoda-API ist nicht erreichbar."];
+ }
+
+ $ablauf = null;
+ foreach (explode("\n", substr($inhalt, 0, $kopfEnde)) as $zeile) {
+ if (stripos($zeile, "X-API-Key-Expires-At:") === 0) {
+ $ablauf = trim(substr($zeile, strlen("X-API-Key-Expires-At:")));
+ }
+ }
+
+ switch ($status) {
+ case 200:
+ return ["ok" => true, "status" => 200, "ablauf" => $ablauf, "text" => ""];
+ case 401:
+ return ["ok" => false, "status" => 401, "ablauf" => null,
+ "text" => "Die API kennt diesen Schlüssel nicht - abgelaufen "
+ . "oder vertippt."];
+ case 403:
+ return ["ok" => false, "status" => 403, "ablauf" => null,
+ "text" => "Der Schlüssel gilt nicht für dieses Fahrzeug. In "
+ . "der MyŠkoda-App muss es beim Anlegen ausgewählt sein."];
+ case 404:
+ return ["ok" => false, "status" => 404, "ablauf" => null,
+ "text" => "Fahrzeug nicht gefunden - stimmt die VIN in "
+ . SKODA_KONFIG . "?"];
+ case 429:
+ // Kein Urteil über den Schlüssel: er ist in Ordnung, nur gerade
+ // ausgeschöpft. Das darf den Tausch nicht verhindern.
+ return ["ok" => true, "status" => 429, "ablauf" => null,
+ "text" => "Kontingent gerade erschöpft - nicht geprüft, "
+ . "aber übernommen."];
+ default:
+ return ["ok" => true, "status" => $status, "ablauf" => $ablauf,
+ "text" => "Unerwartete Antwort (HTTP " . $status . ") - nicht "
+ . "geprüft, aber übernommen."];
+ }
+}
+
+/**
+ * Ein Schlüssel, wie er in einer Zeile NAME=wert stehen darf.
+ *
+ * Entscheidend ist, dass kein Zeilenumbruch hineinkommt: mit einem ließen sich
+ * weitere Zuweisungen unterschieben, etwa eine fremde VIN. Sonst wird bewusst
+ * wenig verlangt - welche Form Škoda seinen Schlüsseln morgen gibt, steht
+ * nicht in unserer Hand.
+ */
+function skodaWertPruefen($feld, $wert)
+{
+ $wert = trim((string)$wert);
+ if ($wert === "") {
+ throw new InvalidArgumentException($feld . ": kein Wert angegeben.");
+ }
+ if (preg_match('/\s/', $wert)) {
+ throw new InvalidArgumentException($feld . ": ein Schlüssel enthält keine "
+ . "Leerzeichen oder Zeilenumbrüche - versehentlich zu viel kopiert?");
+ }
+ if (!preg_match('/^[\x21-\x7e]{8,256}$/', $wert)) {
+ throw new InvalidArgumentException($feld . ": das sieht nicht nach einem "
+ . "Schlüssel aus (8 bis 256 Zeichen, keine Sonderzeichen).");
+ }
+ return $wert;
+}
+
+/**
+ * skoda.conf neu schreiben.
+ *
+ * $aenderungen: Feld => neuer Wert, oder Feld => null zum Entfernen.
+ *
+ * Die Datei bleibt, wie sie ist: jede Zeile, die nicht gemeint ist, geht
+ * unverändert durch - Kommentare, VIN, DB_PASSWORD, Leerzeilen. Ein Feld, das
+ * noch nicht vorkommt, wird angehängt.
+ *
+ * Geschrieben wird daneben und dann umbenannt. Das Umbenennen ist der einzige
+ * Augenblick, in dem sich die Datei ändert; wer sie gerade liest - der Manager
+ * alle paar Minuten, skodaCmd.php bei jedem Befehl - sieht entweder den alten
+ * oder den neuen Stand, nie einen halben.
+ */
+function skodaKonfigSchreiben(array $aenderungen)
+{
+ $roh = @file(SKODA_KONFIG);
+ if ($roh === false) {
+ throw new RuntimeException("Die Datei " . SKODA_KONFIG . " ist nicht lesbar.");
+ }
+
+ $offen = $aenderungen; // was noch nicht untergebracht ist
+ $neu = [];
+ foreach ($roh as $zeile) {
+ $blank = trim($zeile);
+ $name = "";
+ if ($blank !== "" && $blank[0] !== "#" && strpos($blank, "=") !== false) {
+ $name = strtoupper(trim(explode("=", $blank, 2)[0]));
+ }
+ if ($name === "" || !array_key_exists($name, $offen)) {
+ $neu[] = rtrim($zeile, "\r\n");
+ continue;
+ }
+ if ($offen[$name] !== null) {
+ $neu[] = $name . "=" . $offen[$name];
+ } // null: Zeile fällt weg
+ unset($offen[$name]);
+ }
+ foreach ($offen as $name => $wert) {
+ if ($wert !== null) {
+ $neu[] = $name . "=" . $wert;
+ }
+ }
+
+ // Rechte der alten Datei übernehmen: der Manager läuft unter einem anderen
+ // Benutzer und muss sie weiter lesen können.
+ $rechte = @fileperms(SKODA_KONFIG);
+ $rechte = $rechte ? ($rechte & 0777) : 0666;
+
+ $vorlaeufig = SKODA_KONFIG . ".neu." . getmypid();
+ if (@file_put_contents($vorlaeufig, implode("\n", $neu) . "\n") === false) {
+ throw new RuntimeException("Konnte neben " . SKODA_KONFIG . " nicht "
+ . "schreiben - fehlen dem Webserver die Rechte auf das Verzeichnis?");
+ }
+ @chmod($vorlaeufig, $rechte);
+ if (!@rename($vorlaeufig, SKODA_KONFIG)) {
+ @unlink($vorlaeufig);
+ throw new RuntimeException("Konnte " . SKODA_KONFIG . " nicht ersetzen.");
+ }
+}
+
+/**
+ * Neue Schlüssel prüfen und speichern.
+ *
+ * $neu: Feld => Wert. Leere Werte bedeuten "unverändert" und fallen hier
+ * schon weg - ein leeres Feld in der Maske soll nichts löschen.
+ * $loeschen: Felder, die verschwinden sollen.
+ *
+ * Erst prüfen, dann schreiben: ein Schlüssel, den die API ablehnt, kommt gar
+ * nicht erst in die Datei. Sonst stünde der Abruf still, bis es jemand im Log
+ * bemerkt.
+ */
+function skodaSchluesselSpeichern(array $neu, array $loeschen = [])
+{
+ $stand = skodaStand();
+ if (!$stand["da"]) {
+ throw new RuntimeException("Die Datei " . SKODA_KONFIG . " gibt es nicht. "
+ . "Ohne sie läuft auch der Abruf nicht - sie gehört zum SolarManager.");
+ }
+ $erlaubt = array_column(skodaFelder(), "feld");
+ $werte = skodaKonfigLesen();
+ $vin = $werte["VIN"] ?? "";
+ if ($vin === "") {
+ throw new RuntimeException("In " . SKODA_KONFIG . " steht keine VIN - "
+ . "ohne sie lässt sich kein Schlüssel prüfen.");
+ }
+
+ $aenderungen = [];
+ foreach ($neu as $feld => $wert) {
+ $feld = strtoupper((string)$feld);
+ if (!in_array($feld, $erlaubt, true)) {
+ throw new InvalidArgumentException("Unbekanntes Feld: " . $feld);
+ }
+ if (trim((string)$wert) === "") {
+ continue; // leer heißt: so lassen
+ }
+ $aenderungen[$feld] = skodaWertPruefen($feld, $wert);
+ }
+
+ /*
+ * Derselbe Schlüssel zweimal bringt kein zweites Kontingent - die API
+ * zählt den Schlüssel, nicht die Zeile. gatherSkodaData.py wirft ein
+ * Duplikat beim Lesen weg; hier fiele es nur als "zwei Schlüssel" auf,
+ * die den Abruf dann doch nicht beschleunigen.
+ */
+ foreach ($aenderungen as $feld => $wert) {
+ if ($wert === null) {
+ continue;
+ }
+ foreach ($erlaubt as $anderes) {
+ if ($anderes === $feld) {
+ continue;
+ }
+ $schon = array_key_exists($anderes, $aenderungen)
+ ? $aenderungen[$anderes] : ($werte[$anderes] ?? "");
+ if ($schon !== null && $schon !== "" && hash_equals((string)$schon, $wert)) {
+ throw new InvalidArgumentException($feld . ": dieser Schlüssel steht "
+ . "schon als " . $anderes . " da. Ein zweites Mal derselbe bringt "
+ . "kein zweites Kontingent - in der MyŠkoda-App einen neuen anlegen.");
+ }
+ }
+ }
+ foreach ($loeschen as $feld) {
+ $feld = strtoupper((string)$feld);
+ if (!in_array($feld, $erlaubt, true)) {
+ throw new InvalidArgumentException("Unbekanntes Feld: " . $feld);
+ }
+ if ($feld === "API_KEY") {
+ throw new InvalidArgumentException("Ohne den ersten Schlüssel gibt es "
+ . "keinen Abruf mehr. Er lässt sich ersetzen, aber nicht entfernen.");
+ }
+ $aenderungen[$feld] = null;
+ }
+ if (!$aenderungen) {
+ throw new InvalidArgumentException("Nichts einzutragen.");
+ }
+
+ /*
+ * Ab hier wird das Netz befragt, und das kann dauern. helper.php hat die
+ * Sitzung geöffnet und hält sie gesperrt, solange diese Anfrage läuft -
+ * ohne das hier stünde der ganze Browser so lange still, auch die Kacheln
+ * in einem anderen Reiter.
+ */
+ if (session_status() === PHP_SESSION_ACTIVE) {
+ session_write_close();
+ }
+
+ $meldungen = [];
+ foreach ($aenderungen as $feld => $wert) {
+ if ($wert === null) {
+ continue;
+ }
+ $urteil = skodaSchluesselPruefen($wert, $vin);
+ if (!$urteil["ok"]) {
+ throw new InvalidArgumentException($feld . ": " . $urteil["text"]);
+ }
+ if ($urteil["text"] !== "") {
+ $meldungen[] = $feld . ": " . $urteil["text"];
+ } elseif ($urteil["ablauf"]) {
+ $meldungen[] = $feld . " angenommen, gültig bis "
+ . skodaDatumKurz($urteil["ablauf"]) . ".";
+ } else {
+ $meldungen[] = $feld . " angenommen.";
+ }
+ }
+
+ skodaKonfigSchreiben($aenderungen);
+
+ /*
+ * Der Manager liest skoda.conf von selbst neu, sobald sie sich geändert
+ * hat - aber erst, wenn der nächste Abruf ohnehin ansteht, und beim Parken
+ * sind das 20 Minuten. Derselbe Anstoß wie nach einem Befehl holt ihn auf
+ * eine halbe Minute heran; dann greift ein neuer Schlüssel sofort.
+ */
+ skodaAbrufAnstossen();
+
+ return ["meldungen" => $meldungen, "stand" => skodaStand()];
+}
+
+/* ---------------------------------------------------------------- Beiwerk - */
+
+/** Ein API-Datum kurz und deutsch. Unlesbares bleibt, wie es ist. */
+function skodaDatumKurz($iso)
+{
+ $zeit = strtotime((string)$iso);
+ return $zeit ? date("d.m.Y", $zeit) : (string)$iso;
+}
+
+/**
+ * Dem Abruf im solarManager Bescheid geben, dass sich gleich etwas ändert.
+ *
+ * Berührt wird eine leere Datei; gatherSkodaData.py sieht bei jedem seiner
+ * Durchläufe auf ihre Änderungszeit und holt den nächsten Abruf auf
+ * _NACH_BEFEHL Sekunden danach vor. Ein stat alle drei Sekunden ist billiger
+ * als eine Verbindung, die dafür offen bleiben müsste, und es geht auch dann
+ * nichts verloren, wenn einer der beiden Prozesse gerade neu startet.
+ *
+ * Kein Fehler, wenn es nicht klappt: der Befehl ist beim Fahrzeug, nur die
+ * Anzeige zieht dann später nach.
+ */
+function skodaAbrufAnstossen()
+{
+ foreach (SKODA_ARBEITSORTE as $ort) {
+ if (is_dir($ort) && is_writable($ort)) {
+ @touch($ort . "/" . SKODA_ANSTOSS_DATEI);
+ return;
+ }
+ }
+}
diff --git a/solarLog_skoda_ladepunkte.sql b/solarLog_skoda_ladepunkte.sql
new file mode 100644
index 0000000..8a36a62
--- /dev/null
+++ b/solarLog_skoda_ladepunkte.sql
@@ -0,0 +1,40 @@
+-- Verlauf der Wallboxen waehrend einer Ladung.
+--
+-- Das Fahrzeug meldet seinen Stand beim Laden an der Wallbox mitunter nur
+-- einmal in der Stunde (Tabelle skoda). Fuer die Summe einer Ladung genuegt
+-- das - Energie vom Wallbox-Zaehler, Ladestand von der Zeile davor und danach
+-- -, fuer den Verlauf nicht: am 13.09.2026 meldete es um 10:29 noch 5,7 kW und
+-- um 11:22 dann 7,7 kW, waehrend die Wallbox seit 10:54 mit 11 kW lief.
+--
+-- Die Wallboxen dagegen stehen alle drei Sekunden zur Verfuegung. In EnergyFlow
+-- landen sie als Fuenf-Minuten-Werte, aber EnergyFlow wird nach zwoelf Monaten
+-- ausgeduennt (solarlog-rollup.sh thin) - eine Ladekurve vom letzten Sommer
+-- liesse sich dann nicht mehr zeichnen. Deshalb haelt gatherSkodaData.py die
+-- Werte hier fest, solange eine Wallbox Strom liefert, und diese Tabelle wird
+-- nicht ausgeduennt. Bei 60 Sekunden Abstand sind das rund 180 Zeilen je
+-- Ladung, im Jahr grob 25.000.
+--
+-- Der Gesamtzaehler steht in jeder Zeile mit. Zwischen zwei Meldungen des
+-- Fahrzeugs laesst sich der Ladestand damit nach der geflossenen Energie
+-- auffuellen, ohne Akkukapazitaet oder Wirkungsgrad zu kennen:
+--
+-- soc(t) = soc_a + (soc_b - soc_a) * (wh(t) - wh_a) / (wh_b - wh_a)
+--
+-- Beide Wallboxen werden festgehalten, auch wenn die Auswertung heute nur die
+-- im Carport kennt: laedt das Fahrzeug einmal im Obergeschoss, soll der
+-- Verlauf nicht fehlen.
+
+CREATE TABLE IF NOT EXISTS `skoda_ladepunkte` (
+ `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+ `datetime` datetime NOT NULL COMMENT 'Ortszeit',
+ `wallbox` enum('carport','og') NOT NULL COMMENT 'carport = Wattpilot, og = go-e',
+ `kw` decimal(6,3) NOT NULL COMMENT 'Ladeleistung laut Wallbox; 0 = Ende der Ladung',
+ `wh_total` bigint(20) unsigned DEFAULT NULL COMMENT 'Gesamtzaehler der Wallbox; NULL = nicht gelesen',
+ `pv_kw` decimal(7,3) DEFAULT NULL COMMENT 'PV-Leistung im selben Augenblick',
+ `grid_kw` decimal(7,3) DEFAULT NULL COMMENT 'Netz, positiv = Bezug',
+ `quelle` enum('manager','energyflow') NOT NULL DEFAULT 'manager'
+ COMMENT 'energyflow = nachgetragen aus Fuenf-Minuten-Werten, wh_total dann gerechnet',
+ PRIMARY KEY (`id`),
+ KEY `datetime` (`datetime`),
+ KEY `wallbox` (`wallbox`, `datetime`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;