Laden oeffnet jetzt ein Fenster wie auf der Solarseite: Ring mit Ladestand und Ladeziel, ein Knopf zum Starten oder Stoppen, das Ladeziel in Stufen von 50 bis 100 Prozent. skodaCmd.php kennt dafuer den Befehl ladeziel (PUT charging/limit) und prueft die Stufe, bevor ein Befehl aus dem Kontingent draufgeht. Gesendet wird erst, wenn eine Weile keine andere Stufe gewaehlt wurde; bis das Fahrzeug das Ziel meldet, steht die eigene Wahl im Fenster. Ohne Kabel ist der Start gesperrt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
255 lines
9.7 KiB
PHP
255 lines
9.7 KiB
PHP
<?php
|
||
/*
|
||
* Befehle an das Fahrzeug.
|
||
*
|
||
* Aufruf per POST: befehl=laden_start | laden_stop | klima_start | klima_stop
|
||
* | lueftung_start | lueftung_stop | ladeziel
|
||
* temperatur=<Grad Celsius> (nur bei klima_start)
|
||
* prozent=50..100 in Zehnern (nur bei ladeziel)
|
||
*
|
||
* 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"],
|
||
// Die API nimmt das Ladeziel per PUT, alle anderen Befehle per POST.
|
||
"ladeziel" => ["pfad" => "charging/limit", "methode" => "PUT", "text" => "Ladeziel gesetzt"],
|
||
];
|
||
|
||
/* ------------------------------------------------------------------ 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];
|
||
|
||
// Das Ladeziel vor dem Kontingent pruefen: eine falsche Zahl soll keinen der
|
||
// vier Befehle je Stunde kosten. Laut Spec nehmen die Fahrzeuge
|
||
// "typischerweise" 50 bis 100 in Zehnern und lehnen alles andere ab.
|
||
$prozent = null;
|
||
if ($name === "ladeziel") {
|
||
$prozent = isset($_POST["prozent"]) ? intval($_POST["prozent"]) : 0;
|
||
if ($prozent < 50 || $prozent > 100 || $prozent % 10 !== 0) {
|
||
antwort(400, false, "Das Ladeziel geht von 50 bis 100 Prozent in Zehnerschritten.");
|
||
}
|
||
}
|
||
|
||
$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;
|
||
$text = $befehl["text"];
|
||
if ($name === "ladeziel") {
|
||
$rumpf = ["targetStateOfChargeInPercent" => $prozent];
|
||
$text = "Ladeziel " . $prozent . " % gesetzt";
|
||
}
|
||
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, $befehl["methode"] ?? "POST");
|
||
|
||
if ($status === 202) {
|
||
skodaAbrufAnstossen();
|
||
antwort(200, true, $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, $methode = "POST")
|
||
{
|
||
$url = SKODA_API . rawurlencode($konfig["vin"]) . "/" . $pfad;
|
||
$kopf = [
|
||
"X-API-Key: " . $konfig["schluessel"],
|
||
"Accept: application/json",
|
||
];
|
||
$ch = curl_init($url);
|
||
$optionen = [
|
||
CURLOPT_CUSTOMREQUEST => $methode,
|
||
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;
|
||
}
|
||
}
|