Compare commits

...
2 Commits
Author SHA1 Message Date
adminandClaude Opus 5 59d2a4a55e Automatiken: Rahmen am Vorabend fuer den folgenden Tag
Neuer Schalter "Am Vorabend" im Wann-Band (Spalte next_day). Wochentage,
Ferien und Feiertage gelten dann fuer morgen: "Kinderrollos zu, wenn morgen
Schule ist" ist Mo-Fr, Ferien nie, Feiertage nie - wie der Wecker. Mit dem
heutigen Tag ging das am letzten Ferientag, am Abend vor einem Feiertag und
am Abend eines Feiertags daneben.

Editor-Satz, Kurzfassung und Uebersicht nennen den Vorabend vorne, damit
auch "nicht an Feiertagen" als Folgetag gelesen wird. Die Spalte legt
vorabend.sql im SolarManager an, ausgewertet wird sie im Runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:33:59 +02:00
adminandClaude Opus 5 deb81045d5 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 <noreply@anthropic.com>
2026-09-15 09:30:18 +02:00
17 changed files with 1313 additions and 137 deletions
+15
View File
@@ -200,6 +200,11 @@ if ($action === "list") {
} else { } else {
$wann = "nie – kein Tag ausgewählt"; $wann = "nie – kein Tag ausgewählt";
} }
// Vorabend: alle Tagesangaben meinen morgen. Vorne, damit auch
// "nicht an Feiertagen" weiter hinten als Folgetag gelesen wird.
if ($a["next_day"]) {
$wann = "Vorabend von " . $wann;
}
if ($a["window_from"] !== "00:00" || $a["window_to"] !== "23:59") { if ($a["window_from"] !== "00:00" || $a["window_to"] !== "23:59") {
$wann .= ", " . $a["window_from"] . "–" . $a["window_to"]; $wann .= ", " . $a["window_from"] . "–" . $a["window_to"];
} }
@@ -405,6 +410,16 @@ $nutzdaten = json_encode([
<div class="btn-group btn-group-sm" role="group" id="feiertagGroup"></div> <div class="btn-group btn-group-sm" role="group" id="feiertagGroup"></div>
</div> </div>
</div> </div>
<!-- Vorabend: fuer alles, was abends fuer morgen geschieht - "Kinder
ins Bett, wenn morgen Schule ist" ist dann Mo-Fr, Ferien nie,
Feiertage nie, wie der Wecker. Uhrzeit und Zeitfenster bleiben
beim heutigen Tag. -->
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="nextDay">
<label class="form-check-label" for="nextDay">
Am Vorabend – Wochentage, Ferien und Feiertage gelten für den folgenden Tag
</label>
</div>
<!-- <!--
.callout ist ein AdminLTE-Bauteil: farbiger Balken links, getoenter .callout ist ein AdminLTE-Bauteil: farbiger Balken links, getoenter
Grund, und die Toene kommen aus Bootstraps *-bg-subtle-Variablen - Grund, und die Toene kommen aus Bootstraps *-bg-subtle-Variablen -
+24
View File
@@ -10,6 +10,9 @@
* GET ?action=geraete Geraete mit Raum, Verwendung und Alter * GET ?action=geraete Geraete mit Raum, Verwendung und Alter
* POST ?action=geraete-raeume {"rooms": {"12": "OG/Bad", ...}} * POST ?action=geraete-raeume {"rooms": {"12": "OG/Bad", ...}}
* POST ?action=geraet-loeschen {"id": 12} * 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 * Ein reiner Verteiler: geprueft und geschrieben wird in
* restricted/costs.php bzw. restricted/kacheln.php, damit Maske und Endpunkt * 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/costs.php");
require_once("../restricted/kacheln.php"); require_once("../restricted/kacheln.php");
require_once("../restricted/automations.php"); require_once("../restricted/automations.php");
require_once("../restricted/skodaKeys.php");
if (!checkLogin()) { if (!checkLogin()) {
http_response_code(403); http_response_code(403);
@@ -86,11 +90,23 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$name = geraetLoeschen($body["id"] ?? 0); $name = geraetLoeschen($body["id"] ?? 0);
jsonAntwort(["geloescht" => $name, "geraete" => geraeteListe()]); 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: default:
jsonAntwort(["error" => "Unbekannte Aktion."], 400); jsonAntwort(["error" => "Unbekannte Aktion."], 400);
} }
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
jsonAntwort(["error" => $e->getMessage()], 400); 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) { } catch (Throwable $e) {
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500); 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") { if ($action === "geraete") {
try { try {
jsonAntwort(["geraete" => geraeteListe(), "raeume" => raumListe()]); jsonAntwort(["geraete" => geraeteListe(), "raeume" => raumListe()]);
+232 -35
View File
@@ -42,54 +42,100 @@ if ($mysql->connect_errno) {
$mysql->set_charset("utf8mb4"); $mysql->set_charset("utf8mb4");
/* /*
* Ladevorgänge aus den Einzelzeilen schneiden. Aufeinanderfolgende Zeilen mit * Ladevorgänge aus den Einzelzeilen schneiden. Wird von mehreren Abschnitten
* chg_state = 'CHARGING' gehören zusammen, eine Pause über 30 Minuten trennt * gebraucht, deshalb einmal hier.
* zwei Vorgänge. 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 * Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer
* Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde. * Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
* Steht der Zähler still, wurde auswärts geladen (Schnellladesäule); dann * Ein Zählerstand 0 ist kein Stand, sondern ein ausgefallener Abruf der
* bleibt wh null und die Kapazitätsrechnung lässt die Ladung aus. * 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) function sitzungenSQL($seit)
{ {
$luecke = LADUNG_LUECKE_S;
return " return "
SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende, SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
MIN(soc) AS soc_von, MAX(soc) AS soc_bis, MIN(soc_start) AS soc_von, MAX(soc) AS soc_bis,
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc) AS SIGNED) AS zuwachs, CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc_start) AS SIGNED) AS zuwachs,
MAX(chg_type) AS art, MAX(target_soc) AS ziel, 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, ROUND(AVG(CASE WHEN laedt THEN chg_kw END), 2) AS kw_mittel,
MAX(wb_wh_total) - MIN(wb_wh_total) AS wh, ROUND(MAX(CASE WHEN laedt THEN chg_kw END), 2) AS kw_spitze,
ROUND(SUM(kwh_teil), 3) AS kwh_fahrzeug, COALESCE(MAX(NULLIF(wb_wh_total, 0)) - MIN(wh_start), 0) AS wh,
ROUND(AVG(pv_kw), 2) AS pv_kw, COUNT(*) AS punkte /*
* 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 ( FROM (
SELECT s.*, SUM(s.neu) OVER (ORDER BY s.datetime) AS sitzung FROM ( SELECT b.*, SUM(b.neu) OVER (ORDER BY b.datetime) AS sitzung FROM (
SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc, SELECT a.*,
wb_wh_total, pv_kw, a.chg_state = 'CHARGING' AS laedt,
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING' (a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke) AS weiter,
AND TIMESTAMPDIFF(MINUTE, LAG(datetime) OVER w, datetime) <= 30 CASE WHEN a.chg_state = 'CHARGING'
THEN 0 ELSE 1 END AS neu, 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 * chg_kw ist die Ladeleistung, die das Fahrzeug selbst meldet -
* das Fahrzeug selbst meldet - gemessen HINTER dem Ladegerät. * gemessen HINTER dem Ladegerät. Die API kennt keine
* Die API kennt keine Kilowattstunden, deshalb wird hier über * Kilowattstunden, deshalb wird über die Zeit integriert, je
* die Zeit integriert, je Schritt mit dem Mittel aus vorigem * Schritt mit dem Mittel aus vorigem und aktuellem Wert.
* und aktuellem Wert (Trapez). Beim Laden mit Wechselstrom
* liegt die Leistung fast konstant an, da ist das genau genug.
* *
* Die Bedingung hält die Summe innerhalb einer Ladung: über * Über mehr als 30 Minuten ohne Meldung wäre die Fläche unter
* eine Lücke von mehr als 30 Minuten hinweg wäre die Fläche * der Kurve erfunden: die Leistung kann in der Zeit von 4 auf
* unter der Kurve frei erfunden. * 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' CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor BETWEEN 1 AND $luecke
AND TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) THEN (COALESCE(a.chg_kw, 0) + COALESCE(a.kw_vor, 0)) / 2
BETWEEN 1 AND 1800 * a.dt_vor / 3600
THEN (COALESCE(chg_kw, 0) + COALESCE(LAG(chg_kw) OVER w, 0)) / 2 ELSE 0 END AS kwh_teil,
* TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) / 3600 CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor > 1800
ELSE 0 END AS kwh_teil THEN 1 ELSE 0 END AS zu_lang
FROM skoda WHERE datetime >= $seit FROM (
WINDOW w AS (ORDER BY datetime)) s) t SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
WHERE chg_state = 'CHARGING' 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"; 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. */ /** Median eines nicht leeren Zahlenfeldes. */
function median($werte) function median($werte)
{ {
@@ -237,6 +431,9 @@ case "kurve":
while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; } while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; }
$antwort["von"] = $von; $antwort["von"] = $von;
$antwort["bis"] = $bis; $antwort["bis"] = $bis;
list($antwort["verlauf"], $antwort["meldungen"], $antwort["wallbox"])
= ladeverlauf($mysql, $von, $bis);
break; break;
/* ------------------------------------------------------------ gesundheit -- */ /* ------------------------------------------------------------ gesundheit -- */
+39 -73
View File
@@ -14,28 +14,25 @@
* ein vorausgeladenes Bild oder einen Klick auf eine fremde Seite fernsteuerbar. * 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 * Die API begrenzt auf 20 Anfragen je Stunde, und Befehle zählen genauso mit
* zählen genauso mit wie Abrufe. Steht in skoda.conf ein eigener * wie Abrufe. Die Doku schreibt "je Schlüssel" - gemessen stimmt das nicht,
* CMD_API_KEY, hat die Steuerung damit ihr eigenes Kontingent und nimmt dem * der Zähler gilt für alle Schlüssel des Kontos zusammen (die Rechnung steht
* Abruf des Fahrzeugzustands nichts weg. Beide Schlüssel werden in der * bei _LIMIT in gatherSkodaData.py). Mehrere Schlüssel bringen also kein
* MyŠkoda-App angelegt. * zusätzliches Kontingent.
* *
* Ohne eigenen Schlüssel greift dieses Skript auf API_KEY zurück - dann * Abruf und Steuerung laufen in getrennten Prozessen ohne gemeinsamen Zähler,
* teilen sich beide ein Kontingent, und weil sie in getrennten Prozessen * deshalb ist fest aufgeteilt:
* laufen und keinen gemeinsamen Zähler haben, ist es fest aufgeteilt:
* *
* eigener CMD_API_KEY nur API_KEY * Abruf 14 / Stunde (gatherSkodaData.py)
* Abruf 18 / Stunde 14 / Stunde * Befehle 4 / Stunde
* Befehle 15 / Stunde 4 / Stunde * Reserve 2 / Stunde
* Reserve 2 / Stunde 2 / Stunde
* *
* gatherSkodaData.py stellt sich selbst um, sobald der zweite Schlüssel in * Ein eigener CMD_API_KEY trennt damit nicht die Kontingente, sondern nur,
* skoda.conf auftaucht - dort ist nichts von Hand nachzuziehen. * wer womit fragt - er lässt sich einzeln widerrufen, ohne den Abruf
* * mitzunehmen. Welchen dieses Skript nimmt, entscheidet
* Der zweite Schlüssel ist kein Freibrief, den Abruf zu beschleunigen: die * skodaBefehlsSchluessel(): den eigenen, wenn es ihn gibt, sonst API_KEY.
* Kachelrichtlinie zielt auf die Last, nicht auf die Buchführung.
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
* *
* Nicht angebunden ist die Standheizung. Ihr Start verlangt laut Spec die * Nicht angebunden ist die Standheizung. Ihr Start verlangt laut Spec die
@@ -46,24 +43,13 @@
*/ */
require_once("../helper.php"); 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'); 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 * Die erlaubten Befehle. Der Schlüssel kommt aus dem Formular, der Pfad
* niemals - so kann über den Parameter kein beliebiger Endpunkt der API * niemals - so kann über den Parameter kein beliebiger Endpunkt der API
@@ -93,18 +79,16 @@ if (!isset(BEFEHLE[$name])) {
} }
$befehl = BEFEHLE[$name]; $befehl = BEFEHLE[$name];
$konfig = konfigLesen(); $konfig = skodaBefehlsSchluessel();
if (!$konfig) { 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(SKODA_BEFEHLE)) {
if (!budgetFrei($grenze)) {
antwort(429, false, "Stündliches Kontingent für Befehle erschöpft (" antwort(429, false, "Stündliches Kontingent für Befehle erschöpft ("
. $grenze . " je Stunde)." . SKODA_BEFEHLE . " je Stunde). Es teilt sich die 20 Anfragen der API"
. ($konfig["EIGENER"] ? "" : " Ein eigener CMD_API_KEY in skoda.conf" . " mit dem Abruf des Fahrzeugzustands. Später erneut versuchen.");
. " würde der Steuerung ein eigenes Kontingent geben.")
. " Später erneut versuchen.");
} }
/* ---------------------------------------------------------------- Ausführen */ /* ---------------------------------------------------------------- Ausführen */
@@ -121,8 +105,9 @@ if ($name === "klima_start") {
list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf); list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf);
if ($status === 202) { if ($status === 202) {
skodaAbrufAnstossen();
antwort(200, true, $befehl["text"] . ". Das Fahrzeug führt den Befehl aus; " 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)); antwort(200, false, fehlertext($status, $inhalt));
@@ -136,39 +121,19 @@ function antwort($code, $ok, $text)
exit; 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: * Stundenzähler für Befehle. Gleiches Verfahren wie die Bremse in tile.php:
* eine kleine Datei, gegen gleichzeitige Zugriffe mit flock abgesichert. * 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) function budgetFrei($grenze)
{ {
$ort = null; $ort = null;
foreach (ZAEHLER_ORTE as $kandidat) { foreach (SKODA_ARBEITSORTE as $kandidat) {
if (is_dir($kandidat) && is_writable($kandidat)) { if (is_dir($kandidat) && is_writable($kandidat)) {
$ort = $kandidat; $ort = $kandidat;
break; break;
@@ -180,7 +145,7 @@ function budgetFrei($grenze)
return true; return true;
} }
$griff = fopen($ort . "/.befehle.json", "c+"); $griff = fopen($ort . "/" . SKODA_ZAEHLER_DATEI, "c+");
if (!$griff || !flock($griff, LOCK_EX)) { if (!$griff || !flock($griff, LOCK_EX)) {
return true; return true;
} }
@@ -207,16 +172,16 @@ function budgetFrei($grenze)
/** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */ /** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */
function senden($konfig, $pfad, $rumpf) function senden($konfig, $pfad, $rumpf)
{ {
$url = API_BASIS . rawurlencode($konfig["VIN"]) . "/" . $pfad; $url = SKODA_API . rawurlencode($konfig["vin"]) . "/" . $pfad;
$kopf = [ $kopf = [
"X-API-Key: " . $konfig["SCHLUESSEL"], "X-API-Key: " . $konfig["schluessel"],
"Accept: application/json", "Accept: application/json",
]; ];
$ch = curl_init($url); $ch = curl_init($url);
$optionen = [ $optionen = [
CURLOPT_POST => true, CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => KENNUNG, CURLOPT_USERAGENT => SKODA_KENNUNG,
CURLOPT_TIMEOUT => 20, CURLOPT_TIMEOUT => 20,
CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 5,
]; ];
@@ -257,7 +222,8 @@ function fehlertext($status, $inhalt)
switch ($status) { switch ($status) {
case 0: return "Die Škoda-API ist nicht erreichbar."; case 0: return "Die Škoda-API ist nicht erreichbar.";
case 400: return "Die API hat den Befehl abgelehnt." . $detail; 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 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 404: return "Fahrzeug nicht gefunden - stimmt die VIN in skoda.conf?";
case 422: return "Das Fahrzeug kann das gerade nicht." . $detail; case 422: return "Das Fahrzeug kann das gerade nicht." . $detail;
+6
View File
@@ -2394,6 +2394,12 @@ input[type='range']::-webkit-slider-runnable-track {
gap: .5rem; gap: .5rem;
white-space: nowrap; 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 { .kachel-wert .kw-schalter label {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+1
View File
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS `automations` (
`weekdays` tinyint(3) unsigned NOT NULL DEFAULT 127 COMMENT 'Bitmaske: Bit 0 = Montag ... Bit 6 = Sonntag', `weekdays` tinyint(3) unsigned NOT NULL DEFAULT 127 COMMENT 'Bitmaske: Bit 0 = Montag ... Bit 6 = Sonntag',
`on_vacation` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch in den Ferien ausfuehren', `on_vacation` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch in den Ferien ausfuehren',
`on_holiday` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch an Feiertagen ausfuehren', `on_holiday` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch an Feiertagen ausfuehren',
`next_day` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Wochentage, Ferien und Feiertage gelten fuer morgen (Vorabend). Nachgeruestet mit vorabend.sql im SolarManager',
`force_once` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Am Ende des Zeitraums auf jeden Fall ausfuehren', `force_once` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Am Ende des Zeitraums auf jeden Fall ausfuehren',
`lockout_secs` int(11) NOT NULL DEFAULT 60 COMMENT 'Sperrzeit: so lange nach einer Ausloesung wird nicht erneut geschaltet. Gegen Messwerte, die um die Schwelle pendeln - jedes Ueberschreiten waere sonst eine echte Flanke. Der Editor bietet 0, 60 und 900 an', `lockout_secs` int(11) NOT NULL DEFAULT 60 COMMENT 'Sperrzeit: so lange nach einer Ausloesung wird nicht erneut geschaltet. Gegen Messwerte, die um die Schwelle pendeln - jedes Ueberschreiten waere sonst eine echte Flanke. Der Editor bietet 0, 60 und 900 an',
`cond_met` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'War die Bedingung beim letzten Durchlauf erfuellt? Nur die steigende Flanke loest aus, sonst wuerde Temperatur groesser 22 im Sekundentakt feuern', `cond_met` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'War die Bedingung beim letzten Durchlauf erfuellt? Nur die steigende Flanke loest aus, sonst wuerde Temperatur groesser 22 im Sekundentakt feuern',
+10 -1
View File
@@ -1100,6 +1100,9 @@ function rahmenText() {
if (dazu.length && tage !== 127) satz += ", dazu " + aufzaehlung(dazu); if (dazu.length && tage !== 127) satz += ", dazu " + aufzaehlung(dazu);
} }
if (verbote.length) satz += ", aber nicht " + aufzaehlung(verbote); if (verbote.length) satz += ", aber nicht " + aufzaehlung(verbote);
// Vorabend: derselbe Satz, nur fuer morgen gerechnet. Vorne, damit auch
// "aber nicht an Feiertagen" als Folgetag gelesen wird.
if (autoModel.next_day) satz = satz.replace(/^Läuft /, "Läuft am Vortag, gerechnet für den Folgetag: ");
return satz + "."; return satz + ".";
} }
@@ -1112,6 +1115,7 @@ function rahmenText() {
*/ */
function rahmenKurzText() { function rahmenKurzText() {
const teile = [tageText(autoModel.weekdays) || "an keinem Tag"]; const teile = [tageText(autoModel.weekdays) || "an keinem Tag"];
if (autoModel.next_day) teile[0] = "Vorabend von " + teile[0];
if (autoModel.on_vacation === 0) teile.push("nicht in den Ferien"); if (autoModel.on_vacation === 0) teile.push("nicht in den Ferien");
if (autoModel.on_vacation === 2) teile.push("dazu Ferientage"); if (autoModel.on_vacation === 2) teile.push("dazu Ferientage");
if (autoModel.on_holiday === 0) teile.push("nicht an Feiertagen"); if (autoModel.on_holiday === 0) teile.push("nicht an Feiertagen");
@@ -1133,6 +1137,7 @@ function rahmenIstVorgabe() {
return autoModel.weekdays === 127 return autoModel.weekdays === 127
&& autoModel.on_vacation === 1 && autoModel.on_vacation === 1
&& autoModel.on_holiday === 1 && autoModel.on_holiday === 1
&& !autoModel.next_day
&& autoModel.window_from === "00:00" && autoModel.window_from === "00:00"
&& autoModel.window_to === "23:59" && autoModel.window_to === "23:59"
&& !autoModel.once_per_day && !autoModel.once_per_day
@@ -1179,6 +1184,7 @@ function fuelleFormular() {
document.getElementById("tSpanTo").value = autoModel.window_to; document.getElementById("tSpanTo").value = autoModel.window_to;
document.getElementById("runOnce").checked = autoModel.force_once; document.getElementById("runOnce").checked = autoModel.force_once;
document.getElementById("oncePerDay").checked = autoModel.once_per_day; document.getElementById("oncePerDay").checked = autoModel.once_per_day;
document.getElementById("nextDay").checked = autoModel.next_day;
renderRahmen(); renderRahmen();
rahmenFelderVerdrahten(); rahmenFelderVerdrahten();
renderConditions(); renderConditions();
@@ -1197,6 +1203,7 @@ function leseFormular() {
// sofort nachziehen muss. // sofort nachziehen muss.
autoModel.force_once = document.getElementById("runOnce").checked; autoModel.force_once = document.getElementById("runOnce").checked;
autoModel.once_per_day = document.getElementById("oncePerDay").checked; autoModel.once_per_day = document.getElementById("oncePerDay").checked;
autoModel.next_day = document.getElementById("nextDay").checked;
autoModel.lockout_secs = Number(document.getElementById("lockoutSecs").value); autoModel.lockout_secs = Number(document.getElementById("lockoutSecs").value);
} }
@@ -1205,7 +1212,7 @@ function leseFormular() {
* Ohne das stuende dort "rund um die Uhr", waehrend darunter 08:45 steht. * Ohne das stuende dort "rund um die Uhr", waehrend darunter 08:45 steht.
*/ */
function rahmenFelderVerdrahten() { function rahmenFelderVerdrahten() {
["tSpanFrom", "tSpanTo", "lockoutSecs", "runOnce", "oncePerDay"].forEach(id => { ["tSpanFrom", "tSpanTo", "lockoutSecs", "runOnce", "oncePerDay", "nextDay"].forEach(id => {
const feld = document.getElementById(id); const feld = document.getElementById(id);
if (feld) feld.addEventListener("change", () => { leseFormular(); renderRahmen(); }); if (feld) feld.addEventListener("change", () => { leseFormular(); renderRahmen(); });
}); });
@@ -1250,6 +1257,7 @@ function loadAutomatic() {
weekdays: a.weekdays, weekdays: a.weekdays,
on_vacation: a.on_vacation, on_vacation: a.on_vacation,
on_holiday: a.on_holiday, on_holiday: a.on_holiday,
next_day: !!a.next_day,
force_once: a.force_once, force_once: a.force_once,
once_per_day: a.once_per_day, once_per_day: a.once_per_day,
lockout_secs: a.lockout_secs, lockout_secs: a.lockout_secs,
@@ -1338,6 +1346,7 @@ function submitAutoAction() {
weekdays: autoModel.weekdays, weekdays: autoModel.weekdays,
on_vacation: autoModel.on_vacation, on_vacation: autoModel.on_vacation,
on_holiday: autoModel.on_holiday, on_holiday: autoModel.on_holiday,
next_day: autoModel.next_day,
force_once: autoModel.force_once, force_once: autoModel.force_once,
once_per_day: autoModel.once_per_day, once_per_day: autoModel.once_per_day,
lockout_secs: autoModel.lockout_secs, lockout_secs: autoModel.lockout_secs,
+6 -2
View File
@@ -156,8 +156,12 @@ const homeSVG = {
// Solarseite fuer dieselben Zahlen benutzt - eine eigene Regel hier // Solarseite fuer dieselben Zahlen benutzt - eine eigene Regel hier
// haette bedeutet, dass dieselbe Wallbox an zwei Stellen verschieden // haette bedeutet, dass dieselbe Wallbox an zwei Stellen verschieden
// gerundet dasteht. // 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; if (text === "" || isNaN(zahl)) return null;
// Ersetzt wird nur das Trennzeichen: auf der Kachel steht ein // Ersetzt wird nur das Trennzeichen: auf der Kachel steht ein
// schmales Leerzeichen, im Rest der Anwendung ein gewoehnliches. // schmales Leerzeichen, im Rest der Anwendung ein gewoehnliches.
+269 -8
View File
@@ -316,7 +316,7 @@ function kachelQuelle(wert) {
* und aus drei Kuerzeln wuerden drei Zeilen. * und aus drei Kuerzeln wuerden drei Zeilen.
*/ */
function kachelWertKurz(wert, kachel) { 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); const treffer = wert.wechselrichter ? null : katalogTreffer(wert);
let text; let text;
@@ -568,20 +568,29 @@ function kachelWertZeile(wert, i, kachel) {
+ '<div class="kw-einheit"><input type="text" class="form-control form-control-sm"' + '<div class="kw-einheit"><input type="text" class="form-control form-control-sm"'
+ ' data-feld="einheit" placeholder="Einheit" maxlength="8"' + ' data-feld="einheit" placeholder="Einheit" maxlength="8"'
+ (wert.format === "leistung" ? " disabled" : "") + (istLeistung(wert) ? " disabled" : "")
+ ' value="' + textSchuetzen(wert.einheit || "") + '"></div>' + ' value="' + textSchuetzen(wert.einheit || "") + '"></div>'
+ '<div class="kw-stellen"><input type="number" class="form-control form-control-sm"' + '<div class="kw-stellen"><input type="number" class="form-control form-control-sm"'
+ ' data-feld="stellen" min="0" max="3" placeholder="0"' + ' data-feld="stellen" min="0" max="3" placeholder="0"'
+ (wert.format === "leistung" ? " disabled" : "") + (istLeistung(wert) ? " disabled" : "")
+ ' value="' + (wert.stellen === undefined ? "" : wert.stellen) + '"></div>' + ' value="' + (wert.stellen === undefined ? "" : wert.stellen) + '"></div>'
+ '<div class="kw-symbol">' + kachelSymbolFeld(wert.icon || "") + "</div>" + '<div class="kw-symbol">' + kachelSymbolFeld(wert.icon || "") + "</div>"
+ '<div class="kw-schalter">' + '<div class="kw-schalter">'
+ '<label title="Leistung: skaliert selbst zwischen W und kW">' // Leistung: die Anzeige skaliert selbst zwischen W und kW. Gewählt wird
+ '<input type="checkbox" class="form-check-input" data-feld="format"' // nur, in welcher Einheit die Quelle meldet - die Wallboxen schicken kW,
+ (wert.format === "leistung" ? " checked" : "") + "> kW</label>" // die Zähler W. Früher ein Häkchen, das stillschweigend Watt annahm.
+ '<select class="form-select form-select-sm kw-format" data-feld="format"'
+ ' title="Leistung: in welcher Einheit die Quelle meldet. Angezeigt wird'
+ ' dann selbst in W oder kW.">'
+ '<option value=""' + (istLeistung(wert) ? "" : " selected") + '>Einheit</option>'
+ '<option value="leistung"' + (wert.format === "leistung" ? " selected" : "")
+ '>Leistung aus W</option>'
+ '<option value="leistung_kw"' + (wert.format === "leistung_kw" ? " selected" : "")
+ '>Leistung aus kW</option>'
+ "</select>"
+ '<label title="Vorzeichen umdrehen - für Zähler, die verkehrt herum' + '<label title="Vorzeichen umdrehen - für Zähler, die verkehrt herum'
+ ' eingebaut sind">' + ' eingebaut sind">'
+ '<input type="checkbox" class="form-check-input" data-feld="negativ"' + '<input type="checkbox" class="form-check-input" data-feld="negativ"'
@@ -594,6 +603,11 @@ function kachelWertZeile(wert, i, kachel) {
+ "</div></div>"; + "</div></div>";
} }
/** 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. */ /** Fremden Text sicher ins Markup bringen - Topics kommen aus der Datenbank. */
function textSchuetzen(s) { function textSchuetzen(s) {
const d = document.createElement("div"); const d = document.createElement("div");
@@ -639,8 +653,9 @@ function kachelWertLesen(feldZeile) {
const pfad = (hol("pfad") || "").trim(); const pfad = (hol("pfad") || "").trim();
if (pfad) wert.pfad = pfad; if (pfad) wert.pfad = pfad;
} }
if (hol("format")) { const format = hol("format");
wert.format = "leistung"; if (format === "leistung" || format === "leistung_kw") {
wert.format = format;
} else { } else {
const einheit = (hol("einheit") || "").trim(); const einheit = (hol("einheit") || "").trim();
if (einheit) wert.einheit = einheit; if (einheit) wert.einheit = einheit;
@@ -1130,3 +1145,249 @@ document.addEventListener("DOMContentLoaded", function () {
geraeteBinden(); geraeteBinden();
geraeteHolen(); 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 = '<div class="alert alert-warning mb-0">'
+ "Die Datei <code>" + textSchuetzen(skodaModell.datei) + "</code> gibt es nicht. "
+ "Sie gehört zum SolarManager und wird dort angelegt – ohne sie läuft auch "
+ "der Abruf nicht.</div>";
return;
}
const zeilen = skodaModell.felder.map(skodaZeile).join("");
ziel.innerHTML =
'<div class="skoda-felder">' + zeilen + "</div>"
+ (skodaModell.schreibbar ? "" :
'<div class="alert alert-warning mt-3 mb-0 py-2 small">'
+ "Der Webserver darf <code>" + textSchuetzen(skodaModell.datei)
+ "</code> nicht schreiben. Eintragen lässt sich hier nichts.</div>")
+ '<div class="d-flex flex-wrap align-items-center gap-3 mt-3">'
+ '<button type="button" class="btn btn-primary" id="skodaSpeichern"'
+ (skodaModell.schreibbar ? "" : " disabled") + ">Eintragen</button>"
+ '<span class="skoda-stand small text-body-secondary"></span>'
+ "</div>"
+ 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
? '<span class="badge text-bg-success">hinterlegt</span>'
+ ' <span class="text-body-secondary">endet auf ' + textSchuetzen(f.maske) + "</span>"
: '<span class="badge text-bg-secondary">'
+ (f.pflicht ? "fehlt" : "nicht hinterlegt") + "</span>";
// 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)
? '<button type="button" class="btn btn-outline-danger btn-sm skoda-weg"'
+ ' data-feld="' + textSchuetzen(f.feld) + '" title="Diesen Schlüssel entfernen">'
+ '<i class="bi bi-trash"></i></button>'
: "";
return '<div class="border-top py-3 skoda-feld" data-feld="' + textSchuetzen(f.feld) + '">'
+ '<div class="d-flex flex-wrap align-items-baseline gap-2">'
+ '<label class="fw-semibold mb-0" for="' + kennung + '">'
+ textSchuetzen(f.titel) + "</label>"
+ stand
+ "</div>"
+ '<p class="text-body-secondary small mb-2">' + textSchuetzen(f.zweck) + "</p>"
+ '<div class="input-group input-group-sm">'
+ '<input type="password" class="form-control" id="' + kennung + '"'
+ ' autocomplete="off" spellcheck="false"'
+ ' placeholder="' + (f.gesetzt ? "neuer Schlüssel – leer lassen, um ihn zu behalten"
: "Schlüssel einfügen") + '">'
+ '<button type="button" class="btn btn-outline-secondary skoda-zeigen"'
+ ' title="Anzeigen"><i class="bi bi-eye"></i></button>'
+ weg
+ "</div>"
+ "</div>";
}
/** 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 '<p class="text-body-secondary small mt-3 mb-0">'
+ textSchuetzen(teile.join(" · ")) + "<br>"
+ "Ein neuer Schlüssel gilt sofort; der Abruf liest die Datei von selbst neu."
+ "</p>";
}
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 = '<div class="alert alert-danger mb-0">Schlüssel nicht abrufbar: '
+ textSchuetzen(e.message) + "</div>";
}
}
/**
* 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();
});
+36 -1
View File
@@ -649,7 +649,10 @@ function baueKurve() {
options: { options: {
maintainAspectRatio: false, maintainAspectRatio: false,
animation: 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: { scales: {
// 'timestack' ist die Zeitachse, die diese Seite mitbringt // 'timestack' ist die Zeitachse, die diese Seite mitbringt
// (js/chartjs-scale-timestack.min.js). Chart.js' eingebautes 'time' // (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 // Date-Objekten - die nimmt sie stillschweigend nicht an und zeigt
// stattdessen den heutigen Tag. // stattdessen den heutigen Tag.
const zeit = (p) => new Date(String(p.datetime).replace(" ", "T")).getTime(); 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 = [ kurveChart.data.datasets = [
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)", { label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
borderWidth: 2, pointRadius: 0, fill: "origin", tension: .25, borderWidth: 2, pointRadius: 0, fill: "origin", tension: .25,
+8 -1
View File
@@ -339,7 +339,14 @@ const solarSVG = {
} else { } else {
setDisplay("heatEG", "none"); 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", ""); setDisplay("evPlug", "");
} else { } else {
setDisplay("evPlug", "none"); setDisplay("evPlug", "none");
+15 -10
View File
@@ -803,6 +803,7 @@ function emptyAutomation($floor)
"weekdays" => 127, "weekdays" => 127,
"on_vacation" => 1, "on_vacation" => 1,
"on_holiday" => 1, "on_holiday" => 1,
"next_day" => false,
"force_once" => false, "force_once" => false,
"once_per_day" => false, "once_per_day" => false,
"lockout_secs" => 60, "lockout_secs" => 60,
@@ -835,6 +836,9 @@ function loadAutomation($id)
"weekdays" => intval($row["weekdays"]), "weekdays" => intval($row["weekdays"]),
"on_vacation" => intval($row["on_vacation"]), "on_vacation" => intval($row["on_vacation"]),
"on_holiday" => intval($row["on_holiday"]), "on_holiday" => intval($row["on_holiday"]),
// Vorabend: der Rahmen gilt fuer morgen. Siehe vorabend.sql im
// SolarManager; ?? 0 wie bei once_per_day, falls die Spalte fehlt.
"next_day" => (bool)($row["next_day"] ?? 0),
"force_once" => (bool)$row["force_once"], "force_once" => (bool)$row["force_once"],
// ?? 0, damit der Editor auch vor der Migration noch aufgeht: // ?? 0, damit der Editor auch vor der Migration noch aufgeht:
// rahmen_erweitern.sql legt die Spalte erst an. // rahmen_erweitern.sql legt die Spalte erst an.
@@ -925,6 +929,7 @@ function saveAutomation($data)
$weekdays = intval($data["weekdays"] ?? 127) & 127; $weekdays = intval($data["weekdays"] ?? 127) & 127;
$onVacation = kalendertagWert($data["on_vacation"] ?? 1); $onVacation = kalendertagWert($data["on_vacation"] ?? 1);
$onHoliday = kalendertagWert($data["on_holiday"] ?? 1); $onHoliday = kalendertagWert($data["on_holiday"] ?? 1);
$nextDay = !empty($data["next_day"]) ? 1 : 0;
$forceOnce = !empty($data["force_once"]) ? 1 : 0; $forceOnce = !empty($data["force_once"]) ? 1 : 0;
$oncePerDay = !empty($data["once_per_day"]) ? 1 : 0; $oncePerDay = !empty($data["once_per_day"]) ? 1 : 0;
// Nur die angebotenen Stufen, sonst die Vorgabe. Eine krumme Zahl kaeme // Nur die angebotenen Stufen, sonst die Vorgabe. Eine krumme Zahl kaeme
@@ -957,12 +962,12 @@ function saveAutomation($data)
// Zeitstempel, dass er sein Regelwerk neu laden muss. // Zeitstempel, dass er sein Regelwerk neu laden muss.
$stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?, $stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?,
window_from = ?, window_to = ?, weekdays = ?, window_from = ?, window_to = ?, weekdays = ?,
on_vacation = ?, on_holiday = ?, force_once = ?, on_vacation = ?, on_holiday = ?, next_day = ?, force_once = ?,
once_per_day = ?, lockout_secs = ?, changed = NOW() once_per_day = ?, lockout_secs = ?, changed = NOW()
WHERE id = ?"); WHERE id = ?");
$stmt->bind_param("ssissiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo, $stmt->bind_param("ssissiiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $forceOnce, $oncePerDay, $weekdays, $onVacation, $onHoliday, $nextDay, $forceOnce,
$lockout, $id); $oncePerDay, $lockout, $id);
$stmt->execute(); $stmt->execute();
$stmt->close(); $stmt->close();
$db->query("DELETE FROM automation_conditions WHERE automation_id = " . $id); $db->query("DELETE FROM automation_conditions WHERE automation_id = " . $id);
@@ -970,12 +975,12 @@ function saveAutomation($data)
} else { } else {
$stmt = $db->prepare("INSERT INTO automations $stmt = $db->prepare("INSERT INTO automations
(name, floor, enabled, window_from, window_to, weekdays, (name, floor, enabled, window_from, window_to, weekdays,
on_vacation, on_holiday, force_once, once_per_day, on_vacation, on_holiday, next_day, force_once,
lockout_secs) once_per_day, lockout_secs)
VALUES (?,?,?,?,?,?,?,?,?,?,?)"); VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
$stmt->bind_param("ssissiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo, $stmt->bind_param("ssissiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $forceOnce, $oncePerDay, $weekdays, $onVacation, $onHoliday, $nextDay, $forceOnce,
$lockout); $oncePerDay, $lockout);
$stmt->execute(); $stmt->execute();
$id = $db->insert_id; $id = $db->insert_id;
$stmt->close(); $stmt->close();
+13 -3
View File
@@ -14,7 +14,9 @@
* pfad optional: Schlüssel in einer JSON-Nachricht * pfad optional: Schlüssel in einer JSON-Nachricht
* wechselrichter Liste von Namen unter solarManager/invertersN (statt topic) * wechselrichter Liste von Namen unter solarManager/invertersN (statt topic)
* feld bei Wechselrichtern das Feld, meist p_AC * 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 * einheit sonst die Einheit, die hinter der Zahl steht
* stellen Nachkommastellen (0-3) * stellen Nachkommastellen (0-3)
* negativ das Vorzeichen umdrehen - fuer Zaehler, die verkehrt * 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. */ /** Höchstens drei Werte je Kachel - mehr passt nicht auf 24 x 19 Einheiten. */
const KACHEL_MAX = 3; 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. * 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. // Entweder das Format rechnet die Einheit selbst aus, oder sie steht da.
if (($wert["format"] ?? "") === "leistung") { if (in_array($wert["format"] ?? "", KACHEL_LEISTUNG, true)) {
$sauber["format"] = "leistung"; $sauber["format"] = $wert["format"];
} else { } else {
$einheit = trim((string)($wert["einheit"] ?? "")); $einheit = trim((string)($wert["einheit"] ?? ""));
if (mb_strlen($einheit) > 8) { if (mb_strlen($einheit) > 8) {
+4 -3
View File
@@ -22,7 +22,8 @@
* restricted/kacheln.php): bis zu drei Messwerte, jeder mit * restricted/kacheln.php): bis zu drei Messwerte, jeder mit
* topic (oder wechselrichter, siehe Carport), optional pfad * topic (oder wechselrichter, siehe Carport), optional pfad
* (Schlüssel in einer JSON-Nachricht) und entweder format * (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 * einheit und stellen (Nachkommastellen). Fehlt der Eintrag, sind
* es die drei Thermostatwerte - Soll, Ist, Feuchte - wie bisher. * es die drei Thermostatwerte - Soll, Ist, Feuchte - wie bisher.
* Eine leere Liste heisst: kein Messwert, die Kachel trägt nur * 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? // die man sich vor dem Carport stellt: laedt gerade jemand?
["floor" => "AG", "mqtt" => "Carport OG", "id" => "carport_og", "x" => 72, "y" => 154, ["floor" => "AG", "mqtt" => "Carport OG", "id" => "carport_og", "x" => 72, "y" => 154,
"werte" => [ "werte" => [
["topic" => "solarManager/evPowerOG", "format" => "leistung", ["topic" => "solarManager/evPowerOG", "format" => "leistung_kw",
"icon" => "ev-station"], "icon" => "ev-station"],
]], ]],
// Veranda und Terrasse gibt es zweimal, je Wohnpartei eine. In der // 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. // restricted/config.php. Ohne Kuerzel im Topic ist das die EG-Wallbox.
["floor" => "AG", "mqtt" => "Garage", "id" => "garage", "x" => 131, "y" => 135, ["floor" => "AG", "mqtt" => "Garage", "id" => "garage", "x" => 131, "y" => 135,
"werte" => [ "werte" => [
["topic" => "solarManager/evPower", "format" => "leistung", ["topic" => "solarManager/evPower", "format" => "leistung_kw",
"icon" => "ev-station"], "icon" => "ev-station"],
]], ]],
// Der Garten ist kein Bauteil wie die anderen, aber ein Ort: hier haengen // Der Garten ist kein Bauteil wie die anderen, aber ein Ort: hier haengen
+49
View File
@@ -22,6 +22,9 @@
require_once(__DIR__ . "/costs.php"); require_once(__DIR__ . "/costs.php");
require_once(__DIR__ . "/kacheln.php"); require_once(__DIR__ . "/kacheln.php");
require_once(__DIR__ . "/reiter.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, * Die Reiter. Reihenfolge ist Anzeigereihenfolge; der erste ist der offene,
@@ -31,6 +34,7 @@ $reiter = [
["id" => "preise", "titel" => "Preise", "symbol" => "bi-currency-euro"], ["id" => "preise", "titel" => "Preise", "symbol" => "bi-currency-euro"],
["id" => "kacheln", "titel" => "Home-Kacheln", "symbol" => "bi-grid-1x2"], ["id" => "kacheln", "titel" => "Home-Kacheln", "symbol" => "bi-grid-1x2"],
["id" => "geraete", "titel" => "Geräte", "symbol" => "bi-house-gear"], ["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 // "merken": der offene Reiter steht in der Adresse und uebersteht ein
// Neuladen - siehe reiterMerken() in js/solar/common.js. // Neuladen - siehe reiterMerken() in js/solar/common.js.
@@ -192,6 +196,51 @@ try {
</div> </div>
</div> </div>
<?= reiterFlaecheZu() ?><!-- /Geräte --> <?= reiterFlaecheZu() ?><!-- /Geräte -->
<!-- =================================================== Fahrzeug ==== -->
<!-- Die Zugangsschlüssel der MyŠkoda-Anbindung. Sie laufen ab und
müssen dann getauscht werden - vorher hieß das: per SSH auf die NAS
und skoda.conf im Editor öffnen.
Die Zeilen zeichnet js/solar/settings.js aus dem, was
ajax/settings.php?action=skoda-keys liefert. Was dort NICHT
mitkommt, 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. Zu sehen ist nur, ob einer
hinterlegt ist und auf welche vier Zeichen er endet. -->
<?= reiterFlaecheAuf("fahrzeug", $reiterOpt) ?>
<div class="row">
<div class="col-12 col-xxl-8">
<div class="card mb-4" id="skodaKarte">
<div class="card-header pb-2 pt-2">
<h3 class="card-title"><i class="bi bi-key me-2"></i>Zugang zum Fahrzeug</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" id="skodaNeuLaden" title="Neu laden">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</div>
</div>
<div class="card-body">
<p class="text-secondary small mb-3">
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.
</p>
<p class="mb-3">
<a class="btn btn-sm btn-outline-primary"
href="<?= SKODA_APP_LINK ?>" target="_blank" rel="noopener">
<i class="bi bi-box-arrow-up-right me-1"></i>Schlüssel in der MyŠkoda-App verwalten
</a>
</p>
<div id="skodaListe">
<p class="text-secondary small mb-0">Wird geladen …</p>
</div>
</div>
</div>
</div>
</div>
<?= reiterFlaecheZu() ?><!-- /Fahrzeug -->
</div><!-- /tab-content --> </div><!-- /tab-content -->
</div> </div>
+546
View File
@@ -0,0 +1,546 @@
<?php
/**
* Die Zugangsschlüssel der MyŠkoda-Anbindung.
*
* Eine Stelle für alles, was skoda.conf betrifft: wo die Datei liegt, wie sie
* gelesen wird, was davon die Oberfläche sehen darf und wie ein neuer
* Schlüssel hineinkommt. Vorher wusste ajax/skodaCmd.php das für sich allein;
* mit dem Reiter in den Einstellungen wären es zwei Fassungen geworden, die
* sich beim ersten Formatwechsel unterscheiden.
*
* Dieselbe Datei liest gatherSkodaData.py im SolarManager. Dass das Format
* dort ein zweites Mal steht, lässt sich nicht vermeiden - zwei Sprachen,
* zwei Repositorien. skoda.conf.example daneben ist die gemeinsame
* Beschreibung, und sie ist die Stelle, an der ein neues Feld zuerst
* auftauchen sollte.
*
* ---------------------------------------------------------------------------
* Ein Schlüssel geht nie an den Browser zurück
*
* Die Seite steht im Heimnetz ohne Anmeldung offen (isLocal() in helper.php),
* und so ein Schlüssel steuert das Fahrzeug von überall auf der Welt - Laden,
* Klimatisierung, Standort. Wer ihn abliest, braucht das Heimnetz danach nicht
* mehr. Die Maske bekommt deshalb nur zu sehen, ob ein Schlüssel hinterlegt
* ist und auf welche vier Zeichen er endet; das genügt, um zwei
* auseinanderzuhalten, und taugt zu nichts sonst.
*
* Geschrieben wird nur in eine Richtung: ein neuer Wert kommt herein, der alte
* kommt nie heraus.
* ---------------------------------------------------------------------------
*/
/** Die Datei mit den Zugangsdaten. Liegt außerhalb des Webverzeichnisses; sie
* gehört zum SolarManager, der sie ebenfalls liest.
*
* Mit define() davor umzuhängen - ein Prüflauf arbeitet dann auf einer Kopie
* und nicht auf den echten Zugangsdaten. */
defined("SKODA_KONFIG") || define("SKODA_KONFIG", "/volume1/homes/wagner/SolarManager/skoda.conf");
/** Basis der öffentlichen API. Ein GET darauf prüft einen Schlüssel. */
const SKODA_API = "https://public.api.connect.skoda-auto.cz/api/v1/vehicles/";
const SKODA_KENNUNG = "SmartController-Skoda/1.0 (+https://nas.el-wa.org/smart)";
/** Wo die Schlüssel herkommen - der Link gehört in die Maske. */
const SKODA_APP_LINK = "https://go.skoda.eu/api-keys";
/** Befehle je Stunde.
*
* Das Kontingent der API - 20 je Stunde - gilt gemessen fürs ganze Konto und
* nicht je Schlüssel (die Rechnung steht bei _LIMIT in gatherSkodaData.py).
* Abruf und Steuerung laufen in getrennten Prozessen ohne gemeinsamen
* Zähler, also ist fest aufgeteilt: 14 für den Abruf, 4 für Befehle, 2
* Reserve. Ein eigener CMD_API_KEY ändert daran nichts mehr. */
const SKODA_BEFEHLE = 4;
/** Verzeichnisse für kleine Merkzettel zwischen Weboberfläche und Manager.
* Das erste, das der Webserver beschreiben darf, gewinnt. Dieselbe Liste in
* derselben Reihenfolge steht als _ANSTOSS_ORTE in gatherSkodaData.py. */
const SKODA_ARBEITSORTE = [
"/volume1/web/smart/tiles", // falls per synoacltool freigegeben
"/var/services/tmp/smart-tiles", // sonst
];
/** Name der Anstoßdatei - siehe skodaAbrufAnstossen(). */
const SKODA_ANSTOSS_DATEI = ".skoda_abruf";
/** Name des Stundenzählers für Befehle - siehe budgetFrei() in skodaCmd.php. */
const SKODA_ZAEHLER_DATEI = ".befehle.json";
/** Abstand zweier Abrufe beim Laden und während der Fahrt. Dieselbe Zahl
* steht als _I_LADEN in gatherSkodaData.py; hier nur, um sie anzuzeigen.
* Weitere Schlüssel machen sie nicht kleiner - siehe SKODA_BEFEHLE. */
const SKODA_TAKT = 260;
/**
* Die Schlüssel, die skoda.conf kennen darf, in Anzeigereihenfolge.
*
* Als Funktion und nicht als Konstante, weil jeder Eintrag seinen Satz
* Erklärung mitbringt - die steht in der Maske und soll nicht dort noch
* einmal getippt werden.
*/
function skodaFelder()
{
return [
[
"feld" => "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;
}
}
}
+40
View File
@@ -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;