diff --git a/.gitignore b/.gitignore
index 88c52db..e6c3b58 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,7 @@ Thumbs.db
*.tmp
*.bak
*.backup
+
+# Kartenkacheln des Zwischenspeichers (ajax/tile.php).
+# Wachsen mit dem, was angesehen wurde - gehoeren nicht ins Repository.
+tiles/
diff --git a/ajax/energyHistory.php b/ajax/energyHistory.php
index c9bf534..e797fa6 100644
--- a/ajax/energyHistory.php
+++ b/ajax/energyHistory.php
@@ -4,30 +4,94 @@
* Ersetzt getConsData_{month,year,decade}.php und getProdData_{month,year,decade}.php.
*
* Aufruf: energyHistory.php?series=prod|cons&range=month|year|decade
+ *
+ * Zwei Quellen, je nach Zeitraum:
+ *
+ * month EnergyFlow, also die Rohdaten. Nach Tagen gruppiert, und der
+ * heutige Balken soll bis zur letzten Messung reichen. Das
+ * Stundenarchiv wird nur einmal nachts fortgeschrieben und wäre
+ * für den laufenden Tag noch leer.
+ *
+ * year EnergyFlow_hourly, das Stundenarchiv. Die Rohdaten werden nach
+ * decade zwölf Monaten ausgedünnt (solarlog-rollup.sh thin), diese
+ * Diagramme fänden dort also nichts mehr. Bei Gruppierung nach
+ * Monat bzw. Jahr fällt der fehlende laufende Tag nicht auf.
+ *
+ * Beide Quellen liefern Wattstunden, weil die Diagramme auf der y-Achse durch
+ * 1.000.000 teilen und MWh beschriften. Die Rohdaten sind Momentanleistungen
+ * alle fünf Minuten, daher /12; im Archiv stehen bereits kWh, daher *1000.
+ * Gegengerechnet über einen kompletten Monat stimmen beide auf 0,003 % überein
+ * - der Rest ist die /12-Näherung gegenüber der echten Zeitgewichtung.
+ *
+ * pv_fehlbetrag_kwh wird zur erfassten Erzeugung addiert. Wenn Wechselrichter
+ * die Verbindung zur OpenDTU verlieren, fehlt ihr Beitrag in pvP bzw. pv_kwh -
+ * die Einspeisung, die Batterieladung und der Heizstab sind aber vollständig
+ * gemessen. Ohne die Korrektur wird der Direktverbrauch als Differenz negativ
+ * und der Balken bleibt leer, etwa am 03.08.2026 mit -0,5 statt +15,9 kWh.
+ * Im Rohdaten-Zweig kommt der Betrag über eine nach Tagen vorverdichtete
+ * abgeleitete Tabelle dazu, sonst zählte ihn jede der zwölf Zeilen je Stunde
+ * erneut; für den laufenden Tag gibt es ihn noch nicht, dann bleibt er 0.
+ *
+ * Die Einspeisung kommt in beiden Fällen aus gridPfeed, dem Zählerregister,
+ * nicht aus dem Saldo gridP. Innerhalb eines Fensters kommen Bezug und
+ * Einspeisung gleichzeitig vor; der Saldo löscht kurze Bezüge weg und passt
+ * dann nicht mehr zu dem, was abgerechnet wird.
*/
require_once("../helper.php");
require_once("./chartData.php");
-// Zeitraum -> [Format der Beschriftung, Filter auf datetime]
+// Zeitraum -> [Format der Beschriftung, Quelle]
$ranges = [
- "month" => ["'%d.%b.'", "DATE_FORMAT(datetime,'%Y%m%d') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 1 MONTH),'%Y%m%d')"],
- "year" => ["'%b. %y'", "DATE_FORMAT(datetime,'%Y%m') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 13 MONTH),'%Y%m')"],
- "decade" => ["'%Y'", "DATE_FORMAT(datetime,'%Y') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 10 YEAR),'%Y')"],
+ "month" => ["'%d.%b.'", "roh"],
+ "year" => ["'%b. %y'", "archiv"],
+ "decade" => ["'%Y'", "archiv"],
+];
+
+// Filter auf die Zeitspalte. ZEIT wird durch den Spaltennamen der Quelle ersetzt.
+$filters = [
+ "month" => "DATE_FORMAT(ZEIT,'%Y%m%d') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 1 MONTH),'%Y%m%d')",
+ "year" => "DATE_FORMAT(ZEIT,'%Y%m') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 13 MONTH),'%Y%m')",
+ "decade" => "DATE_FORMAT(ZEIT,'%Y') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 10 YEAR),'%Y')",
];
// Datenreihen je Diagramm. Beide teilen sich Solarertrag und Heizstab und
// unterscheiden sich nur darin, wohin die Energie fließt (prod) bzw. woher
// sie kommt (cons).
-$series = [
- "prod" => "SUM(pvP/12 - IF(gridP < 0, -gridP/12,0) - IF(battP < 0, -battP/12,0) - heaterPwr/12) AS Direktverbrauch,
+$quellen = [
+ "roh" => [
+ "tabelle" => "EnergyFlow",
+ "zeit" => "datetime",
+ "join" => "LEFT JOIN (SELECT DATE(stunde) AS tag, SUM(pv_fehlbetrag_kwh) AS fehlt
+ FROM EnergyFlow_hourly GROUP BY 1) f ON f.tag = DATE(datetime)",
+ "reihen" => [
+ "prod" => "SUM(pvP/12 - gridPfeed/12 - IF(battP < 0, -battP/12,0) - heaterPwr/12)
+ + IFNULL(MAX(f.fehlt),0)*1000 AS Direktverbrauch,
SUM(heaterPwr/12) AS Heizstab,
SUM(IF(battP < 0, -battP/12,0)) AS Batterieladung,
SUM(gridPfeed/12) AS Einspeisung",
- "cons" => "SUM(pvP/12 - IF(gridP < 0, -gridP/12,0) - IF(battP < 0, -battP/12,0) - heaterPwr/12) AS Solarverbrauch,
+ "cons" => "SUM(pvP/12 - gridPfeed/12 - IF(battP < 0, -battP/12,0) - heaterPwr/12)
+ + IFNULL(MAX(f.fehlt),0)*1000 AS Solarverbrauch,
SUM(heaterPwr/12) AS Heizstab,
SUM(IF(battP > 0, battP/12,0)) AS Batteriebezug,
SUM(gridPcons/12) AS Netzbezug",
+ ],
+ ],
+ "archiv" => [
+ "tabelle" => "EnergyFlow_hourly",
+ "zeit" => "stunde",
+ "join" => "",
+ "reihen" => [
+ "prod" => "SUM((pv_kwh + IFNULL(pv_fehlbetrag_kwh,0) - netz_einsp_kwh - batt_laden_kwh - heizstab_kwh)*1000) AS Direktverbrauch,
+ SUM(heizstab_kwh*1000) AS Heizstab,
+ SUM(batt_laden_kwh*1000) AS Batterieladung,
+ SUM(netz_einsp_kwh*1000) AS Einspeisung",
+ "cons" => "SUM((pv_kwh + IFNULL(pv_fehlbetrag_kwh,0) - netz_einsp_kwh - batt_laden_kwh - heizstab_kwh)*1000) AS Solarverbrauch,
+ SUM(heizstab_kwh*1000) AS Heizstab,
+ SUM(batt_entladen_kwh*1000) AS Batteriebezug,
+ SUM(netz_bezug_kwh*1000) AS Netzbezug",
+ ],
+ ],
];
$linecolors["Direktverbrauch"] = "#FFFF00";
@@ -46,14 +110,17 @@ $linecolors["Vorhersage"] = "#2222FF";
header('Content-Type: application/json; charset=utf-8');
-$rangeKey = isset($_GET["range"]) ? $_GET["range"] : "";
+$rangeKey = isset($_GET["range"]) ? $_GET["range"] : "";
$seriesKey = isset($_GET["series"]) ? $_GET["series"] : "";
-if (!isset($ranges[$rangeKey]) || !isset($series[$seriesKey])) {
+if (!isset($ranges[$rangeKey]) || !in_array($seriesKey, ["prod", "cons"], true)) {
http_response_code(400);
echo json_encode(["error" => "Unbekannter Zeitraum oder Datenreihe."]);
exit;
}
-list($labelFormat, $filter) = $ranges[$rangeKey];
+list($labelFormat, $quelleKey) = $ranges[$rangeKey];
+$quelle = $quellen[$quelleKey];
+$zeit = $quelle["zeit"];
+$filter = str_replace("ZEIT", $zeit, $filters[$rangeKey]);
$obj = (object)[];
$obj->labels = [];
@@ -62,12 +129,12 @@ $obj->datasets = [];
if (checkLogin()) {
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
$query = "SELECT
- DATE_FORMAT(datetime, $labelFormat) AS 'time',
- " . $series[$seriesKey] . "
-FROM EnergyFlow
+ DATE_FORMAT($zeit, $labelFormat) AS 'time',
+ " . $quelle["reihen"][$seriesKey] . "
+FROM " . $quelle["tabelle"] . " " . $quelle["join"] . "
WHERE $filter
- GROUP BY DATE_FORMAT(datetime, $labelFormat)
-ORDER BY datetime ; ";
+ GROUP BY DATE_FORMAT($zeit, $labelFormat)
+ORDER BY MIN($zeit) ; ";
$obj = buildChartData($mysql, $query, $linecolors);
}
diff --git a/ajax/getStats.php b/ajax/getStats.php
index 54d9ca4..c146b5e 100644
--- a/ajax/getStats.php
+++ b/ajax/getStats.php
@@ -1,6 +1,7 @@
0, battP,0))*cost/12000 as 'Verbrauchsersparnis',
- SUM((gridPfeed*gain)/12000) as 'Einspeisevergütung',
- SUM(IF(gridP < 500, heaterPwr, 0)*0.063/12000) as 'Ersparnis Heizung',
- SUM(IF(battP > 0, battP, 0)*cost/12000) as 'Ersparnis Batterie',
- AVG(autonomy) AS 'Ø Autarkie',
- (SUM(PL1_EV+PL2_EV+PL3_EV))/12 AS 'Autoladung ges. EG',
- (((SUM(IF(gridP>100,0,PL1_EV+PL2_EV+PL3_EV)))/(SUM(PL1_EV+PL2_EV+PL3_EV))))*100 AS 'Autoladung Solar EG',
- (SUM(IF(gridP>100,0,(PL1_EV+PL2_EV+PL3_EV)*cost))/12) AS 'Ersparnis Solarladung EG',
- (SUM(PL1_EV+PL2_EV+PL3_EV))/24.5 AS 'Benzin gespart EG',
- (SUM(PL1_EVog+PL2_EVog+PL3_EVog))/12 AS 'Autoladung ges. OG',
- (((SUM(IF(gridP>100,0,PL1_EVog+PL2_EVog+PL3_EVog)))/(SUM(PL1_EVog+PL2_EVog+PL3_EVog))))*100 AS 'Autoladung Solar OG',
- (SUM(IF(gridP>100,0,(PL1_EVog+PL2_EVog+PL3_EVog)*cost))/12) AS 'Ersparnis Solarladung OG',
- (SUM(PL1_EVog+PL2_EVog+PL3_EVog))/24.5 AS 'Benzin gespart OG'
-FROM EnergyFlow JOIN gridCosts ON DATE(datetime) >= DATE(gridCosts.active_date) AND DATE(datetime) <= DATE(gridCosts.end_date)
-WHERE year(datetime) = ".$_GET["year"].";";
+/**
+ * Die Jahresstatistik als eine Abfrage - jetzt auf stats_daily.
+ *
+ * Gerechnet wird nicht mehr ueber die Rohdaten aus EnergyFlow, sondern ueber
+ * die Tageswerte. Zwei Gruende:
+ *
+ * 1. Die Rohdaten werden nach zwoelf Monaten ausgeduennt bzw. ins
+ * Stundenarchiv verschoben (solarlog-rollup.sh). Eine Kachel, die auf
+ * EnergyFlow rechnet, zeigt fuer alte Jahre danach zu wenig an.
+ * 2. Ein Jahr sind 365 Zeilen statt rund 105.000.
+ *
+ * Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt
+ * LEAD() beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie
+ * zwei Tarife haben - die Verdichtung auf Tage verliert hier also nichts.
+ *
+ * Was in stats_daily vorberechnet sein MUSS, weil es eine Bedingung je
+ * Messwert auswertet und sich aus Tagessummen nicht rekonstruieren laesst:
+ *
+ * eigenverbrauch_kwh pv - Einspeisung - Batterieladung
+ * - Heizstab + Batterieentladung. Dazu addiert
+ * wird pv_fehlbetrag_kwh: bei Ausfall einzelner
+ * Wechselrichter fehlt deren Beitrag in pv, alle
+ * Senken sind dagegen gemessen - die Differenz
+ * faellt sonst zu klein oder negativ aus.
+ * heizstab_solar_kwh Heizstab, anteilig nach autonomy
+ * auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom
+ * Geraet in feiner Aufloesung gerechneten
+ * Nicht-Netz-Anteil der Zufuhr
+ *
+ * Einheiten sind in stats_daily bereits bereinigt: PL*_EV und PL*_EVog
+ * liefern Kilowatt, alle uebrigen Leistungsspalten Watt. In der alten
+ * Abfrage stand das als /12 gegen /12000 nebeneinander.
+ *
+ * Die Solaranteile teilen jetzt durch NULLIF(...): ohne Ladung im Jahr kam
+ * vorher 0/0 heraus.
+ *
+ * "Ersparnis Batterie" rechnet den Nutzen netto: was die entladene Energie
+ * aus dem Netz gekostet haette, abzueglich der Verguetung, die fuer den zum
+ * Laden verwendeten Strom entgangen ist. Brutto waere die Kachel zu
+ * freundlich - der Speicher ist nicht umsonst zu fuellen.
+ *
+ * Achtung beim Lesen: "Verbrauchsersparnis" ist der GESAMTE Eigenverbrauch
+ * zum Strompreis. "Ersparnis Batterie" und "Ersparnis Solarladung" sind
+ * Teilmengen davon, nach Herkunft bzw. Verwendung herausgegriffen. Sie
+ * duerfen nicht addiert werden.
+ *
+ * Der Grundpreis fehlt weiterhin bewusst - er haengt an den Tagen des
+ * Jahres, nicht an einem Messwert, und kommt aus grundpreisImJahr() dazu.
+ */
+function statistikAbfrage($jahr)
+{
+ return "WITH
+ preis AS (SELECT active_date, cost, gain,
+ LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gridCosts),
+ gas AS (SELECT active_date, cost, kwhPerLitre,
+ LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gasCosts),
+ sprit AS (SELECT active_date, cost, lPer100km, kwhPer100km,
+ LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM fuelCosts)
+SELECT
+ SUM(d.netz_bezug_kwh) AS 'Stromverbrauch',
+ SUM(d.netz_bezug_kwh * preis.cost) AS 'Stromkosten',
+ SUM((d.eigenverbrauch_kwh + IFNULL(d.pv_fehlbetrag_kwh,0)) * preis.cost) AS 'Verbrauchsersparnis',
+ SUM(d.netz_einsp_kwh * preis.gain) AS 'Einspeisevergütung',
+ SUM(d.heizstab_solar_kwh * gas.cost / NULLIF(gas.kwhPerLitre,0)) AS 'Ersparnis Heizung',
+ SUM(d.batt_entladen_kwh * preis.cost - d.batt_laden_kwh * preis.gain) AS 'Ersparnis Batterie',
+ SUM(d.autarkie_avg * d.stunden_erfasst) / NULLIF(SUM(d.stunden_erfasst),0) AS 'Ø Autarkie',
+ SUM(d.auto_eg_kwh) AS 'Autoladung ges. EG',
+ SUM(d.auto_eg_solar_kwh) / NULLIF(SUM(d.auto_eg_kwh),0) * 100 AS 'Autoladung Solar EG',
+ SUM(d.auto_eg_solar_kwh * preis.cost) AS 'Ersparnis Solarladung EG',
+ SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart EG',
+ SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin EG',
+ SUM(d.auto_og_kwh) AS 'Autoladung ges. OG',
+ SUM(d.auto_og_solar_kwh) / NULLIF(SUM(d.auto_og_kwh),0) * 100 AS 'Autoladung Solar OG',
+ SUM(d.auto_og_solar_kwh * preis.cost) AS 'Ersparnis Solarladung OG',
+ SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart OG',
+ SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin OG'
+FROM stats_daily d
+ JOIN preis ON d.tag >= preis.active_date AND (preis.folgt IS NULL OR d.tag < preis.folgt)
+ LEFT JOIN gas ON d.tag >= gas.active_date AND (gas.folgt IS NULL OR d.tag < gas.folgt)
+ LEFT JOIN sprit ON d.tag >= sprit.active_date AND (sprit.folgt IS NULL OR d.tag < sprit.folgt)
+WHERE YEAR(d.tag) = " . intval($jahr) . ";";
+}
-$PrevQuery = "SELECT
- SUM(gridPcons/12000) as 'Stromverbrauch',
- SUM((gridPcons*cost)/12000)+140 as 'Stromkosten (140€ fix)',
- SUM(pvP - IF(gridP < 0, -gridP,0) - IF(battP < 0, -battP,0)- heaterPwr + IF(battP > 0, battP,0))*cost/12000 as 'Verbrauchsersparnis',
- SUM((gridPfeed*gain)/12000) as 'Einspeisevergütung',
- SUM(IF(gridP < 500, heaterPwr, 0)*0.063/12000) as 'Ersparnis Heizung',
- SUM(IF(battP > 0, battP, 0)*cost/12000) as 'Ersparnis Batterie',
- AVG(autonomy) AS 'Ø Autarkie',
- (SUM(PL1_EV+PL2_EV+PL3_EV))/12 AS 'Autoladung ges. EG',
- (((SUM(IF(gridP>100,0,PL1_EV+PL2_EV+PL3_EV)))/(SUM(PL1_EV+PL2_EV+PL3_EV))))*100 AS 'Autoladung Solar EG',
- (SUM(IF(gridP>100,0,(PL1_EV+PL2_EV+PL3_EV)*cost))/12) AS 'Ersparnis Solarladung EG',
- (SUM(PL1_EV+PL2_EV+PL3_EV))/24.5 AS 'Benzin gespart EG',
- (SUM(PL1_EVog+PL2_EVog+PL3_EVog))/12 AS 'Autoladung ges. OG',
- (((SUM(IF(gridP>100,0,PL1_EVog+PL2_EVog+PL3_EVog)))/(SUM(PL1_EVog+PL2_EVog+PL3_EVog))))*100 AS 'Autoladung Solar OG',
- (SUM(IF(gridP>100,0,(PL1_EVog+PL2_EVog+PL3_EVog)*cost))/12) AS 'Ersparnis Solarladung OG',
- (SUM(PL1_EVog+PL2_EVog+PL3_EVog))/24.5 AS 'Benzin gespart OG'
-FROM EnergyFlow JOIN gridCosts ON DATE(datetime) >= DATE(gridCosts.active_date) AND DATE(datetime) <= DATE(gridCosts.end_date)
-WHERE year(datetime) = ".($_GET["year"]-1).";";
+$Query = statistikAbfrage($_GET["year"]);
+$PrevQuery = statistikAbfrage($_GET["year"] - 1);
-
-$units = Array("kWh","€","€","€","€","€","€","%","kWh","%","€","L","kWh","%","€","L");
-$LessIsBetter = Array(true,true,false,false,false,false,false,false,false,false,false,false,false,false,false,false);
+// Reihenfolge wie in der Abfrage, mit "Verg.+Einsp. Strom" an Stelle 4.
+$units = Array("kWh","€","€","€","€","€","€","%",
+ "kWh","%","€","L","€",
+ "kWh","%","€","L","€");
+// Nur bei Verbrauch und Kosten ist weniger besser.
+$LessIsBetter = array_merge([true, true], array_fill(0, count($units) - 2, false));
function array_insert($array,$values,$offset) {
return array_slice($array, 0, $offset, true) + $values + array_slice($array, $offset, NULL, true);
@@ -67,7 +119,7 @@ function array_insert($array,$values,$offset) {
if (checkLogin()) {
$html = "
";
- $mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
+ $mysql = solarDb();
$Res = mysqli_query($mysql,$Query);
$ResPrev = mysqli_query($mysql,$PrevQuery);
if(!$Res || !$ResPrev){
@@ -77,13 +129,17 @@ if (checkLogin()) {
$i = 0;
$row = $Res->fetch_assoc();
$rowPrev = $ResPrev->fetch_assoc();
+ // Der Grundpreis faellt unabhaengig vom Verbrauch an und laesst sich
+ // deshalb nicht aus den Messwerten summieren.
+ $row["Stromkosten"] += grundpreisImJahr($_GET["year"]);
+ $rowPrev["Stromkosten"] += grundpreisImJahr($_GET["year"] - 1);
//$row["Vergütung+Einsparung Strom"] = $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"];
$row = array_insert($row,["Verg.+Einsp. Strom" => $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"]],4);
$rowPrev = array_insert($rowPrev,["Verg.+Einsp. Strom" => $rowPrev["Einspeisevergütung"]+$rowPrev["Verbrauchsersparnis"]],4);
$i = 0;
foreach ($row as $name => $value) {
if(str_starts_with($name,"Autoladung ges.")){
- $html .= "
";
+ $html .= "";
$html .= "
";
}
/*$html .= "
diff --git a/ajax/settings.php b/ajax/settings.php
new file mode 100644
index 0000000..2977bc9
--- /dev/null
+++ b/ajax/settings.php
@@ -0,0 +1,66 @@
+ "Konnte die Anfrage nicht lesen."], 400);
+ }
+ try {
+ switch ($action) {
+ case "save":
+ $anzahl = preiseSpeichern($body["reihe"] ?? "", $body["zeilen"] ?? []);
+ // Die gespeicherte Fassung zurueckgeben: die Maske zeigt danach
+ // die abgeleiteten Enddaten, nicht die eingetippten.
+ jsonAntwort([
+ "gespeichert" => $anzahl,
+ "zeilen" => preiseLaden($body["reihe"]),
+ ]);
+ default:
+ jsonAntwort(["error" => "Unbekannte Aktion."], 400);
+ }
+ } catch (InvalidArgumentException $e) {
+ jsonAntwort(["error" => $e->getMessage()], 400);
+ } catch (Throwable $e) {
+ jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
+ }
+}
+
+if ($action === "list") {
+ try {
+ jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]);
+ } catch (InvalidArgumentException $e) {
+ jsonAntwort(["error" => $e->getMessage()], 400);
+ } catch (Throwable $e) {
+ jsonAntwort(["error" => "Laden fehlgeschlagen: " . $e->getMessage()], 500);
+ }
+}
+
+jsonAntwort(["error" => "Unbekannte Aktion."], 400);
diff --git a/ajax/skoda.php b/ajax/skoda.php
new file mode 100644
index 0000000..5685c9e
--- /dev/null
+++ b/ajax/skoda.php
@@ -0,0 +1,366 @@
+ "Nicht angemeldet."]);
+ exit;
+}
+
+$was = isset($_GET["was"]) ? $_GET["was"] : "live";
+$tage = isset($_GET["tage"]) ? max(1, min(3650, intval($_GET["tage"]))) : 90;
+
+$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
+if ($mysql->connect_errno) {
+ http_response_code(500);
+ echo json_encode(["error" => "Datenbank nicht erreichbar."]);
+ exit;
+}
+$mysql->set_charset("utf8mb4");
+
+/*
+ * Ladevorgänge aus den Einzelzeilen schneiden. Aufeinanderfolgende Zeilen mit
+ * chg_state = 'CHARGING' gehören zusammen, eine Pause über 30 Minuten trennt
+ * zwei Vorgänge. Wird von mehreren Abschnitten gebraucht, deshalb einmal hier.
+ *
+ * Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer
+ * Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
+ * Steht der Zähler still, wurde auswärts geladen (Schnellladesäule); dann
+ * bleibt wh null und die Kapazitätsrechnung lässt die Ladung aus.
+ */
+function sitzungenSQL($seit)
+{
+ return "
+ SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
+ MIN(soc) AS soc_von, MAX(soc) AS soc_bis,
+ CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc) AS SIGNED) AS zuwachs,
+ MAX(chg_type) AS art, MAX(target_soc) AS ziel,
+ ROUND(AVG(chg_kw), 2) AS kw_mittel, ROUND(MAX(chg_kw), 2) AS kw_spitze,
+ MAX(wb_wh_total) - MIN(wb_wh_total) AS wh,
+ ROUND(SUM(kwh_teil), 3) AS kwh_fahrzeug,
+ ROUND(AVG(pv_kw), 2) AS pv_kw, COUNT(*) AS punkte
+ FROM (
+ SELECT s.*, SUM(s.neu) OVER (ORDER BY s.datetime) AS sitzung FROM (
+ SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
+ wb_wh_total, pv_kw,
+ CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
+ AND TIMESTAMPDIFF(MINUTE, LAG(datetime) OVER w, datetime) <= 30
+ THEN 0 ELSE 1 END AS neu,
+ /*
+ * Fahrzeugseitige Energie: chg_kw ist die Ladeleistung, die
+ * das Fahrzeug selbst meldet - gemessen HINTER dem Ladegerät.
+ * Die API kennt keine Kilowattstunden, deshalb wird hier über
+ * die Zeit integriert, je Schritt mit dem Mittel aus vorigem
+ * und aktuellem Wert (Trapez). Beim Laden mit Wechselstrom
+ * liegt die Leistung fast konstant an, da ist das genau genug.
+ *
+ * Die Bedingung hält die Summe innerhalb einer Ladung: über
+ * eine Lücke von mehr als 30 Minuten hinweg wäre die Fläche
+ * unter der Kurve frei erfunden.
+ */
+ CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
+ AND TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime)
+ BETWEEN 1 AND 1800
+ THEN (COALESCE(chg_kw, 0) + COALESCE(LAG(chg_kw) OVER w, 0)) / 2
+ * TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) / 3600
+ ELSE 0 END AS kwh_teil
+ FROM skoda WHERE datetime >= $seit
+ WINDOW w AS (ORDER BY datetime)) s) t
+ WHERE chg_state = 'CHARGING'
+ GROUP BY sitzung";
+}
+
+/*
+ * Rückfallwert für den Ladewirkungsgrad, solange er sich nicht messen lässt.
+ * Beim Laden mit Wechselstrom gehen im Ladegerät des Fahrzeugs grob zehn
+ * Prozent verloren.
+ */
+const LADE_WIRKUNGSGRAD_ANNAHME = 0.90;
+
+/** Ab so vielen Ladungen gilt der gemessene Wirkungsgrad als belastbar. */
+const WIRKUNGSGRAD_MIN_LADUNGEN = 5;
+
+/**
+ * Wie viele Kilowattstunden ein Ladestandsprozent wert ist.
+ *
+ * Zwei Energien je Ladung stehen zur Verfügung, und der Unterschied zwischen
+ * ihnen ist genau das, was das Ladegerät verheizt:
+ *
+ * wh Differenz der Wallbox-Zählerstände - vor dem Ladegerät
+ * kwh_fahrzeug chg_kw über die Zeit integriert - hinter dem Ladegerät
+ *
+ * Für den Fahrverbrauch zählt, was die Batterie verlässt, also die zweite.
+ * Sie wird bevorzugt; erst wenn zu wenige Ladungen sie hergeben, wird der
+ * Zählerwert mit dem angenommenen Wirkungsgrad heruntergerechnet.
+ *
+ * Der Median statt des Mittels, weil einzelne Ladungen durch die Rundung des
+ * Ladestands weit ausreissen können.
+ *
+ * Gibt null zurück, solange es zu wenige Ladungen gibt. Die Oberfläche zeigt
+ * dann keine Kilowattstunden an, statt eine erfundene Zahl.
+ */
+function kapazitaetSchaetzen($mysql)
+{
+ $sql = "SELECT ROUND(wh/1000/zuwachs*100, 3) AS je100_zaehler,
+ CASE WHEN kwh_fahrzeug > 0
+ THEN ROUND(kwh_fahrzeug/zuwachs*100, 3) END AS je100_fahrzeug
+ FROM (" . sitzungenSQL("NOW() - INTERVAL 365 DAY") . ") v
+ WHERE zuwachs >= 20 AND wh > 1000";
+ $res = $mysql->query($sql);
+ if (!$res) {
+ return null;
+ }
+ $zaehler = [];
+ $fahrzeug = [];
+ $paare = []; // beide Werte derselben Ladung, für den Wirkungsgrad
+ while ($z = $res->fetch_assoc()) {
+ $zaehler[] = floatval($z["je100_zaehler"]);
+ if ($z["je100_fahrzeug"] !== null) {
+ $fahrzeug[] = floatval($z["je100_fahrzeug"]);
+ $paare[] = floatval($z["je100_fahrzeug"]) / floatval($z["je100_zaehler"]);
+ }
+ }
+ if (count($zaehler) < 3) {
+ return null;
+ }
+
+ $gemessen = count($fahrzeug) >= WIRKUNGSGRAD_MIN_LADUNGEN;
+ if ($gemessen) {
+ $netto = median($fahrzeug);
+ $grad = median($paare);
+ } else {
+ $netto = median($zaehler) * LADE_WIRKUNGSGRAD_ANNAHME;
+ $grad = LADE_WIRKUNGSGRAD_ANNAHME;
+ }
+
+ return [
+ "je100_zaehler" => round(median($zaehler), 2),
+ "je100_netto" => round($netto, 2),
+ "wirkungsgrad" => round($grad, 3),
+ "gemessen" => $gemessen,
+ "je_soc_netto" => round($netto / 100, 4),
+ "n" => count($zaehler),
+ "n_fahrzeug" => count($fahrzeug),
+ ];
+}
+
+/** Median eines nicht leeren Zahlenfeldes. */
+function median($werte)
+{
+ sort($werte);
+ return $werte[intdiv(count($werte), 2)];
+}
+
+$antwort = [];
+
+switch ($was) {
+
+/* ------------------------------------------------------------------ live -- */
+case "live":
+ $res = $mysql->query("SELECT * FROM skoda ORDER BY id DESC LIMIT 1");
+ $zeile = $res ? $res->fetch_assoc() : null;
+ $antwort = ["stand" => $zeile];
+
+ // Wie lange schweigt das Fahrzeug schon? Sagt mehr als der Zeitstempel
+ // allein, weil die Seite auch aus dem Zwischenspeicher kommen kann.
+ if ($zeile) {
+ $res = $mysql->query("SELECT TIMESTAMPDIFF(SECOND, '"
+ . $mysql->real_escape_string($zeile["datetime"]) . "', NOW()) AS alter_s");
+ $r = $res ? $res->fetch_assoc() : null;
+ $antwort["alter_s"] = $r ? intval($r["alter_s"]) : null;
+ }
+
+ // Kennzahlen über den gesamten Bestand, für die Kopfzeile.
+ $res = $mysql->query("SELECT COUNT(*) AS zeilen, MIN(datetime) AS seit,
+ MAX(odo_km) AS km FROM skoda");
+ $antwort["bestand"] = $res ? $res->fetch_assoc() : null;
+ break;
+
+/* -------------------------------------------------------------- ladungen -- */
+case "ladungen":
+ $sql = "SELECT * FROM (" . sitzungenSQL("NOW() - INTERVAL $tage DAY") . ") v
+ ORDER BY beginn DESC LIMIT 200";
+ $res = $mysql->query($sql);
+ $antwort["ladungen"] = [];
+ while ($res && $z = $res->fetch_assoc()) {
+ $antwort["ladungen"][] = $z;
+ }
+ break;
+
+/* ----------------------------------------------------------------- kurve -- */
+case "kurve":
+ // Verlauf einer Ladung. Ohne Parameter die jüngste.
+ $von = isset($_GET["von"]) ? $_GET["von"] : null;
+ $bis = isset($_GET["bis"]) ? $_GET["bis"] : null;
+ if (!$von || !$bis) {
+ $sql = "SELECT beginn, ende FROM (" . sitzungenSQL("NOW() - INTERVAL 365 DAY") . ") v
+ ORDER BY beginn DESC LIMIT 1";
+ $res = $mysql->query($sql);
+ $z = $res ? $res->fetch_assoc() : null;
+ if (!$z) { $antwort["punkte"] = []; break; }
+ $von = $z["beginn"];
+ $bis = $z["ende"];
+ }
+ // Eine Zeile Vorlauf und Nachlauf, damit die Kurve nicht in der Luft endet.
+ $stmt = $mysql->prepare(
+ "SELECT datetime, soc, chg_kw, wb_kw, pv_kw, chg_state, wb_wh_total
+ FROM skoda
+ WHERE datetime BETWEEN (? - INTERVAL 15 MINUTE) AND (? + INTERVAL 15 MINUTE)
+ ORDER BY datetime");
+ $stmt->bind_param("ss", $von, $bis);
+ $stmt->execute();
+ $res = $stmt->get_result();
+ $antwort["punkte"] = [];
+ while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; }
+ $antwort["von"] = $von;
+ $antwort["bis"] = $bis;
+ break;
+
+/* ------------------------------------------------------------ gesundheit -- */
+case "gesundheit":
+ /*
+ * Kilowattstunden je hundert Ladestandsprozent. Der Wert liegt über der
+ * nutzbaren Kapazität, weil die Ladeverluste mitgezählt werden - absolut
+ * also nur eine Näherung, sein Verlauf über die Jahre dagegen die
+ * eigentliche Aussage.
+ *
+ * Nur lange Ladungen an der eigenen Wallbox taugen dazu: bei zwanzig
+ * Prozentpunkten Zuwachs wiegt ein gerundeter Ladestandspunkt fünf
+ * Prozent, und eine Schnellladung unterwegs hat auf unserer Seite keinen
+ * Zähler.
+ */
+ $sql = "SELECT beginn, soc_von, soc_bis, zuwachs, art, wh,
+ ROUND(wh/1000/zuwachs*100, 2) AS je100
+ FROM (" . sitzungenSQL("NOW() - INTERVAL $tage DAY") . ") v
+ WHERE zuwachs >= 20 AND wh > 1000
+ ORDER BY beginn";
+ $res = $mysql->query($sql);
+ $antwort["ladungen"] = [];
+ while ($res && $z = $res->fetch_assoc()) { $antwort["ladungen"][] = $z; }
+
+ // Ausgleichsgerade über die Einzelwerte, x in Jahren. Die Steigung sagt,
+ // ob der Wert wandert; ihr Fehler, ab welcher Alterung man überhaupt
+ // etwas sehen könnte.
+ $n = count($antwort["ladungen"]);
+ $antwort["trend"] = null;
+ if ($n >= 6) {
+ $t0 = strtotime($antwort["ladungen"][0]["beginn"]);
+ $xs = []; $ys = [];
+ foreach ($antwort["ladungen"] as $l) {
+ $xs[] = (strtotime($l["beginn"]) - $t0) / (365.25 * 86400);
+ $ys[] = floatval($l["je100"]);
+ }
+ $mx = array_sum($xs) / $n;
+ $my = array_sum($ys) / $n;
+ $sxx = 0; $sxy = 0;
+ for ($i = 0; $i < $n; $i++) {
+ $sxx += ($xs[$i] - $mx) ** 2;
+ $sxy += ($xs[$i] - $mx) * ($ys[$i] - $my);
+ }
+ if ($sxx > 0) {
+ $steigung = $sxy / $sxx;
+ $achse = $my - $steigung * $mx;
+ $rest = 0;
+ for ($i = 0; $i < $n; $i++) {
+ $rest += ($ys[$i] - ($achse + $steigung * $xs[$i])) ** 2;
+ }
+ $streu = $n > 2 ? sqrt($rest / ($n - 2)) : 0;
+ $fehler = $streu / sqrt($sxx);
+ $antwort["trend"] = [
+ "steigung" => round($steigung, 3),
+ "fehler" => round($fehler, 3),
+ "achse" => round($achse, 2),
+ "prozent" => $achse != 0 ? round(100 * $steigung / $achse, 2) : null,
+ "nachweisbar" => $achse != 0 ? round(100 * 2 * $fehler / $achse, 1) : null,
+ "spanne_jahre" => round(max($xs), 2),
+ ];
+ }
+ }
+
+ // Median je Jahr - der Stand, um den die Wolke streut.
+ $jahre = [];
+ foreach ($antwort["ladungen"] as $l) {
+ $j = substr($l["beginn"], 0, 4);
+ $jahre[$j][] = floatval($l["je100"]);
+ }
+ $antwort["jahre"] = [];
+ foreach ($jahre as $j => $werte) {
+ sort($werte);
+ $k = count($werte);
+ $antwort["jahre"][] = [
+ "jahr" => $j,
+ "n" => $k,
+ "q1" => round($werte[intdiv($k, 4)], 2),
+ "median" => round($werte[intdiv($k, 2)], 2),
+ "q3" => round($werte[intdiv(3 * $k, 4)], 2),
+ ];
+ }
+ break;
+
+/* --------------------------------------------------------------- fahrten -- */
+case "fahrten":
+ /*
+ * Eine Fahrt liegt zwischen zwei Parkständen. Die API liefert nur die
+ * letzte bekannte PARKposition - fährt das Fahrzeug, kommt allein
+ * IN_MOTION ohne Koordinaten. Start und Ziel sind damit bekannt, die
+ * Strecke dazwischen nicht.
+ *
+ * CAST(... AS SIGNED) ist nötig: soc und odo_km sind vorzeichenlos, und
+ * eine Differenz unter null bricht die Abfrage sonst ab - genau das
+ * passiert, sobald zwischen zwei Ständen geladen wurde.
+ */
+ $sql = "SELECT * FROM (
+ SELECT datetime AS ankunft, address AS nach, lat, lon, odo_km, soc,
+ LAG(datetime) OVER w AS abfahrt,
+ LAG(address) OVER w AS von,
+ LAG(lat) OVER w AS von_lat,
+ LAG(lon) OVER w AS von_lon,
+ CAST(odo_km AS SIGNED) - LAG(CAST(odo_km AS SIGNED)) OVER w AS km,
+ LAG(CAST(soc AS SIGNED)) OVER w - CAST(soc AS SIGNED) AS soc_ab
+ FROM skoda
+ WHERE park_state = 'PARKED' AND odo_km IS NOT NULL
+ AND datetime >= NOW() - INTERVAL $tage DAY
+ WINDOW w AS (ORDER BY datetime)) f
+ WHERE km > 0
+ ORDER BY ankunft DESC LIMIT 100";
+ $res = $mysql->query($sql);
+ $antwort["fahrten"] = [];
+ while ($res && $z = $res->fetch_assoc()) { $antwort["fahrten"][] = $z; }
+
+ // Aktueller Standort, für die Karte.
+ $res = $mysql->query("SELECT datetime, lat, lon, address, park_state
+ FROM skoda WHERE lat IS NOT NULL
+ ORDER BY id DESC LIMIT 1");
+ $antwort["standort"] = $res ? $res->fetch_assoc() : null;
+
+ $antwort["kapazitaet"] = kapazitaetSchaetzen($mysql);
+ break;
+
+default:
+ http_response_code(400);
+ $antwort = ["error" => "Unbekannter Abschnitt."];
+}
+
+echo json_encode($antwort, JSON_UNESCAPED_UNICODE);
diff --git a/ajax/skodaCmd.php b/ajax/skodaCmd.php
new file mode 100644
index 0000000..5208302
--- /dev/null
+++ b/ajax/skodaCmd.php
@@ -0,0 +1,269 @@
+ (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;
+ }
+}
diff --git a/ajax/tile.php b/ajax/tile.php
new file mode 100644
index 0000000..7039c79
--- /dev/null
+++ b/ajax/tile.php
@@ -0,0 +1,290 @@
+ ZOOM_MAX
+ || $x === false || $x === null || $x < 0 || $x >= $grenze
+ || $y === false || $y === null || $y < 0 || $y >= $grenze) {
+ http_response_code(400);
+ exit;
+}
+
+$pfad = speicherOrt() . "/$z/$x/$y.png";
+
+/* -------------------------------------------------------------- Ausliefern */
+
+$vorhanden = is_file($pfad);
+$veraltet = $vorhanden && (time() - filemtime($pfad) > AUFFRISCHEN_NACH);
+
+if (!$vorhanden || $veraltet) {
+ if (holeUndSpeichere($z, $x, $y, $pfad)) {
+ $vorhanden = true;
+ }
+}
+
+if ($vorhanden) {
+ liefereDatei($pfad);
+} else {
+ liefereErsatzkachel();
+}
+exit;
+
+/* ------------------------------------------------------------- Bausteine -- */
+
+/**
+ * Der erste beschreibbare Ort aus SPEICHER_ORTE, einmal je Aufruf ermittelt.
+ * Existiert keiner, wird der letzte angelegt - der liegt im Systemverzeichnis
+ * für flüchtige Dateien und ist immer beschreibbar.
+ */
+function speicherOrt()
+{
+ static $ort = null;
+ if ($ort !== null) {
+ return $ort;
+ }
+ foreach (SPEICHER_ORTE as $kandidat) {
+ if (is_dir($kandidat) && is_writable($kandidat)) {
+ return $ort = $kandidat;
+ }
+ }
+ $ort = SPEICHER_ORTE[count(SPEICHER_ORTE) - 1];
+ if (!is_dir($ort)) {
+ @mkdir($ort, 0755, true);
+ }
+ return $ort;
+}
+
+/**
+ * Eine Kachel bei der Quelle holen und ablegen.
+ * Gibt true zurück, wenn danach eine brauchbare Datei vorliegt.
+ */
+function holeUndSpeichere($z, $x, $y, $pfad)
+{
+ if (!bremseErlaubt()) {
+ return is_file($pfad); // notfalls die veraltete Kachel
+ }
+
+ $url = str_replace(["{z}", "{x}", "{y}"], [$z, $x, $y], KACHEL_QUELLE);
+
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_USERAGENT => KENNUNG,
+ CURLOPT_TIMEOUT => 8,
+ CURLOPT_CONNECTTIMEOUT => 4,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 2,
+ ]);
+ $inhalt = curl_exec($ch);
+ $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+
+ // Auf den PNG-Kopf prüfen, statt nur auf den Statuscode: eine Fehlerseite
+ // mit Status 200 wäre sonst als Kachel im Speicher gelandet und bliebe
+ // dort für immer.
+ if ($status !== 200 || $inhalt === false || strncmp($inhalt, "\x89PNG", 4) !== 0) {
+ return is_file($pfad);
+ }
+
+ if (!is_dir(dirname($pfad)) && !mkdir(dirname($pfad), 0755, true)) {
+ return is_file($pfad);
+ }
+
+ // Erst daneben schreiben, dann umbenennen. Sonst könnte ein zweiter
+ // Aufruf eine halb geschriebene Datei ausliefern.
+ $temp = $pfad . "." . getmypid() . ".teil";
+ if (file_put_contents($temp, $inhalt) === false) {
+ return is_file($pfad);
+ }
+ if (!rename($temp, $pfad)) {
+ unlink($temp);
+ return is_file($pfad);
+ }
+ return true;
+}
+
+/**
+ * Darf jetzt noch eine Kachel geholt werden?
+ *
+ * Gezählt wird in einer kleinen Datei, gegen gleichzeitige Zugriffe mit
+ * flock abgesichert. Zwei Fenster: eines je Minute, eines je Stunde. Läuft
+ * ein Fenster ab, fängt sein Zähler wieder bei null an.
+ */
+function bremseErlaubt()
+{
+ $datei = speicherOrt() . "/.bremse.json";
+
+ $griff = fopen($datei, "c+");
+ if (!$griff) {
+ return true;
+ }
+ if (!flock($griff, LOCK_EX)) {
+ fclose($griff);
+ return true;
+ }
+
+ $stand = json_decode(stream_get_contents($griff), true);
+ if (!is_array($stand)) {
+ $stand = ["minute" => 0, "min_n" => 0, "stunde" => 0, "std_n" => 0];
+ }
+
+ $jetzt = time();
+ if ($jetzt - $stand["minute"] >= 60) { $stand["minute"] = $jetzt; $stand["min_n"] = 0; }
+ if ($jetzt - $stand["stunde"] >= 3600) { $stand["stunde"] = $jetzt; $stand["std_n"] = 0; }
+
+ $erlaubt = $stand["min_n"] < HOLEN_JE_MINUTE && $stand["std_n"] < HOLEN_JE_STUNDE;
+ if ($erlaubt) {
+ $stand["min_n"]++;
+ $stand["std_n"]++;
+ }
+
+ ftruncate($griff, 0);
+ rewind($griff);
+ fwrite($griff, json_encode($stand));
+ flock($griff, LOCK_UN);
+ fclose($griff);
+
+ if (!$erlaubt) {
+ error_log("tile.php: Bremse greift, keine weiteren Kacheln in diesem Fenster.");
+ }
+ return $erlaubt;
+}
+
+/** Eine gespeicherte Kachel ausliefern, mit Unterstützung für 304. */
+function liefereDatei($pfad)
+{
+ $zeit = filemtime($pfad);
+ $marke = '"' . md5($pfad . $zeit) . '"';
+
+ header("Content-Type: image/png");
+ header("Cache-Control: public, max-age=" . BROWSER_FRIST);
+ header("ETag: $marke");
+ header("Last-Modified: " . gmdate("D, d M Y H:i:s", $zeit) . " GMT");
+
+ $bekannt = isset($_SERVER["HTTP_IF_NONE_MATCH"]) ? trim($_SERVER["HTTP_IF_NONE_MATCH"]) : "";
+ if ($bekannt === $marke) {
+ http_response_code(304);
+ return;
+ }
+
+ header("Content-Length: " . filesize($pfad));
+ readfile($pfad);
+}
+
+/**
+ * Graue Ersatzkachel, wenn nichts zu holen war - kein Internet, Quelle
+ * gestört oder Bremse aktiv. Ein leeres Feld ist besser als ein kaputtes
+ * Bildsymbol, und es wird bewusst nicht zwischengespeichert.
+ */
+function liefereErsatzkachel()
+{
+ header("Content-Type: image/png");
+ header("Cache-Control: no-store");
+ // 1x1 Punkt in Grau, von Leaflet auf Kachelgröße gezogen.
+ echo base64_decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
+ . "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
+}
diff --git a/css/images/layers-2x.png b/css/images/layers-2x.png
new file mode 100644
index 0000000..200c333
Binary files /dev/null and b/css/images/layers-2x.png differ
diff --git a/css/images/layers.png b/css/images/layers.png
new file mode 100644
index 0000000..1a72e57
Binary files /dev/null and b/css/images/layers.png differ
diff --git a/css/images/marker-icon-2x.png b/css/images/marker-icon-2x.png
new file mode 100644
index 0000000..88f9e50
Binary files /dev/null and b/css/images/marker-icon-2x.png differ
diff --git a/css/images/marker-icon.png b/css/images/marker-icon.png
new file mode 100644
index 0000000..950edf2
Binary files /dev/null and b/css/images/marker-icon.png differ
diff --git a/css/images/marker-shadow.png b/css/images/marker-shadow.png
new file mode 100644
index 0000000..9fd2979
Binary files /dev/null and b/css/images/marker-shadow.png differ
diff --git a/css/leaflet.css b/css/leaflet.css
new file mode 100644
index 0000000..9ade8dc
--- /dev/null
+++ b/css/leaflet.css
@@ -0,0 +1,661 @@
+/* required styles */
+
+.leaflet-pane,
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-tile-container,
+.leaflet-pane > svg,
+.leaflet-pane > canvas,
+.leaflet-zoom-box,
+.leaflet-image-layer,
+.leaflet-layer {
+ position: absolute;
+ left: 0;
+ top: 0;
+ }
+.leaflet-container {
+ overflow: hidden;
+ }
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ user-select: none;
+ -webkit-user-drag: none;
+ }
+/* Prevents IE11 from highlighting tiles in blue */
+.leaflet-tile::selection {
+ background: transparent;
+}
+/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
+.leaflet-safari .leaflet-tile {
+ image-rendering: -webkit-optimize-contrast;
+ }
+/* hack that prevents hw layers "stretching" when loading new tiles */
+.leaflet-safari .leaflet-tile-container {
+ width: 1600px;
+ height: 1600px;
+ -webkit-transform-origin: 0 0;
+ }
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+ display: block;
+ }
+/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
+/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
+.leaflet-container .leaflet-overlay-pane svg {
+ max-width: none !important;
+ max-height: none !important;
+ }
+.leaflet-container .leaflet-marker-pane img,
+.leaflet-container .leaflet-shadow-pane img,
+.leaflet-container .leaflet-tile-pane img,
+.leaflet-container img.leaflet-image-layer,
+.leaflet-container .leaflet-tile {
+ max-width: none !important;
+ max-height: none !important;
+ width: auto;
+ padding: 0;
+ }
+
+.leaflet-container img.leaflet-tile {
+ /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
+ mix-blend-mode: plus-lighter;
+}
+
+.leaflet-container.leaflet-touch-zoom {
+ -ms-touch-action: pan-x pan-y;
+ touch-action: pan-x pan-y;
+ }
+.leaflet-container.leaflet-touch-drag {
+ -ms-touch-action: pinch-zoom;
+ /* Fallback for FF which doesn't support pinch-zoom */
+ touch-action: none;
+ touch-action: pinch-zoom;
+}
+.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
+ -ms-touch-action: none;
+ touch-action: none;
+}
+.leaflet-container {
+ -webkit-tap-highlight-color: transparent;
+}
+.leaflet-container a {
+ -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
+}
+.leaflet-tile {
+ filter: inherit;
+ visibility: hidden;
+ }
+.leaflet-tile-loaded {
+ visibility: inherit;
+ }
+.leaflet-zoom-box {
+ width: 0;
+ height: 0;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ z-index: 800;
+ }
+/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
+.leaflet-overlay-pane svg {
+ -moz-user-select: none;
+ }
+
+.leaflet-pane { z-index: 400; }
+
+.leaflet-tile-pane { z-index: 200; }
+.leaflet-overlay-pane { z-index: 400; }
+.leaflet-shadow-pane { z-index: 500; }
+.leaflet-marker-pane { z-index: 600; }
+.leaflet-tooltip-pane { z-index: 650; }
+.leaflet-popup-pane { z-index: 700; }
+
+.leaflet-map-pane canvas { z-index: 100; }
+.leaflet-map-pane svg { z-index: 200; }
+
+.leaflet-vml-shape {
+ width: 1px;
+ height: 1px;
+ }
+.lvml {
+ behavior: url(#default#VML);
+ display: inline-block;
+ position: absolute;
+ }
+
+
+/* control positioning */
+
+.leaflet-control {
+ position: relative;
+ z-index: 800;
+ pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
+ pointer-events: auto;
+ }
+.leaflet-top,
+.leaflet-bottom {
+ position: absolute;
+ z-index: 1000;
+ pointer-events: none;
+ }
+.leaflet-top {
+ top: 0;
+ }
+.leaflet-right {
+ right: 0;
+ }
+.leaflet-bottom {
+ bottom: 0;
+ }
+.leaflet-left {
+ left: 0;
+ }
+.leaflet-control {
+ float: left;
+ clear: both;
+ }
+.leaflet-right .leaflet-control {
+ float: right;
+ }
+.leaflet-top .leaflet-control {
+ margin-top: 10px;
+ }
+.leaflet-bottom .leaflet-control {
+ margin-bottom: 10px;
+ }
+.leaflet-left .leaflet-control {
+ margin-left: 10px;
+ }
+.leaflet-right .leaflet-control {
+ margin-right: 10px;
+ }
+
+
+/* zoom and fade animations */
+
+.leaflet-fade-anim .leaflet-popup {
+ opacity: 0;
+ -webkit-transition: opacity 0.2s linear;
+ -moz-transition: opacity 0.2s linear;
+ transition: opacity 0.2s linear;
+ }
+.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
+ opacity: 1;
+ }
+.leaflet-zoom-animated {
+ -webkit-transform-origin: 0 0;
+ -ms-transform-origin: 0 0;
+ transform-origin: 0 0;
+ }
+svg.leaflet-zoom-animated {
+ will-change: transform;
+}
+
+.leaflet-zoom-anim .leaflet-zoom-animated {
+ -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
+ -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
+ transition: transform 0.25s cubic-bezier(0,0,0.25,1);
+ }
+.leaflet-zoom-anim .leaflet-tile,
+.leaflet-pan-anim .leaflet-tile {
+ -webkit-transition: none;
+ -moz-transition: none;
+ transition: none;
+ }
+
+.leaflet-zoom-anim .leaflet-zoom-hide {
+ visibility: hidden;
+ }
+
+
+/* cursors */
+
+.leaflet-interactive {
+ cursor: pointer;
+ }
+.leaflet-grab {
+ cursor: -webkit-grab;
+ cursor: -moz-grab;
+ cursor: grab;
+ }
+.leaflet-crosshair,
+.leaflet-crosshair .leaflet-interactive {
+ cursor: crosshair;
+ }
+.leaflet-popup-pane,
+.leaflet-control {
+ cursor: auto;
+ }
+.leaflet-dragging .leaflet-grab,
+.leaflet-dragging .leaflet-grab .leaflet-interactive,
+.leaflet-dragging .leaflet-marker-draggable {
+ cursor: move;
+ cursor: -webkit-grabbing;
+ cursor: -moz-grabbing;
+ cursor: grabbing;
+ }
+
+/* marker & overlays interactivity */
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-image-layer,
+.leaflet-pane > svg path,
+.leaflet-tile-container {
+ pointer-events: none;
+ }
+
+.leaflet-marker-icon.leaflet-interactive,
+.leaflet-image-layer.leaflet-interactive,
+.leaflet-pane > svg path.leaflet-interactive,
+svg.leaflet-image-layer.leaflet-interactive path {
+ pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
+ pointer-events: auto;
+ }
+
+/* visual tweaks */
+
+.leaflet-container {
+ background: #ddd;
+ outline-offset: 1px;
+ }
+.leaflet-container a {
+ color: #0078A8;
+ }
+.leaflet-zoom-box {
+ border: 2px dotted #38f;
+ background: rgba(255,255,255,0.5);
+ }
+
+
+/* general typography */
+.leaflet-container {
+ font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
+ font-size: 12px;
+ font-size: 0.75rem;
+ line-height: 1.5;
+ }
+
+
+/* general toolbar styles */
+
+.leaflet-bar {
+ box-shadow: 0 1px 5px rgba(0,0,0,0.65);
+ border-radius: 4px;
+ }
+.leaflet-bar a {
+ background-color: #fff;
+ border-bottom: 1px solid #ccc;
+ width: 26px;
+ height: 26px;
+ line-height: 26px;
+ display: block;
+ text-align: center;
+ text-decoration: none;
+ color: black;
+ }
+.leaflet-bar a,
+.leaflet-control-layers-toggle {
+ background-position: 50% 50%;
+ background-repeat: no-repeat;
+ display: block;
+ }
+.leaflet-bar a:hover,
+.leaflet-bar a:focus {
+ background-color: #f4f4f4;
+ }
+.leaflet-bar a:first-child {
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ }
+.leaflet-bar a:last-child {
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+ border-bottom: none;
+ }
+.leaflet-bar a.leaflet-disabled {
+ cursor: default;
+ background-color: #f4f4f4;
+ color: #bbb;
+ }
+
+.leaflet-touch .leaflet-bar a {
+ width: 30px;
+ height: 30px;
+ line-height: 30px;
+ }
+.leaflet-touch .leaflet-bar a:first-child {
+ border-top-left-radius: 2px;
+ border-top-right-radius: 2px;
+ }
+.leaflet-touch .leaflet-bar a:last-child {
+ border-bottom-left-radius: 2px;
+ border-bottom-right-radius: 2px;
+ }
+
+/* zoom control */
+
+.leaflet-control-zoom-in,
+.leaflet-control-zoom-out {
+ font: bold 18px 'Lucida Console', Monaco, monospace;
+ text-indent: 1px;
+ }
+
+.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
+ font-size: 22px;
+ }
+
+
+/* layers control */
+
+.leaflet-control-layers {
+ box-shadow: 0 1px 5px rgba(0,0,0,0.4);
+ background: #fff;
+ border-radius: 5px;
+ }
+.leaflet-control-layers-toggle {
+ background-image: url(images/layers.png);
+ width: 36px;
+ height: 36px;
+ }
+.leaflet-retina .leaflet-control-layers-toggle {
+ background-image: url(images/layers-2x.png);
+ background-size: 26px 26px;
+ }
+.leaflet-touch .leaflet-control-layers-toggle {
+ width: 44px;
+ height: 44px;
+ }
+.leaflet-control-layers .leaflet-control-layers-list,
+.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
+ display: none;
+ }
+.leaflet-control-layers-expanded .leaflet-control-layers-list {
+ display: block;
+ position: relative;
+ }
+.leaflet-control-layers-expanded {
+ padding: 6px 10px 6px 6px;
+ color: #333;
+ background: #fff;
+ }
+.leaflet-control-layers-scrollbar {
+ overflow-y: scroll;
+ overflow-x: hidden;
+ padding-right: 5px;
+ }
+.leaflet-control-layers-selector {
+ margin-top: 2px;
+ position: relative;
+ top: 1px;
+ }
+.leaflet-control-layers label {
+ display: block;
+ font-size: 13px;
+ font-size: 1.08333em;
+ }
+.leaflet-control-layers-separator {
+ height: 0;
+ border-top: 1px solid #ddd;
+ margin: 5px -10px 5px -6px;
+ }
+
+/* Default icon URLs */
+.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
+ background-image: url(images/marker-icon.png);
+ }
+
+
+/* attribution and scale controls */
+
+.leaflet-container .leaflet-control-attribution {
+ background: #fff;
+ background: rgba(255, 255, 255, 0.8);
+ margin: 0;
+ }
+.leaflet-control-attribution,
+.leaflet-control-scale-line {
+ padding: 0 5px;
+ color: #333;
+ line-height: 1.4;
+ }
+.leaflet-control-attribution a {
+ text-decoration: none;
+ }
+.leaflet-control-attribution a:hover,
+.leaflet-control-attribution a:focus {
+ text-decoration: underline;
+ }
+.leaflet-attribution-flag {
+ display: inline !important;
+ vertical-align: baseline !important;
+ width: 1em;
+ height: 0.6669em;
+ }
+.leaflet-left .leaflet-control-scale {
+ margin-left: 5px;
+ }
+.leaflet-bottom .leaflet-control-scale {
+ margin-bottom: 5px;
+ }
+.leaflet-control-scale-line {
+ border: 2px solid #777;
+ border-top: none;
+ line-height: 1.1;
+ padding: 2px 5px 1px;
+ white-space: nowrap;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ background: rgba(255, 255, 255, 0.8);
+ text-shadow: 1px 1px #fff;
+ }
+.leaflet-control-scale-line:not(:first-child) {
+ border-top: 2px solid #777;
+ border-bottom: none;
+ margin-top: -2px;
+ }
+.leaflet-control-scale-line:not(:first-child):not(:last-child) {
+ border-bottom: 2px solid #777;
+ }
+
+.leaflet-touch .leaflet-control-attribution,
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+ box-shadow: none;
+ }
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+ border: 2px solid rgba(0,0,0,0.2);
+ background-clip: padding-box;
+ }
+
+
+/* popup */
+
+.leaflet-popup {
+ position: absolute;
+ text-align: center;
+ margin-bottom: 20px;
+ }
+.leaflet-popup-content-wrapper {
+ padding: 1px;
+ text-align: left;
+ border-radius: 12px;
+ }
+.leaflet-popup-content {
+ margin: 13px 24px 13px 20px;
+ line-height: 1.3;
+ font-size: 13px;
+ font-size: 1.08333em;
+ min-height: 1px;
+ }
+.leaflet-popup-content p {
+ margin: 17px 0;
+ margin: 1.3em 0;
+ }
+.leaflet-popup-tip-container {
+ width: 40px;
+ height: 20px;
+ position: absolute;
+ left: 50%;
+ margin-top: -1px;
+ margin-left: -20px;
+ overflow: hidden;
+ pointer-events: none;
+ }
+.leaflet-popup-tip {
+ width: 17px;
+ height: 17px;
+ padding: 1px;
+
+ margin: -10px auto 0;
+ pointer-events: auto;
+
+ -webkit-transform: rotate(45deg);
+ -moz-transform: rotate(45deg);
+ -ms-transform: rotate(45deg);
+ transform: rotate(45deg);
+ }
+.leaflet-popup-content-wrapper,
+.leaflet-popup-tip {
+ background: white;
+ color: #333;
+ box-shadow: 0 3px 14px rgba(0,0,0,0.4);
+ }
+.leaflet-container a.leaflet-popup-close-button {
+ position: absolute;
+ top: 0;
+ right: 0;
+ border: none;
+ text-align: center;
+ width: 24px;
+ height: 24px;
+ font: 16px/24px Tahoma, Verdana, sans-serif;
+ color: #757575;
+ text-decoration: none;
+ background: transparent;
+ }
+.leaflet-container a.leaflet-popup-close-button:hover,
+.leaflet-container a.leaflet-popup-close-button:focus {
+ color: #585858;
+ }
+.leaflet-popup-scrolled {
+ overflow: auto;
+ }
+
+.leaflet-oldie .leaflet-popup-content-wrapper {
+ -ms-zoom: 1;
+ }
+.leaflet-oldie .leaflet-popup-tip {
+ width: 24px;
+ margin: 0 auto;
+
+ -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
+ filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
+ }
+
+.leaflet-oldie .leaflet-control-zoom,
+.leaflet-oldie .leaflet-control-layers,
+.leaflet-oldie .leaflet-popup-content-wrapper,
+.leaflet-oldie .leaflet-popup-tip {
+ border: 1px solid #999;
+ }
+
+
+/* div icon */
+
+.leaflet-div-icon {
+ background: #fff;
+ border: 1px solid #666;
+ }
+
+
+/* Tooltip */
+/* Base styles for the element that has a tooltip */
+.leaflet-tooltip {
+ position: absolute;
+ padding: 6px;
+ background-color: #fff;
+ border: 1px solid #fff;
+ border-radius: 3px;
+ color: #222;
+ white-space: nowrap;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ pointer-events: none;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.4);
+ }
+.leaflet-tooltip.leaflet-interactive {
+ cursor: pointer;
+ pointer-events: auto;
+ }
+.leaflet-tooltip-top:before,
+.leaflet-tooltip-bottom:before,
+.leaflet-tooltip-left:before,
+.leaflet-tooltip-right:before {
+ position: absolute;
+ pointer-events: none;
+ border: 6px solid transparent;
+ background: transparent;
+ content: "";
+ }
+
+/* Directions */
+
+.leaflet-tooltip-bottom {
+ margin-top: 6px;
+}
+.leaflet-tooltip-top {
+ margin-top: -6px;
+}
+.leaflet-tooltip-bottom:before,
+.leaflet-tooltip-top:before {
+ left: 50%;
+ margin-left: -6px;
+ }
+.leaflet-tooltip-top:before {
+ bottom: 0;
+ margin-bottom: -12px;
+ border-top-color: #fff;
+ }
+.leaflet-tooltip-bottom:before {
+ top: 0;
+ margin-top: -12px;
+ margin-left: -6px;
+ border-bottom-color: #fff;
+ }
+.leaflet-tooltip-left {
+ margin-left: -6px;
+}
+.leaflet-tooltip-right {
+ margin-left: 6px;
+}
+.leaflet-tooltip-left:before,
+.leaflet-tooltip-right:before {
+ top: 50%;
+ margin-top: -6px;
+ }
+.leaflet-tooltip-left:before {
+ right: 0;
+ margin-right: -12px;
+ border-left-color: #fff;
+ }
+.leaflet-tooltip-right:before {
+ left: 0;
+ margin-left: -12px;
+ border-right-color: #fff;
+ }
+
+/* Printing */
+
+@media print {
+ /* Prevent printers from removing background-images of controls. */
+ .leaflet-control {
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
+ }
diff --git a/css/solar.css b/css/solar.css
index c13f101..20851b9 100644
--- a/css/solar.css
+++ b/css/solar.css
@@ -375,3 +375,534 @@ input[type='range']::-webkit-slider-runnable-track {
fill: currentColor;
opacity: 0.85;
}
+
+/* --- Einstellungen: Preistabellen ------------------------------------ */
+
+/* Der Spaltenkopf steht ueber der ersten Zeile und muss deren Einrueckung
+ treffen, sonst rutschen Beschriftung und Feld auseinander. Er bleibt
+ einzeilig, braucht also keinen senkrechten Gutter - und ohne den entfaellt
+ auch Bootstraps negative Marge von selbst. */
+.preis-kopf {
+ --bs-gutter-y: 0;
+ padding: 0 0.25rem 0.35rem;
+}
+
+/* Eine Zeile ist selbst eine .row mit g-2. Bootstrap zieht so eine Reihe um
+ die Gutter-Hoehe nach oben und gibt jeder Spalte dieselbe Marge nach unten
+ zurueck - gedacht fuer Reihen, die nebeneinander im Raster stehen. Hier
+ stehen sie untereinander, und der Zug nach oben frisst den unteren
+ Innenabstand der vorigen Zeile auf: auf dem Handy, wo der Papierkorb in
+ einer eigenen Zeile landet, klebte er am Trennstrich.
+
+ Die senkrechte Gutter-Marge wird hier gebraucht - sie haelt die umbrochenen
+ Feldzeilen auseinander -, der Zug nach oben nicht. Die Regel braucht eine
+ Klasse mehr als Bootstraps .row: solar.css wird vor AdminLTE geladen und
+ verliert bei gleicher Spezifitaet. */
+.preis-zeilen .preis-zeile {
+ margin-top: 0;
+}
+
+/* Oben um die Gutter-Hoehe weniger Innenabstand, damit ueber der ersten und
+ unter der letzten Feldzeile gleich viel Luft steht. */
+.preis-zeile {
+ padding: 0.15rem 0.25rem 0.65rem;
+ border-top: 1px solid rgba(128, 128, 128, 0.25);
+}
+/* Die Einheit ist Beiwerk und darf der Zahl nicht den Platz nehmen: hinter
+ "kWh/100 km" bliebe in voller Schrift kaum ein Drittel der Spalte fuer das
+ Eingabefeld uebrig. */
+.preis-zeile .input-group-text {
+ font-size: 0.75rem;
+ padding-left: 0.4rem;
+ padding-right: 0.4rem;
+}
+
+/* Der Zeitraum, der heute gilt - der einzige, den man beim Nachschlagen
+ wirklich sucht. */
+.preis-heute {
+ background: rgba(0, 120, 143, 0.12);
+ border-radius: 0.4rem;
+}
+
+
+/* ==========================================================================
+ Skoda-Seite (restricted/skoda.php)
+
+ Farben kommen aus den Bootstrap-Variablen des Themes, damit die Seite dem
+ Hell-/Dunkelmodus folgt, ohne eine zweite Palette zu pflegen. Nur drei
+ Bedeutungsfarben stehen fuer sich: bernstein fuer "offen", gelb fuer
+ eingeschaltetes Licht, gruen fuer Laden.
+ ========================================================================== */
+
+.skoda-auto,
+.skoda-teile,
+.skoda-karte {
+ --skoda-offen: #e0904f;
+ --skoda-licht: #f2d16b;
+ --skoda-laden: #4caf7d;
+ --skoda-blech: 128, 138, 148;
+}
+
+/* --- Fahrzeug in Draufsicht ----------------------------------------------
+ Massstaeblich: 4648 x 1877 mm ergeben das Seitenverhaeltnis 2,48:1, der
+ Radstand von 2765 mm die Lage der Raeder. */
+.skoda-auto {
+ width: 100%;
+ max-width: 215px;
+ height: auto;
+}
+
+.skoda-auto .karosserie {
+ fill: rgba(var(--skoda-blech), 0.17);
+ stroke: rgba(var(--skoda-blech), 0.55);
+ stroke-width: 2;
+}
+
+.skoda-auto .rad rect { fill: rgba(18, 20, 24, 0.9); }
+.skoda-auto .spiegel rect { fill: rgba(var(--skoda-blech), 0.55); }
+.skoda-auto .reling rect { fill: rgba(var(--skoda-blech), 0.62); }
+.skoda-auto .spoiler { fill: rgba(var(--skoda-blech), 0.5); }
+.skoda-auto .sicke {
+ fill: none;
+ stroke: rgba(var(--skoda-blech), 0.35);
+ stroke-width: 1.5;
+}
+
+/* Das Tech-Deck Face: beim Enyaq eine glaenzend schwarze Blende ueber die
+ volle Bugbreite. Von oben sieht man ihre Oberkante. */
+.skoda-auto .techdeck {
+ fill: rgba(12, 14, 18, 0.92);
+ stroke: rgba(var(--skoda-blech), 0.4);
+ stroke-width: 1;
+}
+
+.skoda-auto .scheibe,
+.skoda-auto .dach {
+ fill: rgba(var(--skoda-blech), 0.28);
+ stroke: rgba(var(--skoda-blech), 0.45);
+ stroke-width: 1.5;
+}
+
+/* Grundzustand aller schaltbaren Teile: geschlossen und unauffaellig. */
+.skoda-auto .teil > *,
+.skoda-auto .teil > g > * {
+ fill: rgba(var(--skoda-blech), 0.34);
+ stroke: rgba(var(--skoda-blech), 0.6);
+ stroke-width: 1.5;
+ transition: fill .25s, stroke .25s, filter .25s;
+}
+
+.skoda-auto .teil-fenster > rect { fill: rgba(var(--skoda-blech), 0.5); }
+
+/* Offen: bernstein und leicht leuchtend. Die Farbe allein traegt die Aussage
+ nicht - die Zeile unter dem Bild benennt jedes offene Teil zusaetzlich. */
+.skoda-auto .teil.offen > *,
+.skoda-auto .teil.offen > g > * {
+ fill: var(--skoda-offen);
+ stroke: var(--skoda-offen);
+ filter: drop-shadow(0 0 6px rgba(224, 144, 79, 0.65));
+}
+
+/* Licht: vorn die vier LED-Elemente je Seite, hinten das Leuchtenband. */
+.skoda-auto .teil-licht.an > rect {
+ fill: var(--skoda-licht);
+ stroke: var(--skoda-licht);
+ filter: drop-shadow(0 0 8px rgba(242, 209, 107, 0.75));
+}
+.skoda-auto .teil-licht.an > rect.rueck {
+ fill: #e06060;
+ stroke: #e06060;
+ filter: drop-shadow(0 0 8px rgba(224, 96, 96, 0.7));
+}
+
+.skoda-auto .teil-stecker > path { fill: none; stroke-width: 2; }
+.skoda-auto .teil-stecker.laedt > circle {
+ fill: var(--skoda-laden);
+ stroke: var(--skoda-laden);
+ animation: skodaPuls 1.8s ease-in-out infinite;
+}
+.skoda-auto .teil-stecker.laedt > path { stroke: #0d1210; }
+
+@keyframes skodaPuls {
+ 0%, 100% { filter: drop-shadow(0 0 3px rgba(76, 175, 125, 0.5)); }
+ 50% { filter: drop-shadow(0 0 12px rgba(76, 175, 125, 0.95)); }
+}
+
+/* Hervorheben, wenn der zugehoerige Eintrag darunter beruehrt wird - und
+ umgekehrt. Sichtbarer Fokus, damit das auch mit der Tastatur geht. */
+.skoda-auto .teil.hervor > *,
+.skoda-auto .teil.hervor > g > * {
+ stroke: var(--bs-primary, #0d6efd);
+ stroke-width: 2.5;
+}
+@media (hover: hover) {
+ .skoda-auto .teil:hover > *,
+ .skoda-auto .teil > g:hover > * {
+ stroke: var(--bs-primary, #0d6efd);
+ stroke-width: 2.5;
+ }
+}
+.skoda-auto .teil:focus,
+.skoda-auto .teil > g:focus { outline: none; }
+.skoda-auto .teil:focus-visible > *,
+.skoda-auto .teil > g:focus-visible > * {
+ stroke: var(--bs-primary, #0d6efd);
+ stroke-width: 3;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .skoda-auto .teil > *,
+ .skoda-auto .teil > g > * { transition: none; }
+ .skoda-auto .teil-stecker.laedt > circle { animation: none; }
+}
+
+/* --- Zustandszeile unter dem Fahrzeug ------------------------------------ */
+.skoda-teile {
+ display: flex;
+ flex-wrap: wrap;
+ gap: .35rem;
+ justify-content: center;
+ margin-top: .9rem;
+}
+.skoda-teile .chip {
+ display: inline-flex;
+ align-items: center;
+ gap: .35rem;
+ font-size: .78rem;
+ padding: .2rem .55rem;
+ border-radius: 1rem;
+ border: 1px solid var(--bs-border-color, rgba(128, 128, 128, .35));
+ color: var(--bs-secondary-color, #9aa0a6);
+ cursor: default;
+ white-space: nowrap;
+}
+.skoda-teile .chip.offen { border-color: var(--skoda-offen); color: var(--skoda-offen); }
+.skoda-teile .chip.hervor {
+ border-color: var(--bs-primary, #0d6efd);
+ color: var(--bs-body-color, #e8ede9);
+}
+
+/* Auf Beruehrungsgeraeten sind die Ziele zu klein: eine Fingerkuppe deckt
+ rund 9 mm ab, die Chips waren keine 6 mm hoch. Gleiches gilt fuer die
+ Fahrtzeilen und den Umschalter. */
+@media (pointer: coarse) {
+ .skoda-teile { gap: .5rem; }
+ .skoda-teile .chip {
+ font-size: .85rem;
+ padding: .45rem .8rem;
+ cursor: pointer;
+ }
+ .skoda-liste .zeile { padding: .75rem .9rem; }
+ .skoda-umschalter button { font-size: .85rem; padding: .5rem .9rem; }
+}
+.skoda-teile .chip.an { border-color: var(--skoda-licht); color: var(--skoda-licht); }
+
+/* --- Ladestandsring ------------------------------------------------------ */
+.skoda-ring { width: 100%; max-width: 190px; height: auto; }
+.skoda-ring .ring-bahn {
+ fill: none;
+ stroke: rgba(128, 138, 148, 0.22);
+ stroke-width: 16;
+}
+.skoda-ring .ring-wert {
+ fill: none;
+ stroke: #4caf7d;
+ stroke-width: 16;
+ stroke-linecap: round;
+ transform: rotate(-90deg);
+ transform-origin: 100px 100px;
+ transition: stroke-dashoffset .6s ease, stroke .3s;
+}
+.skoda-ring .ring-ziel {
+ stroke: var(--bs-body-color, #e8ede9);
+ stroke-width: 3;
+ opacity: .75;
+ transform-origin: 100px 100px;
+}
+.skoda-ring .ring-zahl {
+ fill: var(--bs-body-color, #e8ede9);
+ font-size: 46px;
+ font-weight: 600;
+ text-anchor: middle;
+ font-variant-numeric: tabular-nums;
+}
+.skoda-ring .ring-einheit {
+ fill: var(--bs-secondary-color, #9aa0a6);
+ font-size: 14px;
+ letter-spacing: .12em;
+ text-anchor: middle;
+ text-transform: uppercase;
+}
+
+/* --- Wertetabelle -------------------------------------------------------- */
+.skoda-werte {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
+ gap: .75rem 1.25rem;
+}
+.skoda-wert { display: flex; flex-direction: column; }
+.skoda-wert .k {
+ font-size: .7rem;
+ letter-spacing: .09em;
+ text-transform: uppercase;
+ color: var(--bs-secondary-color, #9aa0a6);
+}
+.skoda-wert .v {
+ font-size: 1.15rem;
+ font-variant-numeric: tabular-nums;
+}
+
+/* --- Herkunft des Ladestroms --------------------------------------------- */
+.skoda-balken {
+ display: flex;
+ height: .7rem;
+ border-radius: .35rem;
+ overflow: hidden;
+ background: rgba(128, 138, 148, 0.2);
+}
+.skoda-balken > div { transition: width .5s ease; }
+.skoda-balken .teil-pv { background: #f0b429; }
+.skoda-balken .teil-netz { background: #7a8791; }
+
+/* --- Listen (Ladungen, Fahrten) ------------------------------------------ */
+/* Der Innenabstand oben trennt die erste Zeile von der Ueberschrift. Ohne
+ ihn klebt sie am Umschalter, was auf dem Handy besonders auffaellt. */
+.skoda-liste {
+ max-height: 400px;
+ overflow-y: auto;
+ padding-top: .5rem;
+}
+.skoda-liste .zeile {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: .75rem;
+ padding: .5rem .9rem;
+ border-top: 1px solid var(--bs-border-color, rgba(128, 128, 128, .25));
+}
+.skoda-liste .zeile:first-child { border-top: none; }
+@media (hover: hover) {
+ .skoda-liste .zeile:hover { background: rgba(128, 138, 148, 0.08); }
+}
+.skoda-liste .wann { font-size: .82rem; color: var(--bs-secondary-color, #9aa0a6); }
+.skoda-liste .was { font-size: .95rem; font-variant-numeric: tabular-nums; text-align: right; }
+.skoda-liste .neben { display: block; font-size: .75rem; color: var(--bs-secondary-color, #9aa0a6); }
+.skoda-liste .leer { padding: 1.5rem .9rem; color: var(--bs-secondary-color, #9aa0a6); }
+
+/* Umschalter kWh <-> Prozent in der Ueberschrift der Fahrtenliste. Welche
+ Einheit sichtbar ist, entscheidet die Klasse auf der Kachel - so bleibt die
+ Auswahl bestehen, wenn die Liste neu gebaut wird. */
+.zeigt-kwh .v-prozent,
+.zeigt-prozent .v-kwh,
+.zeigt-prozent #skodaVerbrauchBasis { display: none; }
+
+.skoda-umschalter {
+ display: inline-flex;
+ border: 1px solid var(--bs-border-color, rgba(128, 128, 128, .35));
+ border-radius: .3rem;
+ overflow: hidden;
+}
+.skoda-umschalter button {
+ background: none;
+ border: none;
+ color: var(--bs-secondary-color, #9aa0a6);
+ font-size: .72rem;
+ line-height: 1;
+ padding: .25rem .5rem;
+ cursor: pointer;
+}
+.skoda-umschalter button + button {
+ border-left: 1px solid var(--bs-border-color, rgba(128, 128, 128, .35));
+}
+.skoda-umschalter button:hover { background: rgba(128, 138, 148, .12); }
+.skoda-umschalter button.aktiv {
+ background: rgba(128, 138, 148, .22);
+ color: var(--bs-body-color, #e8ede9);
+}
+.skoda-umschalter button:focus-visible {
+ outline: 2px solid var(--bs-primary, #0d6efd);
+ outline-offset: -2px;
+}
+
+/* Fahrtzeilen, die sich auf der Karte zeigen lassen. Der Zeiger und die
+ Markierung sind der einzige Hinweis darauf - ohne den waere die Kopplung
+ nicht zu erraten. */
+.skoda-liste .zeile.anklickbar { cursor: pointer; }
+.skoda-liste .zeile.anklickbar:focus-visible {
+ outline: 2px solid var(--bs-primary, #0d6efd);
+ outline-offset: -2px;
+}
+/* Dieselbe Farbe wie die gewaehlte Linie auf der Karte - die Zuordnung
+ zwischen Zeile und Linie soll ohne Nachdenken erkennbar sein. */
+.skoda-liste .zeile.gewaehlt {
+ background: rgba(224, 144, 79, 0.14);
+ box-shadow: inset 3px 0 0 #e0904f;
+}
+
+/* --- Karte (Leaflet) -----------------------------------------------------
+ Die Kacheln kommen ueber ajax/tile.php aus dem eigenen Zwischenspeicher.
+ OpenStreetMap-Kacheln sind hell; im dunklen Dashboard wuerden sie wie ein
+ Scheinwerfer wirken. invert + hue-rotate dreht sie ins Dunkle, ohne einen
+ zweiten Anbieter samt eigener Bedingungen ins Spiel zu bringen. Der Filter
+ sitzt auf den Kacheln allein - Marker, Linien und Bedienelemente behalten
+ ihre Farben. */
+.skoda-karte {
+ height: 420px;
+ width: 100%;
+ border-radius: .4rem;
+ background: rgba(128, 138, 148, 0.12);
+ z-index: 0; /* sonst legt sich die Karte ueber die Seitenleiste */
+}
+
+.skoda-karte .leaflet-tile-pane {
+ filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(0.9);
+}
+
+/* Bedienelemente und Sprechblasen an das dunkle Theme angleichen. */
+.skoda-karte .leaflet-bar a,
+.skoda-karte .leaflet-bar a:hover {
+ background: #23282c;
+ color: var(--bs-body-color, #e8ede9);
+ border-bottom-color: #33393e;
+}
+.skoda-karte .leaflet-bar a:hover { background: #2d3338; }
+
+.skoda-karte .leaflet-popup-content-wrapper,
+.skoda-karte .leaflet-popup-tip {
+ background: #23282c;
+ color: var(--bs-body-color, #e8ede9);
+ box-shadow: 0 3px 14px rgba(0, 0, 0, .5);
+}
+.skoda-karte .leaflet-popup-content { margin: .6rem .8rem; line-height: 1.5; }
+.skoda-karte .leaflet-popup-close-button { color: var(--bs-secondary-color, #9aa0a6); }
+
+/* Die Nennung von OpenStreetMap ist Pflicht und darf nicht versteckt werden -
+ nur ruhiger gesetzt, damit sie das Bild nicht dominiert.
+
+ Ohne Hintergrundkasten, dafuer mit Schattenwurf: ein durchsichtiger
+ Hintergrund allein liesse den Text ueber hellen Kartenstellen verschwinden,
+ und unlesbar waere so gut wie versteckt. Der Schatten haelt ihn auf jedem
+ Untergrund erkennbar, ohne einen Balken ins Bild zu setzen. */
+/* Der doppelte Klassenname ist noetig, nicht Zierde: Leaflet setzt den
+ Hintergrund ueber .leaflet-container .leaflet-control-attribution, also mit
+ derselben Spezifitaet wie ein einfaches .skoda-karte davor - und leaflet.css
+ wird spaeter geladen, gewinnt bei Gleichstand also. Der Kartencontainer
+ traegt beide Klassen, damit sticht diese Regel. */
+.skoda-karte.leaflet-container .leaflet-control-attribution {
+ background: transparent;
+ color: #c9d0d6;
+ font-size: .68rem;
+ padding: 0 .4rem;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, .9), 0 0 4px rgba(0, 0, 0, .75);
+}
+.skoda-karte.leaflet-container .leaflet-control-attribution a {
+ color: #a9c8f0;
+ text-shadow: inherit;
+}
+
+.skoda-kartenlegende {
+ display: inline-flex;
+ gap: .9rem;
+ font-size: .75rem;
+ color: var(--bs-secondary-color, #9aa0a6);
+}
+.skoda-kartenlegende span { display: inline-flex; align-items: center; gap: .3rem; }
+.skoda-kartenlegende .pkt { width: .55rem; height: .55rem; border-radius: 50%; }
+.skoda-kartenlegende .pkt.jetzt { background: #4caf7d; }
+.skoda-kartenlegende .pkt.halt { background: #8a949c; }
+.skoda-kartenlegende .lin {
+ width: 1.1rem;
+ border-top: 2px dashed #8a949c;
+}
+
+/* --- Befund -------------------------------------------------------------- */
+.skoda-befund .zahl {
+ font-size: 2.4rem;
+ font-weight: 600;
+ line-height: 1.1;
+ font-variant-numeric: tabular-nums;
+}
+.skoda-befund .zahl .einheit {
+ font-size: .95rem;
+ font-weight: 400;
+ color: var(--bs-secondary-color, #9aa0a6);
+ margin-left: .35rem;
+}
+.skoda-befund dl { margin: 1rem 0 0; display: grid; gap: .5rem; }
+.skoda-befund dl > div { display: flex; justify-content: space-between; gap: 1rem; }
+.skoda-befund dt {
+ font-weight: 400;
+ color: var(--bs-secondary-color, #9aa0a6);
+ font-size: .85rem;
+}
+.skoda-befund dd { margin: 0; font-variant-numeric: tabular-nums; font-size: .9rem; }
+
+/* --- Steuerung ----------------------------------------------------------
+ Die Befehle sitzen im Modal, das ueber die anklickbaren Stellen im
+ Fahrzeugbild aufgeht. */
+.skoda-steuerung .knoepfe { display: flex; flex-wrap: wrap; gap: .5rem; }
+.skoda-steuerung .knoepfe button { flex: 1 1 11rem; }
+.skoda-steuerung .temperatur {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: .75rem;
+ font-size: .9rem;
+}
+.skoda-steuerung .temperatur span { display: flex; align-items: center; gap: .35rem; }
+.skoda-steuerung .temperatur input { width: 5.5rem; }
+
+@media (pointer: coarse) {
+ .skoda-steuerung .knoepfe button { padding: .7rem 1rem; }
+}
+
+.skoda-rueckmeldung {
+ font-size: .9rem;
+ padding: .5rem .75rem;
+ border-radius: .35rem;
+ display: none;
+}
+.skoda-rueckmeldung.warte,
+.skoda-rueckmeldung.gut,
+.skoda-rueckmeldung.schlecht { display: block; }
+.skoda-rueckmeldung.warte { background: rgba(128, 138, 148, .16); color: var(--bs-secondary-color, #9aa0a6); }
+.skoda-rueckmeldung.gut { background: rgba(76, 175, 125, .16); color: #6fd3a0; }
+.skoda-rueckmeldung.schlecht { background: rgba(224, 96, 96, .16); color: #f08a8a; }
+
+/* --- Anklickbare Stellen im Fahrzeugbild --------------------------------
+ Der Ring unterscheidet Bedienung von Anzeige: die uebrigen Teile zeigen
+ nur ihren Zustand, diese beiden aendern etwas. */
+.skoda-auto .steuer { cursor: pointer; }
+.skoda-auto .steuer .ring {
+ fill: none;
+ stroke: var(--bs-primary, #4b8fd6);
+ stroke-width: 1.5;
+ stroke-dasharray: 3 3;
+ opacity: .55;
+ transition: opacity .2s, stroke-width .2s;
+}
+.skoda-auto .steuer .flaeche {
+ fill: rgba(75, 143, 214, .22);
+ stroke: var(--bs-primary, #4b8fd6);
+ stroke-width: 1.5;
+}
+.skoda-auto .steuer .glyphe {
+ fill: none;
+ stroke: var(--bs-primary, #4b8fd6);
+ stroke-width: 1.8;
+ stroke-linecap: round;
+}
+@media (hover: hover) {
+ .skoda-auto .steuer:hover .ring { opacity: 1; stroke-width: 2.5; }
+}
+.skoda-auto .steuer:focus { outline: none; }
+.skoda-auto .steuer:focus-visible .ring { opacity: 1; stroke-width: 3; }
+
+.skoda-hinweis {
+ margin: .6rem 0 0;
+ font-size: .75rem;
+ color: var(--bs-secondary-color, #9aa0a6);
+ text-align: center;
+}
+
+.skoda-alter { font-size: .85rem; }
diff --git a/index.php b/index.php
index f8c371f..e6763e0 100644
--- a/index.php
+++ b/index.php
@@ -1,132 +1,136 @@
-
-
-
- Smarthome control
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-