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
+13 -3
View File
@@ -14,7 +14,9 @@
* pfad optional: Schlüssel in einer JSON-Nachricht
* wechselrichter Liste von Namen unter solarManager/invertersN (statt topic)
* feld bei Wechselrichtern das Feld, meist p_AC
* format "leistung" skaliert selbst zwischen W und kW
* format "leistung" skaliert selbst zwischen W und kW, die Quelle
* liefert Watt; "leistung_kw" dasselbe fuer Quellen, die
* schon in Kilowatt melden (die Wallboxen im SolarManager)
* einheit sonst die Einheit, die hinter der Zahl steht
* stellen Nachkommastellen (0-3)
* negativ das Vorzeichen umdrehen - fuer Zaehler, die verkehrt
@@ -43,6 +45,14 @@ require_once(__DIR__ . "/costs.php"); // solarDb()
/** Höchstens drei Werte je Kachel - mehr passt nicht auf 24 x 19 Einheiten. */
const KACHEL_MAX = 3;
/**
* Die Formate, die selbst zwischen W und kW skalieren - je nachdem, in welcher
* Einheit die Quelle meldet. Die meisten Zähler schicken Watt; die Wallboxen
* im SolarManager (evPower, evPowerOG) schicken Kilowatt, und als Watt gelesen
* stand bei 1 kW Ladeleistung "1 W" auf der Kachel.
*/
const KACHEL_LEISTUNG = ["leistung", "leistung_kw"];
/**
* Vorschläge für das Symbol vor einer Zahl.
*
@@ -326,8 +336,8 @@ function kachelWertPruefen($wert, $nummer)
}
// Entweder das Format rechnet die Einheit selbst aus, oder sie steht da.
if (($wert["format"] ?? "") === "leistung") {
$sauber["format"] = "leistung";
if (in_array($wert["format"] ?? "", KACHEL_LEISTUNG, true)) {
$sauber["format"] = $wert["format"];
} else {
$einheit = trim((string)($wert["einheit"] ?? ""));
if (mb_strlen($einheit) > 8) {
+4 -3
View File
@@ -22,7 +22,8 @@
* restricted/kacheln.php): bis zu drei Messwerte, jeder mit
* topic (oder wechselrichter, siehe Carport), optional pfad
* (Schlüssel in einer JSON-Nachricht) und entweder format
* ("leistung" skaliert selbst zwischen W und kW) oder dem Paar
* ("leistung" skaliert selbst zwischen W und kW, "leistung_kw"
* ebenso fuer Quellen, die schon Kilowatt melden) oder dem Paar
* einheit und stellen (Nachkommastellen). Fehlt der Eintrag, sind
* es die drei Thermostatwerte - Soll, Ist, Feuchte - wie bisher.
* Eine leere Liste heisst: kein Messwert, die Kachel trägt nur
@@ -131,7 +132,7 @@ $rooms = [
// die man sich vor dem Carport stellt: laedt gerade jemand?
["floor" => "AG", "mqtt" => "Carport OG", "id" => "carport_og", "x" => 72, "y" => 154,
"werte" => [
["topic" => "solarManager/evPowerOG", "format" => "leistung",
["topic" => "solarManager/evPowerOG", "format" => "leistung_kw",
"icon" => "ev-station"],
]],
// Veranda und Terrasse gibt es zweimal, je Wohnpartei eine. In der
@@ -159,7 +160,7 @@ $rooms = [
// restricted/config.php. Ohne Kuerzel im Topic ist das die EG-Wallbox.
["floor" => "AG", "mqtt" => "Garage", "id" => "garage", "x" => 131, "y" => 135,
"werte" => [
["topic" => "solarManager/evPower", "format" => "leistung",
["topic" => "solarManager/evPower", "format" => "leistung_kw",
"icon" => "ev-station"],
]],
// Der Garten ist kein Bauteil wie die anderen, aber ein Ort: hier haengen
+49
View File
@@ -22,6 +22,9 @@
require_once(__DIR__ . "/costs.php");
require_once(__DIR__ . "/kacheln.php");
require_once(__DIR__ . "/reiter.php");
// Nur wegen SKODA_APP_LINK - der Weg zu den Schlüsseln gehört neben das Feld,
// in das sie eingetragen werden, und der Link soll nur an einer Stelle stehen.
require_once(__DIR__ . "/skodaKeys.php");
/*
* Die Reiter. Reihenfolge ist Anzeigereihenfolge; der erste ist der offene,
@@ -31,6 +34,7 @@ $reiter = [
["id" => "preise", "titel" => "Preise", "symbol" => "bi-currency-euro"],
["id" => "kacheln", "titel" => "Home-Kacheln", "symbol" => "bi-grid-1x2"],
["id" => "geraete", "titel" => "Geräte", "symbol" => "bi-house-gear"],
["id" => "fahrzeug", "titel" => "Fahrzeug", "symbol" => "bi-ev-front"],
];
// "merken": der offene Reiter steht in der Adresse und uebersteht ein
// Neuladen - siehe reiterMerken() in js/solar/common.js.
@@ -192,6 +196,51 @@ try {
</div>
</div>
<?= 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>
+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;
}
}
}