Sammelstand der offenen Arbeit im Dashboard. Einstellungen: eine Eingabemaske fuer die Energiepreise unter restricted/settings.php, dazu costs.php als Modell und ajax/settings.php als Endpunkt. Die Preise stehen jetzt in gridCosts, gasCosts und fuelCosts statt fest im Quelltext von getStats.php; die Jahresstatistik rechnet ueber LEAD() den jeweils gueltigen Zeitraum aus und schlaegt den anteiligen Grundpreis auf. Eingegeben wird in gewohnten Einheiten - l/100 km, kWh/100 km, Euro je Liter -, umgerechnet wird beim Speichern. solarLog_costs.sql beschreibt die Umstellung der Tabellen. Skoda: eigene Seite mit Live-Werten ueber MQTT und Historie ueber ajax/skoda.php, Kommandos ueber ajax/skodaCmd.php. Karte: Leaflet mit einem eigenen Kachel-Zwischenspeicher (ajax/tile.php), dessen Ablage unter tiles/ nicht ins Repository gehoert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
10 KiB
PHP
270 lines
10 KiB
PHP
<?php
|
|
/*
|
|
* Befehle an das Fahrzeug.
|
|
*
|
|
* Aufruf per POST: befehl=laden_start | laden_stop | klima_start | klima_stop
|
|
* | lueftung_start | lueftung_stop
|
|
* temperatur=<Grad Celsius> (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;
|
|
}
|
|
}
|