(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. * * --------------------------------------------------------------------------- * Zwei Schlüssel, zwei Kontingente * * Die API begrenzt auf 20 Anfragen je Stunde **und Schlüssel**, und Befehle * zählen genauso mit wie Abrufe. Steht in skoda.conf ein eigener * CMD_API_KEY, hat die Steuerung damit ihr eigenes Kontingent und nimmt dem * Abruf des Fahrzeugzustands nichts weg. Beide Schlüssel werden in der * MyŠkoda-App angelegt. * * Ohne eigenen Schlüssel greift dieses Skript auf API_KEY zurück - dann * teilen sich beide ein Kontingent, und weil sie in getrennten Prozessen * laufen und keinen gemeinsamen Zähler haben, ist es fest aufgeteilt: * * eigener CMD_API_KEY nur API_KEY * Abruf 18 / Stunde 14 / Stunde * Befehle 15 / Stunde 4 / Stunde * Reserve 2 / Stunde 2 / Stunde * * gatherSkodaData.py stellt sich selbst um, sobald der zweite Schlüssel in * skoda.conf auftaucht - dort ist nichts von Hand nachzuziehen. * * Der zweite Schlüssel ist kein Freibrief, den Abruf zu beschleunigen: die * Kachelrichtlinie zielt auf die Last, nicht auf die Buchführung. * --------------------------------------------------------------------------- * * 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"); header('Content-Type: application/json; charset=utf-8'); const API_BASIS = "https://public.api.connect.skoda-auto.cz/api/v1/vehicles/"; const KONFIG = "/volume1/homes/wagner/SolarManager/skoda.conf"; const KENNUNG = "SmartController-SkodaSteuerung/1.0 (+https://nas.el-wa.org/smart)"; /** Befehle je Stunde - siehe Kopfkommentar. Hängt davon ab, ob die * Steuerung einen eigenen Schlüssel hat oder ihn mit dem Abruf teilt. */ const BEFEHLE_EIGENER_SCHLUESSEL = 15; const BEFEHLE_GETEILT = 4; /** Wo der Stundenzähler liegt. Muss für den Webserver beschreibbar sein. */ const ZAEHLER_ORTE = [ "/volume1/web/smart/tiles", // falls per synoacltool freigegeben "/var/services/tmp/smart-tiles", // sonst ]; /* * 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 = konfigLesen(); if (!$konfig) { antwort(503, false, "Kein API-Schlüssel hinterlegt - skoda.conf fehlt."); } $grenze = $konfig["EIGENER"] ? BEFEHLE_EIGENER_SCHLUESSEL : BEFEHLE_GETEILT; if (!budgetFrei($grenze)) { antwort(429, false, "Stündliches Kontingent für Befehle erschöpft (" . $grenze . " je Stunde)." . ($konfig["EIGENER"] ? "" : " Ein eigener CMD_API_KEY in skoda.conf" . " würde der Steuerung ein eigenes Kontingent geben.") . " 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) { antwort(200, true, $befehl["text"] . ". Das Fahrzeug führt den Befehl aus; " . "in der Anzeige erscheint es beim nächsten Abruf."); } 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; } /** API-Schlüssel und VIN aus skoda.conf lesen. Format: NAME=wert je Zeile. */ function konfigLesen() { if (!is_readable(KONFIG)) { return null; } $werte = []; foreach (file(KONFIG, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) 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), "\"'"); } if (empty($werte["API_KEY"]) || empty($werte["VIN"])) { return null; } // Eigener Schluessel fuer die Steuerung, wenn hinterlegt - sonst der // gemeinsame. Welcher es wurde, entscheidet unten ueber das Budget. $werte["EIGENER"] = !empty($werte["CMD_API_KEY"]); $werte["SCHLUESSEL"] = $werte["EIGENER"] ? $werte["CMD_API_KEY"] : $werte["API_KEY"]; return $werte; } /** * Stundenzähler für Befehle. Gleiches Verfahren wie die Bremse in tile.php: * eine kleine Datei, gegen gleichzeitige Zugriffe mit flock abgesichert. */ function budgetFrei($grenze) { $ort = null; foreach (ZAEHLER_ORTE 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 . "/.befehle.json", "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 = API_BASIS . 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 => 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."; 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; } }