(nur bei klima_start) * * Warum über den Server und nicht unmittelbar aus dem Browser? Der API- * Schlüssel darf die Seite nicht verlassen. Er steht in skoda.conf neben * gatherSkodaData.py, wird hier gelesen und geht nur von hier nach draußen. * * Nur POST. Ein Befehl, der sich über einen Link auslösen liesse, wäre über * ein vorausgeladenes Bild oder einen Klick auf eine fremde Seite fernsteuerbar. * * --------------------------------------------------------------------------- * Ein Kontingent für alles * * Die API begrenzt auf 20 Anfragen je Stunde, und Befehle zählen genauso mit * wie Abrufe. Die Doku schreibt "je Schlüssel" - gemessen stimmt das nicht, * der Zähler gilt für alle Schlüssel des Kontos zusammen (die Rechnung steht * bei _LIMIT in gatherSkodaData.py). Mehrere Schlüssel bringen also kein * zusätzliches Kontingent. * * Abruf und Steuerung laufen in getrennten Prozessen ohne gemeinsamen Zähler, * deshalb ist fest aufgeteilt: * * Abruf 14 / Stunde (gatherSkodaData.py) * Befehle 4 / Stunde * Reserve 2 / Stunde * * Ein eigener CMD_API_KEY trennt damit nicht die Kontingente, sondern nur, * wer womit fragt - er lässt sich einzeln widerrufen, ohne den Abruf * mitzunehmen. Welchen dieses Skript nimmt, entscheidet * skodaBefehlsSchluessel(): den eigenen, wenn es ihn gibt, sonst API_KEY. * --------------------------------------------------------------------------- * * Nicht angebunden ist die Standheizung. Ihr Start verlangt laut Spec die * Sicherheits-PIN des Fahrzeugs ("spin"). Die dauerhaft auf dem NAS zu * hinterlegen, nur damit ein Knopf im Dashboard funktioniert, ist eine andere * Größenordnung als ein API-Schlüssel - das sollte bewusst entschieden werden, * nicht nebenbei. */ 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'); /* * Die erlaubten Befehle. Der Schlüssel kommt aus dem Formular, der Pfad * niemals - so kann über den Parameter kein beliebiger Endpunkt der API * angesprochen werden. */ const BEFEHLE = [ "laden_start" => ["pfad" => "charging/start", "text" => "Laden gestartet"], "laden_stop" => ["pfad" => "charging/stop", "text" => "Laden gestoppt"], "klima_start" => ["pfad" => "air-conditioning/start", "text" => "Klimatisierung gestartet"], "klima_stop" => ["pfad" => "air-conditioning/stop", "text" => "Klimatisierung gestoppt"], "lueftung_start" => ["pfad" => "active-ventilation/start", "text" => "Lüftung gestartet"], "lueftung_stop" => ["pfad" => "active-ventilation/stop", "text" => "Lüftung gestoppt"], ]; /* ------------------------------------------------------------------ Zugang */ if (!checkLogin()) { antwort(403, false, "Nicht angemeldet."); } if ($_SERVER["REQUEST_METHOD"] !== "POST") { antwort(405, false, "Nur per POST."); } $name = isset($_POST["befehl"]) ? $_POST["befehl"] : ""; if (!isset(BEFEHLE[$name])) { antwort(400, false, "Unbekannter Befehl."); } $befehl = BEFEHLE[$name]; $konfig = skodaBefehlsSchluessel(); if (!$konfig) { antwort(503, false, "Kein API-Schlüssel hinterlegt - unter Einstellungen " . "▸ Fahrzeug lässt sich einer eintragen."); } if (!budgetFrei(SKODA_BEFEHLE)) { antwort(429, false, "Stündliches Kontingent für Befehle erschöpft (" . SKODA_BEFEHLE . " je Stunde). Es teilt sich die 20 Anfragen der API" . " mit dem Abruf des Fahrzeugzustands. Später erneut versuchen."); } /* ---------------------------------------------------------------- Ausführen */ $rumpf = null; if ($name === "klima_start") { // Die Zieltemperatur kommt aus dem Formular und geht in einen Rumpf, den // die API auswertet - deshalb hart begrenzen statt nur zu vertrauen. $grad = isset($_POST["temperatur"]) ? floatval($_POST["temperatur"]) : 21.0; $grad = max(16.0, min(29.5, round($grad * 2) / 2)); // halbe Grad $rumpf = ["targetTemperature" => ["value" => $grad, "unit" => "CELSIUS"]]; } list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf); if ($status === 202) { skodaAbrufAnstossen(); antwort(200, true, $befehl["text"] . ". Das Fahrzeug führt den Befehl aus; " . "in der Anzeige erscheint es in einer halben Minute."); } antwort(200, false, fehlertext($status, $inhalt)); /* ------------------------------------------------------------- Bausteine -- */ /** Antwort ausgeben und beenden. */ function antwort($code, $ok, $text) { http_response_code($code); echo json_encode(["ok" => $ok, "text" => $text], JSON_UNESCAPED_UNICODE); exit; } /** * Stundenzähler für Befehle. Gleiches Verfahren wie die Bremse in tile.php: * 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) { $ort = null; foreach (SKODA_ARBEITSORTE as $kandidat) { if (is_dir($kandidat) && is_writable($kandidat)) { $ort = $kandidat; break; } } if (!$ort) { // Ohne Zähler lieber durchlassen als die Steuerung blockieren; die // API bremst zur Not selbst mit 429. return true; } $griff = fopen($ort . "/" . SKODA_ZAEHLER_DATEI, "c+"); if (!$griff || !flock($griff, LOCK_EX)) { return true; } $stand = json_decode(stream_get_contents($griff), true); if (!is_array($stand)) { $stand = ["fenster" => 0, "n" => 0]; } $jetzt = time(); if ($jetzt - $stand["fenster"] >= 3600) { $stand = ["fenster" => $jetzt, "n" => 0]; } $frei = $stand["n"] < $grenze; if ($frei) { $stand["n"]++; } ftruncate($griff, 0); rewind($griff); fwrite($griff, json_encode($stand)); flock($griff, LOCK_UN); fclose($griff); return $frei; } /** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */ function senden($konfig, $pfad, $rumpf) { $url = SKODA_API . rawurlencode($konfig["vin"]) . "/" . $pfad; $kopf = [ "X-API-Key: " . $konfig["schluessel"], "Accept: application/json", ]; $ch = curl_init($url); $optionen = [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_USERAGENT => SKODA_KENNUNG, CURLOPT_TIMEOUT => 20, CURLOPT_CONNECTTIMEOUT => 5, ]; if ($rumpf !== null) { $kopf[] = "Content-Type: application/json"; $optionen[CURLOPT_POSTFIELDS] = json_encode($rumpf); } else { $optionen[CURLOPT_POSTFIELDS] = ""; } $optionen[CURLOPT_HTTPHEADER] = $kopf; curl_setopt_array($ch, $optionen); $inhalt = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $fehler = curl_error($ch); curl_close($ch); if ($inhalt === false) { error_log("skodaCmd: $url nicht erreichbar: $fehler"); return [0, null]; } return [$status, $inhalt]; } /** * Aus dem Statuscode eine Auskunft machen, mit der man etwas anfangen kann. * Die API liefert dazu einen Problembericht nach RFC 9457; dessen "detail" * ist oft die eigentliche Erklärung. */ function fehlertext($status, $inhalt) { $detail = ""; $bericht = json_decode((string)$inhalt, true); if (is_array($bericht) && !empty($bericht["detail"])) { $detail = " (" . $bericht["detail"] . ")"; } switch ($status) { case 0: return "Die Škoda-API ist nicht erreichbar."; 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 und unter Einstellungen ▸ Fahrzeug eintragen."; 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 422: return "Das Fahrzeug kann das gerade nicht." . $detail; case 429: return "Kontingent der API erschöpft - später erneut versuchen."; case 503: case 504: return "Die API antwortet nicht rechtzeitig. Später erneut versuchen."; default: return "Unerwartete Antwort der API (HTTP " . $status . ")." . $detail; } }