Files
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

547 lines
20 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}
}