From deb81045d5c6e6f9d95c29162bfaf45ba3475a0c Mon Sep 17 00:00:00 2001 From: "m0@nas" Date: Tue, 15 Sep 2026 09:30:18 +0200 Subject: [PATCH] Skoda: Schluessel in den Einstellungen, Ladungen am Stueck, Wallbox in kW - neuer Reiter "Fahrzeug": API-Schluessel eintragen und pruefen, ohne dass ein Wert je an den Browser zurueckgeht (restricted/skodaKeys.php) - ajax/skoda.php trennt Ladungen am Zustand statt an 30 Minuten Pause und rahmt sie mit der Zeile davor und danach ein - solarLog_skoda_ladepunkte.sql: Wallbox-Verlauf waehrend einer Ladung - Kachelformat "leistung_kw" fuer Quellen, die schon Kilowatt melden - Steckersymbol versteht evPlug als 1/0 Co-Authored-By: Claude Opus 5 --- ajax/settings.php | 24 ++ ajax/skoda.php | 267 ++++++++++++++--- ajax/skodaCmd.php | 112 +++---- css/solar.css | 6 + js/solar/homeMQTT.js | 8 +- js/solar/settings.js | 277 ++++++++++++++++- js/solar/skodaMQTT.js | 37 ++- js/solar/solarMQTT.js | 9 +- restricted/kacheln.php | 16 +- restricted/rooms.php | 7 +- restricted/settings.php | 49 +++ restricted/skodaKeys.php | 546 ++++++++++++++++++++++++++++++++++ solarLog_skoda_ladepunkte.sql | 40 +++ 13 files changed, 1272 insertions(+), 126 deletions(-) create mode 100644 restricted/skodaKeys.php create mode 100644 solarLog_skoda_ladepunkte.sql 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.
"; + return; + } + + const zeilen = skodaModell.felder.map(skodaZeile).join(""); + ziel.innerHTML = + '
' + zeilen + "
" + + (skodaModell.schreibbar ? "" : + '
' + + "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." + + "

"; +} + +function skodaStand(text, klasse) { + const ziel = document.querySelector("#skodaListe .skoda-stand"); + if (!ziel) return; + ziel.className = "skoda-stand small " + (klasse || "text-body-secondary"); + ziel.textContent = text; + clearTimeout(skodaStand._uhr); + if (klasse !== "text-danger") { + skodaStand._uhr = setTimeout(function () { + if (ziel.textContent === text) ziel.textContent = ""; + }, 8000); + } +} + +/* --- Sprechen mit dem Endpunkt ------------------------------------------- */ + +async function skodaFrage(action, koerper) { + const antwort = await fetch("ajax/settings.php?action=" + action, koerper ? { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(koerper), + } : { cache: "no-store" }); + const d = await antwort.json(); + if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status); + return d; +} + +async function skodaHolen() { + const ziel = document.getElementById("skodaListe"); + if (!ziel) return; + try { + skodaModell = await skodaFrage("skoda-keys"); + zeichneSkoda(); + } catch (e) { + ziel.innerHTML = '
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 { + + + + +
+
+
+
+

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. +

+

+ + Schlüssel in der MyŠkoda-App verwalten + +

+
+

Wird geladen …

+
+
+
+
+
+ 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;