Einstellungsseite, Skoda-Seite und Karte

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>
This commit is contained in:
2026-09-02 21:21:27 +02:00
co-authored by Claude Opus 5
parent c9a9e16b21
commit d285ec27a0
26 changed files with 4862 additions and 190 deletions
+82 -15
View File
@@ -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);
}
+97 -41
View File
@@ -1,6 +1,7 @@
<?php
require_once("../helper.php");
require_once("../restricted/costs.php");
if(!isset($_GET["year"])){
$_GET["year"] = date("Y");
}else{
@@ -17,47 +18,98 @@ if(isset($_GET["type"])){
}
}
$Query = "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"].";";
/**
* 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 = "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
$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 .= "</div><hr class='mt-2 mb-3 border-light' />";
$html .= "</div><hr class='mt-3 mb-3 border-light' />";
$html .= "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
}
/*$html .= "<div class='col-12 col-sm-4 col-md-2 col-xl-1'>
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* Endpunkt der Einstellungsseite.
*
* GET ?action=list&reihe=strom Zeitraeume einer Preistabelle
* POST ?action=save {"reihe": "strom", "zeilen": [ ... ]}
*
* Ein reiner Verteiler: geprueft und geschrieben wird in
* restricted/costs.php, damit Maske und Endpunkt dieselbe Wahrheit ueber
* einen Zeitraum haben.
*/
require_once("../helper.php");
require_once("../restricted/costs.php");
if (!checkLogin()) {
http_response_code(403);
exit;
}
function jsonAntwort($data, $code = 200)
{
http_response_code($code);
header("Content-Type: application/json; charset=utf-8");
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
$action = $_GET["action"] ?? "list";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$body = json_decode(file_get_contents("php://input"), true);
if (!is_array($body)) {
jsonAntwort(["error" => "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);
+366
View File
@@ -0,0 +1,366 @@
<?php
/*
* Daten der Škoda-Seite. Ein Endpunkt, mehrere Abschnitte:
*
* ?was=live letzter bekannter Stand, alle Spalten der jüngsten Zeile
* ?was=ladungen Ladevorgänge der letzten N Tage, aus Sitzungen gebildet
* ?was=kurve Verlauf einer einzelnen Ladung (SoC und kW über Zeit)
* ?was=gesundheit kWh je 100 Ladestandsprozent, je Ladung, mit Trend
* ?was=fahrten Fahrten zwischen zwei Parkständen, mit Koordinaten
*
* Die Fahrzeugdaten stehen in der Tabelle skoda, geschrieben von
* gatherSkodaData.py. Der Zeilentakt ist nicht fest: geschrieben wird, wenn
* das Fahrzeug etwas Neues meldet, mindestens stündlich. Alle Auswertungen
* hier müssen damit umgehen, dass die Abstände zwischen zwei Zeilen von vier
* Minuten bis zu einer Stunde reichen.
*
* Solange kein Fahrzeug angebunden ist, ist die Tabelle leer. Jeder Abschnitt
* liefert dann eine leere, aber wohlgeformte Antwort - die Seite zeigt
* daraufhin ihre Leerzustände und läuft nicht in Fehler.
*/
require_once("../helper.php");
header('Content-Type: application/json; charset=utf-8');
if (!checkLogin()) {
http_response_code(403);
echo json_encode(["error" => "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);
+269
View File
@@ -0,0 +1,269 @@
<?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;
}
}
+290
View File
@@ -0,0 +1,290 @@
<?php
/*
* Kachel-Zwischenspeicher für die Karte.
*
* Aufruf: tile.php?z=16&x=34567&y=22456
*
* Liegt die Kachel im Zwischenspeicher, kommt sie von dort. Fehlt sie, holt
* das NAS sie einmal bei OpenStreetMap, legt sie ab und liefert aus. Danach
* verlässt für diese Kachel nie wieder eine Anfrage das Haus.
*
* Warum überhaupt ein Zwischenspeicher und nicht der direkte Weg vom Browser
* zu tile.openstreetmap.org? Drei Gründe:
*
* 1. Die Kachelrichtlinie der OSM Foundation verlangt Zwischenspeicherung
* von mindestens sieben Tagen. Der direkte Weg erfüllt das nur, soweit
* der Browser mitspielt.
* 2. Ohne Zwischenspeicher ginge die Parkposition des Fahrzeugs - also die
* Wohnadresse - bei jedem Seitenaufruf als Kachelanfrage nach draußen.
* 3. Einmal geholte Gegenden bleiben ohne Internet benutzbar.
*
* Was die Richtlinie ausdrücklich verbietet, ist Vorratsspeicherung: Kacheln
* auf Verdacht holen, Gebiete vorab abgrasen, Offline-Archive anlegen. Dieses
* Skript holt deshalb ausschließlich das, was gerade jemand ansieht, und die
* Bremse unten begrenzt zusätzlich, wie viel das pro Minute sein darf.
*
* Richtlinie: https://operations.osmfoundation.org/policies/tiles/
*/
require_once("../helper.php");
/* ------------------------------------------------------------ Einstellungen */
/** Woher die Kacheln kommen. {z}/{x}/{y} werden ersetzt. */
const KACHEL_QUELLE = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
/*
* Die Richtlinie verlangt einen eigenen, kontaktierbaren User-Agent. Vorgaben
* von Bibliotheken werden ohne Vorwarnung geblockt. Die Domain genügt als
* Kontakt; wer lieber eine Mailadresse hinterlegt, ersetzt sie hier.
*/
const KENNUNG = "SmartController-SkodaKarte/1.0 (+https://nas.el-wa.org/smart)";
/*
* Wo die Kacheln liegen.
*
* Bevorzugt neben der Seite unter tiles/ - dort bleiben sie über Neustarts
* erhalten und liegen im Backup. PHP läuft auf dem NAS aber unter einem
* eigenen Benutzer, und DSM vergibt im Web-Verzeichnis ACLs, die ihm das
* Schreiben verwehren. Der POSIX-Modus täuscht: drwxrwxrwx mit einem "+"
* dahinter heisst, dass die ACL entscheidet, nicht die Rechtebits.
*
* Damit die Karte auch ohne Eingriff funktioniert, wird ersatzweise das
* Systemverzeichnis für flüchtige Dateien genommen. Das kostet nur den
* Bestand: es wird bei Gelegenheit geleert, die Kacheln müssen dann erneut
* geholt werden. Dauerhaft wird es mit einem Befehl als root:
*
* synoacltool -add /volume1/web/smart/tiles \
* "user:http:allow:rwxpdDaARWc--:fd--"
*
* Welcher Ort gerade benutzt wird, sagt speicherOrt() im Fehlerprotokoll,
* sobald zum ersten Mal eine Kachel geholt wird.
*/
const SPEICHER_ORTE = [
__DIR__ . "/../tiles", // dauerhaft, bevorzugt
"/var/services/tmp/smart-tiles", // flüchtig, aber immer beschreibbar
];
/** Zoomstufen, die ausgeliefert werden. Feiner als Hausnummern braucht es nicht. */
const ZOOM_MIN = 6;
const ZOOM_MAX = 18;
/** Wie lange der Browser eine Kachel behalten darf (Sekunden). */
const BROWSER_FRIST = 7 * 24 * 3600;
/**
* Nach dieser Zeit wird eine Kachel beim nächsten Ansehen neu geholt. Die
* Karte ändert sich, aber nicht schnell. Scheitert das Nachholen, bleibt die
* alte Kachel - die Karte ist dann veraltet, aber nie kaputt.
*/
const AUFFRISCHEN_NACH = 60 * 24 * 3600;
/*
* Bremse gegen den eigenen Fehler. Ein Kartenfeld dieser Größe braucht rund
* zwölf Kacheln je Ansicht; wer sich durch mehrere Zoomstufen klickt, kommt
* auf einige Dutzend. Alles weit darüber wäre eine Schleife im Frontend - und
* die sähe für OSM wie Vorratsspeicherung aus. Ist die Grenze erreicht, wird
* eine graue Ersatzkachel geliefert statt weiter nach draußen zu gehen.
*/
const HOLEN_JE_MINUTE = 150;
const HOLEN_JE_STUNDE = 2000;
/* ------------------------------------------------------------------ Zugriff */
if (!checkLogin()) {
http_response_code(403);
exit;
}
/* ----------------------------------------------------------------- Prüfung */
$z = filter_input(INPUT_GET, "z", FILTER_VALIDATE_INT);
$x = filter_input(INPUT_GET, "x", FILTER_VALIDATE_INT);
$y = filter_input(INPUT_GET, "y", FILTER_VALIDATE_INT);
// Ohne diese Prüfung liesse sich über z/x/y aus dem Speicherverzeichnis
// heraus schreiben - die Werte gehen schliesslich in einen Dateipfad.
$grenze = ($z === false || $z === null) ? 0 : (1 << $z);
if ($z === false || $z === null || $z < ZOOM_MIN || $z > 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==");
}