"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; } } }