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>
This commit is contained in:
2026-09-15 09:30:18 +02:00
co-authored by Claude Opus 5
parent b01e938905
commit deb81045d5
13 changed files with 1272 additions and 126 deletions
+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;
}
}
}