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
+4
View File
@@ -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/
+81 -14
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";
@@ -48,12 +112,15 @@ header('Content-Type: application/json; charset=utf-8');
$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==");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

+661
View File
@@ -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;
}
}
+531
View File
@@ -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; }
+9 -5
View File
@@ -99,16 +99,20 @@ if (isset($_GET["addUser"])) {
* geladen - die Home-Seite etwa zeichnet gar keine Diagramme.
*/
$pages = [
"solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true,
"solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false,
"scripts" => ["js/solar/solarMQTT.js"]],
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false,
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false,
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false,
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false,
"scripts" => ["js/solar/heatMQTT.js"]],
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false,
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false,
"scripts" => ["js/solar/historyMQTT.js"]],
"weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true,
"skoda" => ["template" => "skoda.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => true,
"scripts" => ["js/solar/skodaMQTT.js"]],
"weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true, "leaflet" => false,
"scripts" => ["js/solar/weatherMQTT.js"]],
"settings" => ["template" => "settings.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
"scripts" => ["js/solar/settings.js"]],
];
// Unbekannte oder fehlende Aktion faellt auf die Solar-Seite zurueck.
+6
View File
File diff suppressed because one or more lines are too long
+254
View File
@@ -0,0 +1,254 @@
/*
* Einstellungsseite: Preistabellen bearbeiten.
*
* Eine Preistabelle ist eine Zeitreihe - ab einem Stichtag gilt ein Preis, bis
* der naechste Stichtag kommt. Deshalb gibt es hier nur ein Datumsfeld je
* Zeile. Wo eine Zeile endet, steht als grauer Text daneben und ergibt sich
* aus der naechsten; die juengste Zeile endet gar nicht, sie gilt weiter, bis
* ein neuer Preis eingetragen wird.
*
* Frueher stand das Ende als eigene Spalte in der Datenbank, und weil es
* eigenstaendig gepflegt wurde, konnte es dem naechsten Stichtag widersprechen:
* zwei Zeilen liefen ueber dasselbe Quartal, die Jahresstatistik zaehlte diese
* Tage doppelt. Ohne die Spalte laesst sich das nicht mehr hinschreiben.
*
* Gezeichnet wird aus dem Modell, nicht aus dem DOM: karte._modell ist die
* Wahrheit, jede Eingabe schreibt hinein und loest ein Neuzeichnen aus.
*/
// --- Datumskram ------------------------------------------------------------
/** Ein Datum "JJJJ-MM-TT" um $tage verschieben. */
function tagVersatz(iso, tage) {
const d = new Date(iso + "T00:00:00Z");
d.setUTCDate(d.getUTCDate() + tage);
return d.toISOString().slice(0, 10);
}
/** "2025-01-01" -> "01.01.2025" */
function datumLesbar(iso) {
if (!iso) return "";
const t = iso.split("-");
return t[2] + "." + t[1] + "." + t[0];
}
function heuteISO() {
const d = new Date();
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
}
// --- Modell ---------------------------------------------------------------
/** Zeilen aufsteigend nach Stichtag. */
function sortiert(zeilen) {
return zeilen.slice().sort((a, b) => (a.ab < b.ab ? -1 : a.ab > b.ab ? 1 : 0));
}
/**
* Die einzige Luecke, die es noch geben kann: vor dem aeltesten Stichtag gibt
* es keinen Preis. Messwerte, die davor liegen, fallen aus der Jahresstatistik
* heraus, weil der JOIN fuer sie keine Zeile findet. Passieren kann das, wenn
* jemand den aeltesten Zeitraum loescht oder nach hinten schiebt - deshalb
* wird die Warnung bei jedem Neuzeichnen geprueft, nicht nur beim Laden.
*/
function zeitraumWarnung(zeilen) {
const liste = sortiert(zeilen);
if (!ersteMessung || !liste.length || liste[0].ab <= ersteMessung) return [];
return ["Die Messwerte beginnen am " + datumLesbar(ersteMessung) + ", der älteste Preis"
+ " erst am " + datumLesbar(liste[0].ab) + ". Alles davor fehlt in der Statistik."];
}
// --- Zeichnen -------------------------------------------------------------
function preisZeile(karte, zeile, i, anzahl) {
const daten = preisDaten[karte.dataset.reihe];
const felder = daten.felder;
// Die Spaltenbreiten kommen aus preisSpalten() im PHP - Kopfzeile und
// Felder muessen dieselben benutzen, sonst stehen die Ueberschriften schief.
const breit = daten.spalten;
const letzte = (i === anzahl - 1);
const heute = heuteISO();
// Die juengste Zeile hat kein Ende; alle anderen enden am Tag vor der
// naechsten.
const bis = letzte ? null : tagVersatz(karte._modell[i + 1].ab, -1);
const gilt = zeile.ab <= heute && (bis === null || heute <= bis);
let html = "<div class='row g-2 align-items-center preis-zeile" + (gilt ? " preis-heute" : "")
+ "' data-i='" + i + "'>";
html += "<div class='col-6 col-xl-" + breit.ab + "'>"
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>Gültig ab</label>"
+ "<input type='date' class='form-control form-control-sm' data-feld='ab' value='" + zeile.ab + "'>"
+ "</div>";
// Die Marke "gilt heute" steht beim Zeitraum, nicht beim Papierkorb: sie
// beschreibt ihn, und die Aktionsspalte bleibt schmal genug, dass die
// Felder den Platz bekommen.
html += "<div class='col-6 col-xl-" + breit.bis + "'>"
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>Gültig bis</label>"
+ "<div class='form-control form-control-sm border-0 bg-transparent px-0"
+ " d-flex align-items-center gap-2'>"
+ "<span class='text-secondary'>" + (bis === null ? "offen" : datumLesbar(bis)) + "</span>"
+ (gilt ? "<span class='badge text-bg-primary'>gilt heute</span>" : "")
+ "</div></div>";
Object.keys(felder).forEach(function (spalte, nr) {
const feld = felder[spalte];
// Schrittweite aus den Nachkommastellen: der Energieinhalt je Liter will
// vier Stellen, ein Preis in Cent nur zwei.
const schritt = Math.pow(10, -feld.stellen).toFixed(feld.stellen);
html += "<div class='col-6 col-xl-" + breit.felder[nr] + "'>"
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>" + feld.titel + "</label>"
+ "<div class='input-group input-group-sm'>"
+ "<input type='number' step='" + schritt + "' min='0' max='" + feld.max + "'"
+ " class='form-control text-end' data-feld='" + spalte + "'"
+ " value='" + zeile[spalte] + "'>"
+ "<span class='input-group-text'>" + feld.einheit + "</span>"
+ "</div></div>";
});
html += "<div class='col-12 col-xl-" + breit.aktion + " d-flex align-items-center justify-content-end'>";
html += "<button type='button' class='btn btn-sm btn-outline-danger preis-weg' title='Zeitraum löschen'"
+ (anzahl > 1 ? "" : " disabled") + "><i class='bi bi-trash'></i></button>"
+ "</div></div>";
return html;
}
function zeichnePreise(karte) {
karte._modell = sortiert(karte._modell);
const anzahl = karte._modell.length;
const warnung = zeitraumWarnung(karte._modell);
let html = "";
if (warnung.length) {
html += "<div class='alert alert-warning py-2 small'>"
+ "<i class='bi bi-exclamation-triangle me-1'></i>"
+ warnung.join(" ") + "</div>";
}
for (let i = 0; i < anzahl; i++) {
html += preisZeile(karte, karte._modell[i], i, anzahl);
}
karte.querySelector(".preis-zeilen").innerHTML = html;
}
// --- Bedienung ------------------------------------------------------------
function preisHinweis(karte, text, klasse) {
const feld = karte.querySelector(".preis-hinweis");
feld.className = "preis-hinweis small " + (klasse || "text-secondary");
feld.textContent = text;
}
function preisNeueZeile(karte) {
const modell = karte._modell;
const vorlage = modell[modell.length - 1];
// Der neue Stichtag ist heute; ist der schon vergeben, der naechste freie
// Tag. So entsteht nie eine Zeile, die das Speichern gleich ablehnt.
let ab = heuteISO();
while (modell.some(z => z.ab === ab)) {
ab = tagVersatz(ab, 1);
}
const zeile = { ab: ab };
for (const spalte in preisDaten[karte.dataset.reihe].felder) {
zeile[spalte] = vorlage[spalte]; // Preise uebernehmen, geaendert wird meist nur einer
}
modell.push(zeile);
zeichnePreise(karte);
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
const zeilen = karte.querySelectorAll(".preis-zeile");
const feld = zeilen[zeilen.length - 1].querySelector("[data-feld='ab']");
feld.focus();
}
async function preiseSenden(karte) {
const knopf = karte.querySelector(".preis-speichern");
knopf.disabled = true;
preisHinweis(karte, "Wird gespeichert …");
try {
const antwort = await fetch("ajax/settings.php?action=save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reihe: karte.dataset.reihe, zeilen: karte._modell })
});
const daten = await antwort.json();
if (!antwort.ok) {
throw new Error(daten.error || "Fehler " + antwort.status);
}
// Mit der Antwort des Servers weiterarbeiten, nicht mit dem eigenen
// Modell: sortiert und gerundet wird dort.
karte._modell = daten.zeilen;
zeichnePreise(karte);
preisHinweis(karte, "Gespeichert " + daten.gespeichert + " Zeiträume.", "text-success");
} catch (fehler) {
preisHinweis(karte, fehler.message, "text-danger");
}
knopf.disabled = false;
}
async function preiseHolen(karte) {
preisHinweis(karte, "Wird geladen …");
try {
const antwort = await fetch("ajax/settings.php?action=list&reihe=" + encodeURIComponent(karte.dataset.reihe));
const daten = await antwort.json();
if (!antwort.ok) {
throw new Error(daten.error || "Fehler " + antwort.status);
}
karte._modell = daten.zeilen;
zeichnePreise(karte);
preisHinweis(karte, "");
} catch (fehler) {
preisHinweis(karte, fehler.message, "text-danger");
}
}
function preisKarteBinden(karte) {
karte.addEventListener("input", function (ereignis) {
const feld = ereignis.target.closest("[data-feld]");
if (!feld) return;
const zeile = karte._modell[parseInt(feld.closest(".preis-zeile").dataset.i, 10)];
if (feld.dataset.feld === "ab") {
if (!feld.value) return; // waehrend des Tippens ist das Feld kurz leer
zeile.ab = feld.value;
} else {
zeile[feld.dataset.feld] = isNaN(feld.valueAsNumber) ? 0 : feld.valueAsNumber;
}
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
});
// Ein geaenderter Stichtag verschiebt die Reihenfolge und damit die Enden
// aller Zeilen - aber erst, wenn das Feld fertig ist. Waehrend des Tippens
// neu zu zeichnen wuerde den Eingabefokus wegreissen.
karte.addEventListener("change", function (ereignis) {
if (ereignis.target.dataset.feld === "ab") {
zeichnePreise(karte);
}
});
karte.addEventListener("click", function (ereignis) {
if (ereignis.target.closest(".preis-neu")) {
preisNeueZeile(karte);
} else if (ereignis.target.closest(".preis-weg")) {
const i = parseInt(ereignis.target.closest(".preis-zeile").dataset.i, 10);
karte._modell.splice(i, 1);
zeichnePreise(karte);
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
} else if (ereignis.target.closest(".preis-zurueck")) {
preiseHolen(karte);
} else if (ereignis.target.closest(".preis-speichern")) {
preiseSenden(karte);
}
});
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".preis-karte").forEach(function (karte) {
const daten = preisDaten[karte.dataset.reihe];
if (!daten) return;
karte._modell = sortiert(daten.zeilen);
zeichnePreise(karte);
preisKarteBinden(karte);
});
});
File diff suppressed because it is too large Load Diff
+344
View File
@@ -0,0 +1,344 @@
<?php
/**
* Modell-Schicht fuer die Preistabellen der Einstellungsseite.
*
* Eine Preistabelle ist eine Zeitreihe: ab einem Stichtag gelten neue Preise,
* bis der naechste Eintrag beginnt. Mehr steht dort auch nicht - insbesondere
* kein Enddatum. Das gab es frueher als eigene Spalte, und weil es eigenstaendig
* gepflegt wurde, konnte es etwas anderes behaupten als der naechste Stichtag:
* in gridCosts liefen zwei Zeilen ueber das erste Quartal 2023, der JOIN in
* ajax/getStats.php fand fuer jeden Messwert zwei Preise, und der
* Stromverbrauch 2023 stand mit 2527 statt 1674 kWh in der Statistik.
*
* Ohne die Spalte kann das nicht mehr passieren: zwei Zeitraeume koennen sich
* nicht ueberlappen, weil der eine endet, wo der andere beginnt, und eine
* Luecke kann es auch nicht geben. Die juengste Zeile hat kein Ende und gilt
* weiter, bis ein neuer Preis eingetragen wird - so verhaelt sich ein Tarif ja
* auch. Wo die Statistik das Ende braucht, holt sie es sich mit
* LEAD(active_date) beim naechsten Stichtag.
*
* Was bleibt: vor dem aeltesten Stichtag gibt es keinen Preis. Messwerte, die
* davor liegen, fallen aus der Statistik heraus - dagegen hilft nur hinsehen,
* siehe preisLuecke().
*/
require_once(__DIR__ . "/mysql.php");
/** Verbindung zur Messwert-Datenbank. */
function solarDb()
{
static $db = null;
if ($db === null) {
$db = new mysqli($GLOBALS["mysql_server"], $GLOBALS["mysql_solarUser"],
$GLOBALS["mysql_solarPass"], $GLOBALS["mysql_solarDB"]);
$db->set_charset("utf8mb4");
}
return $db;
}
/**
* Die bearbeitbaren Preistabellen.
*
* Je Feld:
* faktor rechnet zwischen Speicher- und Anzeigeeinheit um. Gespeichert
* wird in Euro, angezeigt und eingegeben in Cent - so, wie es auf
* der Rechnung steht. 0,3163 EUR/kWh sind 31,63 ct/kWh; niemand
* tippt gern vier Nachkommastellen. Vorgabe 1, also unveraendert.
* stellen Nachkommastellen der Anzeige, zugleich die Schrittweite im Feld.
* max Obergrenze. Was darueber liegt, ist ein Tippfehler und keine
* Preiserhoehung.
*
* Nicht jedes Feld ist ein Preis. Der Energieinhalt des Gases stand frueher
* als 6.58 im SQL, die Benzinersparnis als Teiler 24.5 - beides gehoert neben
* den Preis, weil es am Brennstoff und am Fahrzeug haengt und nicht an der
* Physik. Beim Auto aber nicht als "kWh je Liter": diese Zahl steht auf keinem
* Datenblatt. Eingetragen werden die beiden Verbraeuche, die man kennt, und
* die Statistik rechnet daraus, wieviele Liter eine geladene kWh ersetzt.
*
* Kommt spaeter eine weitere Tabelle desselben Zuschnitts dazu, reicht hier
* ein Eintrag - Endpunkt, Maske und Speicherlogik bleiben unveraendert.
*/
function preisReihen()
{
return [
"strom" => [
"titel" => "Strompreise",
"hinweis" => "Arbeitspreis, Einspeisevergütung und Grundpreis, wie sie in die "
. "Jahresstatistik auf der Historienseite eingehen.",
"tabelle" => "gridCosts",
"symbol" => "bi-plug",
"felder" => [
"cost" => ["titel" => "Arbeitspreis", "einheit" => "ct/kWh",
"faktor" => 100, "stellen" => 2, "max" => 500],
"gain" => ["titel" => "Einspeisung", "einheit" => "ct/kWh",
"faktor" => 100, "stellen" => 2, "max" => 500],
"base" => ["titel" => "Grundpreis", "einheit" => "€/Jahr",
"faktor" => 1, "stellen" => 2, "max" => 5000],
],
],
"gas" => [
"titel" => "Gaspreise",
"hinweis" => "Womit die Ersparnis der Heizung gerechnet wird: was das Gas kostet, "
. "das der Heizstab bei Sonnenüberschuss ersetzt.",
"tabelle" => "gasCosts",
"symbol" => "bi-fire",
"felder" => [
"cost" => ["titel" => "Gaspreis", "einheit" => "ct/l",
"faktor" => 100, "stellen" => 2, "max" => 1000],
"kwhPerLitre" => ["titel" => "Energieinhalt", "einheit" => "kWh/l",
"faktor" => 1, "stellen" => 2, "max" => 20],
],
],
"sprit" => [
"titel" => "Benzinpreise",
"hinweis" => "Für „Benzin gespart“ und „Ersparnis Benzin“: der Preis an der Säule "
. "und was die beiden Autos verbrauchen — daraus ergibt sich, wieviele "
. "Liter eine geladene Kilowattstunde ersetzt.",
"tabelle" => "fuelCosts",
"symbol" => "bi-fuel-pump",
"felder" => [
"cost" => ["titel" => "Benzinpreis", "einheit" => "€/l",
"faktor" => 1, "stellen" => 3, "max" => 10],
"lPer100km" => ["titel" => "Verbrenner", "einheit" => "l/100 km",
"faktor" => 1, "stellen" => 1, "max" => 50],
"kwhPer100km" => ["titel" => "Elektro", "einheit" => "kWh/100 km",
"faktor" => 1, "stellen" => 1, "max" => 100],
],
],
];
}
/** Beschreibung einer Reihe, oder null bei unbekanntem Schluessel. */
function preisReihe($key)
{
$reihen = preisReihen();
return isset($reihen[$key]) ? $reihen[$key] : null;
}
/**
* Spaltenbreiten einer Zeile im Bootstrap-Raster (zusammen zwoelf).
*
* Damit Kopfzeile und Felder aus derselben Quelle kommen: das PHP zeichnet die
* Ueberschriften, das JavaScript die Zeilen, und beide holen sich die Breiten
* hier.
*
* Datum, Ende und Papierkorb nehmen sich so wenig wie moeglich - das Ende ist
* nur noch Text, der Papierkorb ein Knopf. Was uebrig bleibt, teilen sich die
* Felder. Geht die Teilung nicht auf, bekommt der Rest das Feld mit der
* laengsten Einheit: hinter "kWh/100 km" bleiben von einer Sechstelspalte
* keine 50 Pixel fuer die Zahl, hinter "EUR/l" ist reichlich Platz.
*
* Ab vier Feldern wird es eng; die Zeile bricht dann um, was haesslicher, aber
* nicht kaputt ist.
*/
function preisSpalten($felder)
{
$felder = array_values($felder);
$anzahl = max(1, count($felder));
$rand = $anzahl >= 3 ? ["ab" => 2, "bis" => 2, "aktion" => 1]
: ["ab" => 3, "bis" => 3, "aktion" => 1];
$rest = 12 - $rand["ab"] - $rand["bis"] - $rand["aktion"];
$breiten = array_fill(0, $anzahl, max(1, intdiv($rest, $anzahl)));
$uebrig = $rest - array_sum($breiten);
if ($uebrig > 0) {
$laengen = array_map(function ($f) { return mb_strlen($f["einheit"]); }, $felder);
$breiten[array_search(max($laengen), $laengen)] += $uebrig;
}
return $rand + ["felder" => $breiten];
}
/**
* Alle Zeitraeume einer Reihe, aeltester zuerst, Werte in Anzeigeeinheit.
*
* Geliefert wird nur der Stichtag; wo eine Zeile endet, ergibt sich aus der
* naechsten und rechnet die Maske selbst aus.
*/
function preiseLaden($key)
{
$reihe = preisReihe($key);
if (!$reihe) {
throw new InvalidArgumentException("Unbekannte Preistabelle.");
}
$spalten = array_keys($reihe["felder"]);
$res = solarDb()->query("SELECT active_date, " . implode(", ", $spalten)
. " FROM " . $reihe["tabelle"] . " ORDER BY active_date");
if (!$res) {
throw new RuntimeException(solarDb()->error);
}
$zeilen = [];
while ($row = $res->fetch_assoc()) {
$zeile = ["ab" => $row["active_date"]];
foreach ($spalten as $spalte) {
$feld = $reihe["felder"][$spalte];
$zeile[$spalte] = round(floatval($row[$spalte]) * $feld["faktor"], $feld["stellen"]);
}
$zeilen[] = $zeile;
}
return $zeilen;
}
/** Datum in der Form JJJJ-MM-TT, sonst null. */
function preisDatum($wert)
{
if (!is_string($wert) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert)) {
return null;
}
$datum = DateTimeImmutable::createFromFormat("!Y-m-d", $wert);
// createFromFormat schluckt den 31. Februar und macht den 3. Maerz daraus;
// der Rueckvergleich faengt das ab.
return ($datum && $datum->format("Y-m-d") === $wert) ? $datum : null;
}
/** Tag der aeltesten Messung in EnergyFlow, als "JJJJ-MM-TT". */
function ersteMessung()
{
static $tag = null;
if ($tag === null) {
$res = solarDb()->query("SELECT MIN(datetime) FROM EnergyFlow");
$zeile = $res ? $res->fetch_row() : null;
$tag = ($zeile && $zeile[0]) ? substr($zeile[0], 0, 10) : "";
}
return $tag;
}
/**
* Beginnt die Reihe zu spaet? Dann fehlt den aeltesten Messwerten der Preis.
*
* Das ist die einzige Luecke, die es noch geben kann - alle anderen hat das
* Weglassen der Spalte end_date erledigt. Auftreten kann sie, wenn jemand den
* aeltesten Zeitraum loescht.
*/
function preisLuecke($zeilen)
{
$erste = ersteMessung();
if (!$erste || !$zeilen) {
return "";
}
$stichtag = $zeilen[0]["ab"];
return $stichtag > $erste ? $erste : "";
}
/**
* Eine ganze Reihe schreiben: pruefen und ersetzen.
*
* Ersetzt statt abzugleichen. Die Tabellen haben eine Handvoll Zeilen und
* keinen kuenstlichen Schluessel - der Stichtag selbst ist der
* Primaerschluessel, und beim Verschieben eines Stichtags waere ein Abgleich
* mehr Buchhaltung als Gewinn. Alles laeuft in einer Transaktion, es gibt also
* keinen Moment, in dem die Statistik auf eine halbe Tabelle blickt.
*/
function preiseSpeichern($key, $zeilen)
{
$reihe = preisReihe($key);
if (!$reihe) {
throw new InvalidArgumentException("Unbekannte Preistabelle.");
}
if (!is_array($zeilen) || !$zeilen) {
throw new InvalidArgumentException("Es muss mindestens ein Zeitraum bleiben.");
}
$spalten = array_keys($reihe["felder"]);
// --- Pruefen --------------------------------------------------------
$geprueft = [];
foreach ($zeilen as $zeile) {
$ab = preisDatum($zeile["ab"] ?? "");
if (!$ab) {
throw new InvalidArgumentException("Ein Zeitraum hat kein gültiges Startdatum.");
}
$satz = ["ab" => $ab];
foreach ($spalten as $spalte) {
$feld = $reihe["felder"][$spalte];
$wert = $zeile[$spalte] ?? null;
if (!is_numeric($wert)) {
throw new InvalidArgumentException(
$feld["titel"] . " ab " . $ab->format("d.m.Y") . " ist keine Zahl.");
}
$wert = floatval($wert);
if ($wert < 0 || $wert > $feld["max"]) {
throw new InvalidArgumentException(
$feld["titel"] . " ab " . $ab->format("d.m.Y")
. " liegt außerhalb des Erwartbaren (0 bis " . $feld["max"] . " "
. $feld["einheit"] . ").");
}
$satz[$spalte] = $wert / $feld["faktor"];
}
$geprueft[] = $satz;
}
// Nach Stichtag sortieren; zwei gleiche Stichtage kann die Tabelle nicht
// halten (Primaerschluessel) und die Statistik nicht deuten.
usort($geprueft, function ($a, $b) { return $a["ab"] <=> $b["ab"]; });
for ($i = 1; $i < count($geprueft); $i++) {
if ($geprueft[$i]["ab"] == $geprueft[$i - 1]["ab"]) {
throw new InvalidArgumentException(
"Zwei Zeiträume beginnen am " . $geprueft[$i]["ab"]->format("d.m.Y") . ".");
}
}
// --- Schreiben ------------------------------------------------------
$db = solarDb();
$db->begin_transaction();
try {
if (!$db->query("DELETE FROM " . $reihe["tabelle"])) {
throw new RuntimeException($db->error);
}
$platzhalter = implode(", ", array_fill(0, count($spalten) + 1, "?"));
$stmt = $db->prepare("INSERT INTO " . $reihe["tabelle"]
. " (active_date, " . implode(", ", $spalten) . ")"
. " VALUES (" . $platzhalter . ")");
if (!$stmt) {
throw new RuntimeException($db->error);
}
foreach ($geprueft as $satz) {
$werte = [$satz["ab"]->format("Y-m-d")];
foreach ($spalten as $spalte) {
$werte[] = $satz[$spalte];
}
$stmt->bind_param("s" . str_repeat("d", count($spalten)), ...$werte);
if (!$stmt->execute()) {
throw new RuntimeException($stmt->error);
}
}
$db->commit();
} catch (Throwable $e) {
$db->rollback();
throw $e;
}
return count($geprueft);
}
/**
* Grundpreis Strom fuer ein Kalenderjahr, in Euro.
*
* Der Grundpreis ist der einzige Posten, der nicht an einem Messwert haengt -
* er faellt auch an, wenn nichts verbraucht wird. Deshalb steht er nicht im
* grossen SUM-Ausdruck der Statistik, sondern wird hier eigens gerechnet:
* jeder Zeitraum steuert seinen Anteil an den Tagen des Jahres bei. Das ist
* unabhaengig davon, wieviele Messwerte tatsaechlich aufgezeichnet wurden -
* 2022 hat merklich Luecken, und ein fehlender Nachmittag darf den Grundpreis
* nicht senken.
*
* LEAD() holt den naechsten Stichtag; einen Tag davor endet der Zeitraum. Die
* juengste Zeile hat keinen Nachfolger und laeuft bis zum Jahresende weiter.
*/
function grundpreisImJahr($jahr)
{
$jahr = intval($jahr);
$von = $jahr . "-01-01";
$bis = $jahr . "-12-31";
$tage = intval((new DateTimeImmutable($bis))->format("z")) + 1; // 365 oder 366
$stmt = solarDb()->prepare(
"WITH p AS (SELECT active_date, base,
LEAD(active_date) OVER (ORDER BY active_date) AS folgt
FROM gridCosts)
SELECT SUM(base * (DATEDIFF(LEAST(IFNULL(DATE_SUB(folgt, INTERVAL 1 DAY), ?), ?),
GREATEST(active_date, ?)) + 1) / ?)
FROM p
WHERE active_date <= ? AND (folgt IS NULL OR folgt > ?)");
$stmt->bind_param("sssiss", $bis, $bis, $von, $tage, $bis, $von);
$stmt->execute();
$wert = $stmt->get_result()->fetch_row()[0];
return $wert === null ? 0.0 : floatval($wert);
}
+5
View File
@@ -62,6 +62,11 @@
<?php if ($page["charts"]): ?>
<?php include __DIR__ . "/charts.html"; ?>
<?php endif; ?>
<?php if ($page["leaflet"]): ?>
<!-- Leaflet 1.9.4, BSD 2-Clause. Liegt wie alle anderen Bibliotheken lokal;
die Kacheln kommen ueber ajax/tile.php aus dem eigenen Zwischenspeicher. -->
<script src="js/leaflet.js"></script>
<?php endif; ?>
<script src="js/solar/common.js?v=<?= filemtime("js/solar/common.js") ?>"></script>
<?php foreach ($page["scripts"] as $script): ?>
<script src="<?= $script ?>?v=<?= filemtime($script) ?>"></script>
+20
View File
@@ -44,6 +44,12 @@ if (!isset($_GET["floor"]))
<link rel="stylesheet" href="./css/adminlte.min.css?v=2" />
<!--end::Required Plugin(AdminLTE)-->
<!--begin::Karte (Leaflet) - nur auf Seiten mit Karte-->
<?php if ($page["leaflet"]): ?>
<link rel="stylesheet" href="css/leaflet.css" />
<?php endif; ?>
<!--end::Karte-->
</head>
<!--end::Head-->
@@ -142,6 +148,12 @@ if (!isset($_GET["floor"]))
<p>Wetter</p>
</a>
</li>
<li class="nav-item">
<a href="?action=skoda" class="nav-link <?php if($_GET["action"]=="skoda"){echo " active";} ?>">
<i class="nav-icon bi bi-ev-front"></i>
<p>Škoda</p>
</a>
</li>
</ul>
</li>
<li class="nav-item menu-open">
@@ -170,6 +182,14 @@ if (!isset($_GET["floor"]))
</li>
</ul>
</li>
<!-- Einstellungen stehen fuer sich: kein Dashboard,
sondern die Parameter dahinter. -->
<li class="nav-item">
<a href="?action=settings" class="nav-link<?php if($_GET["action"]=="settings"){echo " active";} ?>">
<i class="nav-icon bi bi-gear"></i>
<p>Einstellungen</p>
</a>
</li>
</ul>
<!--end::Sidebar Menu-->
</nav>
+1 -1
View File
@@ -74,7 +74,7 @@ $statistics = [
</button>
</div>
</div>
<div class="row-cols-1 row-cols-md-1 g-4 p-2 pb-0" id="<?= $id ?>">
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="<?= $id ?>">
</div>
</div>
<!-- /.card -->
+109
View File
@@ -0,0 +1,109 @@
<?php
/**
* Einstellungsseite: Parameter, die von Hand gepflegt werden.
*
* Bisher sind das die Preistabellen aus restricted/costs.php. Die Seite ist
* als Sammlung von Karten angelegt - eine Karte je Thema. Kommt spaeter ein
* weiterer Parameter dazu, kommt er als eigene Karte hinzu, ohne dass die
* vorhandenen etwas davon merken.
*
* Die Zeilen zeichnet js/solar/settings.js aus dem Modell, das hier
* eingebettet wird - dieselbe Arbeitsteilung wie im Automatik-Editor. Sonst
* gaebe es die erste Zeile als PHP und jede weitere als JavaScript, und die
* beiden Fassungen liefen frueher oder spaeter auseinander. Aus demselben
* Grund kommen die Spaltenbreiten aus preisSpalten(): die Kopfzeile hier und
* die Felder dort muessen dieselben sein.
*/
require_once(__DIR__ . "/costs.php");
$reihen = preisReihen();
$daten = [];
$fehler = "";
try {
foreach ($reihen as $key => $reihe) {
$daten[$key] = [
"felder" => $reihe["felder"],
"spalten" => preisSpalten($reihe["felder"]),
"zeilen" => preiseLaden($key),
];
}
} catch (Throwable $e) {
$fehler = $e->getMessage();
}
?>
<!--begin::App Main-->
<main class="app-main">
<!--begin::App Content Header-->
<div class="app-content-header">
<div class="container-fluid">
<div class="row">
<div class="col-sm-12">
<h3 class="mb-0">Einstellungen</h3>
</div>
</div>
</div>
</div>
<!--end::App Content Header-->
<!--begin::App Content-->
<div class="app-content">
<div class="container-fluid">
<?php if ($fehler): ?>
<div class="alert alert-danger">Die Einstellungen sind nicht erreichbar: <?= htmlspecialchars($fehler) ?></div>
<?php endif; ?>
<?php foreach ($reihen as $key => $reihe): ?>
<?php $breiten = preisSpalten($reihe["felder"]); ?>
<div class="row">
<div class="col-12 col-xxl-9">
<div class="card mb-4 preis-karte" data-reihe="<?= htmlspecialchars($key) ?>">
<div class="card-header pb-2 pt-2">
<h3 class="card-title"><i class="bi <?= $reihe["symbol"] ?> me-2"></i><?= htmlspecialchars($reihe["titel"]) ?></h3>
<div class="card-tools">
<button type="button" class="btn btn-tool preis-neu" title="Zeitraum hinzufügen">
<i class="bi bi-plus-lg"></i>
</button>
<button type="button" class="btn btn-tool preis-zurueck" title="Änderungen verwerfen">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</div>
</div>
<div class="card-body pb-2">
<p class="text-secondary small mb-3"><?= htmlspecialchars($reihe["hinweis"]) ?></p>
<!-- Spaltenköpfe nur auf breiten Schirmen; auf dem Handy trägt
jedes Feld seine eigene Beschriftung. -->
<div class="row g-2 d-none d-xl-flex preis-kopf text-secondary small fw-semibold">
<div class="col-xl-<?= $breiten["ab"] ?>">Gültig ab</div>
<div class="col-xl-<?= $breiten["bis"] ?>">Gültig bis</div>
<?php foreach (array_values($reihe["felder"]) as $i => $feld): ?>
<div class="col-xl-<?= $breiten["felder"][$i] ?>"><?= htmlspecialchars($feld["titel"]) ?></div>
<?php endforeach; ?>
<div class="col-xl-<?= $breiten["aktion"] ?>"></div>
</div>
<div class="preis-zeilen"></div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-3 preis-neu">
<i class="bi bi-plus-lg me-1"></i>Zeitraum hinzufügen
</button>
</div>
<div class="card-footer d-flex align-items-center gap-3">
<button type="button" class="btn btn-primary preis-speichern">Speichern</button>
<span class="preis-hinweis small"></span>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<!--end::App Content-->
</main>
<!--end::App Main-->
<script>
// Ausgangsstand der Preistabellen. Werte in der Anzeigeeinheit (ct statt
// Euro); umgerechnet wird in restricted/costs.php - an genau einer Stelle.
const preisDaten = <?= json_encode($daten, JSON_UNESCAPED_UNICODE) ?>;
// Aelteste Messung in EnergyFlow. Beginnt eine Preisreihe spaeter, fehlt
// den Werten davor der Preis - dann warnt die Maske.
const ersteMessung = <?= json_encode(ersteMessung()) ?>;
</script>
+383
View File
@@ -0,0 +1,383 @@
<?php
/*
* Škoda-Dashboard.
*
* Zwei Datenwege, bewusst getrennt:
*
* MQTT Ladestand, Verriegelung, Reichweite, Ladezustand und
* Wallbox-Leistung kommen laufend über solarManager/# und
* aktualisieren die Anzeige sofort.
*
* ajax/skoda.php Alles, was nur in der Tabelle steht - Türen, Fenster,
* Licht, Kilometerstand, Position, Klima - sowie sämtliche
* Auswertungen. Wird beim Laden und danach minütlich geholt.
*
* Solange kein Fahrzeug angebunden ist, bleibt die Tabelle leer; jeder
* Abschnitt zeigt dann seinen Leerzustand statt eines Fehlers.
*
* Die Fahrzeuggrafik ist eine maßstäbliche Draufsicht des Enyaq:
* 4648 x 1877 mm, Radstand 2765 mm, daraus das Seitenverhältnis 2,48:1 und
* die Lage der Achsen. Kein Foto, sondern ein Schema - die Teile sollen
* ihren Zustand zeigen, nicht das Auto porträtieren.
*/
?>
<!--begin::App Main-->
<main class="app-main">
<div class="app-content-header">
<div class="container-fluid">
<div class="row align-items-center">
<div class="col-sm-8">
<h3 class="mb-0">Škoda</h3>
</div>
<div class="col-sm-4 text-sm-end">
<span class="skoda-alter text-secondary" id="skodaAlter">&nbsp;</span>
</div>
</div>
</div>
</div>
<div class="app-content">
<div class="container-fluid">
<!-- ================================================ Zustand ===== -->
<div class="row">
<!-- Das Fahrzeug als Anzeigeelement. Jedes Teil trägt seinen Zustand
selbst: offene Türen und Klappen leuchten bernstein, Licht gelb,
der Ladeanschluss pulst beim Laden. Die Zeile darunter benennt
dasselbe in Worten und hebt beim Überfahren das zugehörige Teil
hervor - Farbe allein soll die Aussage nicht tragen. -->
<div class="col-xxl-4 col-lg-5 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Fahrzeug</h3>
<div class="card-tools">
<span class="badge text-bg-secondary" id="skodaSchloss"></span>
</div>
</div>
<div class="card-body d-flex flex-column align-items-center pt-2">
<svg id="skodaAuto" class="skoda-auto" viewBox="0 0 224 500"
role="img" aria-label="Draufsicht des Fahrzeugs mit dem Zustand von Türen, Fenstern, Klappen und Licht">
<!-- Räder. Vorderachse bei 860 mm, Hinterachse bei 3625 mm
ab Bugspitze - der Radstand von 2765 mm maßstäblich. -->
<g class="rad">
<rect x="5" y="68" width="21" height="58" rx="9"/>
<rect x="198" y="68" width="21" height="58" rx="9"/>
<rect x="5" y="362" width="21" height="58" rx="9"/>
<rect x="198" y="362" width="21" height="58" rx="9"/>
</g>
<!-- Karosserie -->
<path class="karosserie" d="M112 6
C150 6, 198 16, 204 50 C209 90, 212 160, 212 250
C212 340, 209 410, 204 450 C198 484, 150 494, 112 494
C74 494, 26 484, 20 450 C15 410, 12 340, 12 250
C12 160, 15 90, 20 50 C26 16, 74 6, 112 6 Z"/>
<!-- Tech-Deck Face: die glänzend schwarze Blende über die
volle Bugbreite, die den Enyaq seit der Überarbeitung
kennzeichnet. Von oben sieht man ihre Oberkante. -->
<path class="techdeck" d="M112 7
C150 7, 197 17, 203 49 L194 53 C187 27, 148 18, 112 18
C76 18, 37 27, 30 53 L21 49 C27 17, 74 7, 112 7 Z"/>
<!-- Scheinwerfer, vier Elemente je Seite wie beim Original,
dazu die Rückleuchten als durchgehendes Band. -->
<g id="teil-lights" class="teil teil-licht" data-teil="lights">
<rect x="36" y="30" width="9" height="13" rx="4"/>
<rect x="48" y="27" width="9" height="13" rx="4"/>
<rect x="60" y="25" width="9" height="13" rx="4"/>
<rect x="72" y="24" width="9" height="13" rx="4"/>
<rect x="143" y="24" width="9" height="13" rx="4"/>
<rect x="155" y="25" width="9" height="13" rx="4"/>
<rect x="167" y="27" width="9" height="13" rx="4"/>
<rect x="179" y="30" width="9" height="13" rx="4"/>
<rect class="rueck" x="40" y="462" width="52" height="13" rx="6"/>
<rect class="rueck" x="132" y="462" width="52" height="13" rx="6"/>
<title>Beleuchtung</title>
</g>
<!-- Motorhaube mit angedeuteter Mittelsicke -->
<g id="teil-bonnet" class="teil" tabindex="0" data-teil="bonnet">
<path d="M31 152 L193 152 L189 62 C169 40, 55 40, 35 62 Z"/>
<title>Motorhaube</title>
</g>
<path class="sicke" d="M112 60 L112 150"/>
<!-- Windschutzscheibe, Dach, Heckscheibe -->
<path class="scheibe" d="M34 156 L190 156 L178 200 L46 200 Z"/>
<rect class="dach" x="46" y="200" width="132" height="136" rx="10"/>
<!-- Panoramaglasdach: beim Enyaq nahezu die ganze Dachfläche -->
<g id="teil-sunroof" class="teil" tabindex="0" data-teil="sunroof">
<rect x="62" y="206" width="100" height="120" rx="8"/>
<title>Panoramadach</title>
</g>
<!-- Dachreling -->
<g class="reling">
<rect x="48" y="204" width="9" height="134" rx="4"/>
<rect x="167" y="204" width="9" height="134" rx="4"/>
</g>
<path class="scheibe" d="M46 336 L178 336 L190 382 L34 382 Z"/>
<rect class="spoiler" x="52" y="330" width="120" height="9" rx="4"/>
<!-- Heckklappe -->
<g id="teil-trunk" class="teil" tabindex="0" data-teil="trunk">
<path d="M34 384 L190 384 L186 458 C168 478, 56 478, 38 458 Z"/>
<title>Heckklappe</title>
</g>
<!-- Türen, jeweils mit Seitenfenster darin -->
<g id="teil-doors" class="teil" data-teil="doors">
<g tabindex="0"><rect x="12" y="202" width="23" height="74" rx="6"/><title>Tür vorn links</title></g>
<g tabindex="0"><rect x="12" y="278" width="23" height="66" rx="6"/><title>Tür hinten links</title></g>
<g tabindex="0"><rect x="189" y="202" width="23" height="74" rx="6"/><title>Tür vorn rechts</title></g>
<g tabindex="0"><rect x="189" y="278" width="23" height="66" rx="6"/><title>Tür hinten rechts</title></g>
</g>
<g id="teil-windows" class="teil teil-fenster" data-teil="windows">
<rect x="17" y="210" width="13" height="58" rx="4"/>
<rect x="17" y="286" width="13" height="50" rx="4"/>
<rect x="194" y="210" width="13" height="58" rx="4"/>
<rect x="194" y="286" width="13" height="50" rx="4"/>
<title>Seitenfenster</title>
</g>
<!-- Außenspiegel -->
<g class="spiegel">
<rect x="1" y="194" width="16" height="10" rx="5"/>
<rect x="207" y="194" width="16" height="10" rx="5"/>
</g>
<!-- ============================================================
Steuerung: die beiden anklickbaren Stellen.
Bedient wird das, worum es geht - der Ladeanschluss für
das Laden, der Innenraum für Klima und Lüftung. Beide
tragen einen Ring als Hinweis darauf, dass sie mehr sind
als Anzeige, und öffnen ein Modal mit den Befehlen.
Sie stehen bewusst NICHT in der Zustandszeile darunter:
die zeigt, was ist. Diese beiden ändern etwas.
============================================================ -->
<!-- Ladeanschluss. Beim Enyaq sitzt die Klappe am hinteren
rechten Kotflügel; sie pulst, solange geladen wird. -->
<g id="teil-plug" class="teil teil-stecker steuer" tabindex="0"
role="button" data-steuer="laden" aria-label="Laden steuern">
<circle class="ring" cx="203" cy="408" r="17"/>
<circle cx="203" cy="408" r="11"/>
<path d="M198 402 l6 5 -4 0 4 6"/>
<title>Laden steuern</title>
</g>
<!-- Innenraum, auf der Windschutzscheibe: Klima und Lüftung. -->
<g id="steuer-klima" class="steuer" tabindex="0"
role="button" data-steuer="klima" aria-label="Klima und Lüftung steuern">
<circle class="ring" cx="112" cy="178" r="19"/>
<circle class="flaeche" cx="112" cy="178" r="13"/>
<path class="glyphe" d="M112 170 v10 M108 174 h8 M105 185 q7 -4 14 0"/>
<title>Klima und Lüftung steuern</title>
</g>
</svg>
<div class="skoda-teile" id="skodaTeile"></div>
<p class="skoda-hinweis">
Ladeanschluss und Innenraum antippen, um zu steuern
</p>
</div>
</div>
</div>
<!-- Batterie, Reichweite, Ladung -->
<div class="col-xxl-8 col-lg-7 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Batterie und Ladung</h3>
<div class="card-tools">
<span class="badge text-bg-secondary" id="skodaLadeZustand"></span>
</div>
</div>
<div class="card-body">
<div class="row align-items-center g-3">
<!-- Ladestandsring. Der Strich auf dem Ring ist das
eingestellte Ladeziel - eine Ladung, die dort endet, ist
kein Fehler, sondern Absicht. -->
<div class="col-md-5 text-center">
<svg class="skoda-ring" viewBox="0 0 200 200" role="img" aria-labelledby="skodaSocText">
<circle class="ring-bahn" cx="100" cy="100" r="82"/>
<circle class="ring-wert" id="skodaRing" cx="100" cy="100" r="82"/>
<line class="ring-ziel" id="skodaZiel" x1="100" y1="8" x2="100" y2="26"/>
<text id="skodaSocText" class="ring-zahl" x="100" y="98"></text>
<text class="ring-einheit" x="100" y="126">Prozent</text>
</svg>
</div>
<div class="col-md-7">
<div class="skoda-werte">
<div class="skoda-wert"><span class="k">Reichweite</span><span class="v" id="skodaReichweite"></span></div>
<div class="skoda-wert"><span class="k">Ladeleistung</span><span class="v" id="skodaLeistung"></span></div>
<div class="skoda-wert"><span class="k">Restzeit</span><span class="v" id="skodaRestzeit"></span></div>
<div class="skoda-wert"><span class="k">Ladeziel</span><span class="v" id="skodaZielText"></span></div>
<div class="skoda-wert"><span class="k">Kilometerstand</span><span class="v" id="skodaKm"></span></div>
<div class="skoda-wert"><span class="k">Klima</span><span class="v" id="skodaKlima"></span></div>
</div>
<!-- Woher der Strom gerade kommt: der Balken teilt die
Ladeleistung in den Anteil, den die PV deckt, und den
Rest aus dem Netz. -->
<div class="skoda-quelle mt-3" id="skodaQuelleBox">
<div class="d-flex justify-content-between small text-secondary mb-1">
<span>Ladestrom</span><span id="skodaQuelleText"></span>
</div>
<div class="skoda-balken">
<div class="teil-pv" id="skodaAnteilPV"></div>
<div class="teil-netz" id="skodaAnteilNetz"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ================================================= Ladungen ==== -->
<div class="row">
<div class="col-xl-8 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Verlauf der letzten Ladung</h3>
<div class="card-tools">
<span class="text-secondary small" id="skodaKurveZeit"></span>
</div>
</div>
<div class="card-body pb-1">
<div class="chart-container"><canvas id="skodaKurve"></canvas></div>
</div>
</div>
</div>
<div class="col-xl-4 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Ladungen</h3>
</div>
<div class="card-body p-0">
<div class="skoda-liste" id="skodaLadungen"></div>
</div>
</div>
</div>
</div>
<!-- ============================================== Akkugesundheit == -->
<div class="row">
<div class="col-xl-8 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Akkugesundheit</h3>
<div class="card-tools">
<span class="text-secondary small">kWh je 100 Ladestandsprozent</span>
</div>
</div>
<div class="card-body pb-1">
<div class="chart-container"><canvas id="skodaGesundheit"></canvas></div>
</div>
</div>
</div>
<div class="col-xl-4 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Befund</h3>
</div>
<div class="card-body">
<div class="skoda-befund" id="skodaBefund">
<p class="text-secondary mb-0">Noch keine ausgewerteten Ladungen.</p>
</div>
<p class="text-secondary small mb-0 mt-3">
Gemessen wird am Wallbox-Zähler, die Ladeverluste sind also
enthalten. Absolut ist der Wert eine Näherung aussagekräftig
ist sein Verlauf, denn ein alternder Akku bräuchte <em>weniger</em>
Kilowattstunden für dieselben hundert Prozentpunkte.
</p>
</div>
</div>
</div>
</div>
<!-- ============================================ Standort ========== -->
<!-- Karte auf Leaflet. Die Kacheln kommen nicht unmittelbar von
OpenStreetMap, sondern über ajax/tile.php aus dem eigenen
Zwischenspeicher auf dem NAS - siehe dort, warum.
Die Fahrtlinien sind gestrichelt, und zwar mit Absicht: die API
liefert nur die letzte bekannte PARKposition, also Start und Ziel.
Die tatsächlich gefahrene Strecke kennt niemand, und eine
durchgezogene Linie auf einer Straßenkarte würde genau das
behaupten. -->
<div class="row">
<div class="col-xl-7 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Standort und Fahrten</h3>
<div class="card-tools">
<button type="button" id="skodaKarteHeim" class="btn btn-tool"
title="Auf den aktuellen Standort zentrieren">
<i class="bi bi-geo-alt"></i>
</button>
<a href="#" id="skodaOsmLink" class="btn btn-tool" target="_blank"
rel="noopener noreferrer" title="Auf OpenStreetMap öffnen">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
</div>
<div class="card-body p-2">
<div id="skodaKarte" class="skoda-karte"></div>
<div class="d-flex justify-content-between align-items-center px-2 pt-2 pb-1">
<span class="text-secondary small" id="skodaStandort"></span>
<span class="skoda-kartenlegende">
<span><i class="pkt jetzt"></i>jetzt</span>
<span><i class="pkt halt"></i>Halt</span>
<span><i class="lin"></i>Luftlinie</span>
</span>
</div>
</div>
</div>
</div>
<div class="col-xl-5 d-flex align-items-stretch">
<!-- Die Klasse zeigt-kwh / zeigt-prozent auf dieser Kachel
entscheidet, welche Einheit die Zeilen anzeigen. Beide Werte
stehen im Markup, umgeschaltet wird allein per CSS - so muss
beim Wechsel nichts neu gebaut werden, und die Auswahl
übersteht auch die minütliche Auffrischung der Liste. -->
<div class="card mb-4 p-0 w-100 zeigt-kwh" id="skodaFahrtenKachel">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Fahrten</h3>
<div class="card-tools d-flex align-items-center gap-2">
<!-- Text kommt aus zeigeVerbrauchsbasis(): der Verbrauch in
Kilowattstunden ist nicht gemessen, sondern aus dem
Ladestand und der eigenen Ladehistorie gerechnet. Womit
genau, steht im Titel dieses Elements. -->
<span class="text-secondary small" id="skodaVerbrauchBasis"></span>
<span class="skoda-umschalter" id="skodaEinheit" role="group"
aria-label="Einheit des Verbrauchs">
<button type="button" data-einheit="kwh" aria-pressed="true">kWh</button>
<button type="button" data-einheit="prozent" aria-pressed="false">%</button>
</span>
</div>
</div>
<div class="card-body p-0">
<div class="skoda-liste" id="skodaFahrten"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!--end::App Main-->
+1 -1
View File
@@ -74,7 +74,7 @@
</button>
</div>
</div>
<div class="row-cols-1 row-cols-md-1 g-4 p-2 pb-0" id="Stats-Year">
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="Stats-Year">
</div>
</div>
+63
View File
@@ -0,0 +1,63 @@
-- Preistabellen der Statistik: feste Zahlen rein, Redundanz raus.
--
-- 1) gasCosts und fuelCosts lagen unbenutzt herum; ajax/getStats.php rechnete
-- stattdessen mit drei fest eingetragenen Zahlen:
--
-- 140 Grundpreis Strom pro Jahr -> gridCosts.base
-- 0.063 Gaspreis je kWh -> gasCosts.cost / gasCosts.kwhPerLitre
-- 24.5 12 * kWh je Liter Benzin -> fuelCosts.lPer100km / kwhPer100km
--
-- Beim Gas stand der Energieinhalt bisher nur als Kommentar an der Spalte
-- ("6.58kWh/l"), beim Benzin steckte er unsichtbar im Teiler 24.5. Beides
-- ist keine Physik, die sich nie aendert, sondern haengt am Brennstoff und
-- am Ersatzfahrzeug - und gehoert deshalb neben den Preis.
--
-- Beim Auto nicht als "kWh je Liter": diese Zahl steht auf keinem Datenblatt
-- und niemand hat sie im Kopf. Eingetragen werden die beiden Verbraeuche,
-- die man kennt - Liter je 100 km beim Verbrenner, kWh je 100 km beim
-- Stromer. Wieviele Liter eine geladene kWh ersetzt, rechnet die Abfrage
-- daraus aus.
--
-- 2) end_date faellt weg. Die Spalte war reine Redundanz: sie sagte nie etwas
-- anderes als "Tag vor dem naechsten Stichtag" - konnte aber, weil sie
-- eigenstaendig gepflegt wurde, etwas anderes sagen. Genau das war
-- passiert: gridCosts liess zwei Zeilen ueber das erste Quartal 2023
-- laufen, der JOIN in getStats.php fand fuer jeden Messwert zwei Preise,
-- und der Stromverbrauch 2023 stand mit 2527 statt 1674 kWh in der
-- Statistik - 51 % zu hoch.
--
-- Ohne die Spalte laesst sich eine Ueberlappung nicht mehr hinschreiben,
-- und eine Luecke auch nicht. Das Ende einer Zeile holt sich die Abfrage
-- mit LEAD(active_date) beim naechsten Stichtag; die juengste Zeile hat
-- kein Ende und gilt weiter, bis ein neuer Preis eingetragen wird. So
-- verhaelt sich ein Tarif ja auch.
--
-- Nebenbei ist die Statistik dadurch rund viermal so schnell (2,9 s -> 0,7 s
-- je Jahr): der Optimierer schlaegt die drei kleinen Preistabellen einmal
-- auf, statt fuer jeden der 100.000 Messwerte einen Bereich zu durchsuchen.
--
-- Anwenden: mysql -h <host> -P 3310 -u solarLog -p solarLog < solarLog_costs.sql
ALTER TABLE `gridCosts`
ADD COLUMN `base` float NOT NULL DEFAULT 0
COMMENT 'Grundpreis pro Jahr in Euro' AFTER `gain`,
DROP KEY `end_date`,
DROP COLUMN `end_date`;
-- Bis hierher stand die 140 fest im SQL - fuer alle Jahre gleich.
UPDATE `gridCosts` SET `base` = 140;
-- Die Zeile ab 2022-12-13 lief bis 2023-03-31 und ueberlappte damit die
-- naechste. Ohne end_date endet sie automatisch am 31.12.2022.
ALTER TABLE `gasCosts`
ADD COLUMN `kwhPerLitre` float NOT NULL DEFAULT 6.58
COMMENT 'Energieinhalt: kWh je Liter';
ALTER TABLE `fuelCosts`
ADD COLUMN `lPer100km` float NOT NULL DEFAULT 9.8
COMMENT 'Verbrauch des Verbrenners in Liter je 100 km',
ADD COLUMN `kwhPer100km` float NOT NULL DEFAULT 20
COMMENT 'Verbrauch des Elektroautos in kWh je 100 km',
DROP KEY `end_date`,
DROP COLUMN `end_date`;