diff --git a/.gitignore b/.gitignore index 88c52db..e6c3b58 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,7 @@ Thumbs.db *.tmp *.bak *.backup + +# Kartenkacheln des Zwischenspeichers (ajax/tile.php). +# Wachsen mit dem, was angesehen wurde - gehoeren nicht ins Repository. +tiles/ diff --git a/ajax/energyHistory.php b/ajax/energyHistory.php index c9bf534..e797fa6 100644 --- a/ajax/energyHistory.php +++ b/ajax/energyHistory.php @@ -4,30 +4,94 @@ * Ersetzt getConsData_{month,year,decade}.php und getProdData_{month,year,decade}.php. * * Aufruf: energyHistory.php?series=prod|cons&range=month|year|decade + * + * Zwei Quellen, je nach Zeitraum: + * + * month EnergyFlow, also die Rohdaten. Nach Tagen gruppiert, und der + * heutige Balken soll bis zur letzten Messung reichen. Das + * Stundenarchiv wird nur einmal nachts fortgeschrieben und wäre + * für den laufenden Tag noch leer. + * + * year EnergyFlow_hourly, das Stundenarchiv. Die Rohdaten werden nach + * decade zwölf Monaten ausgedünnt (solarlog-rollup.sh thin), diese + * Diagramme fänden dort also nichts mehr. Bei Gruppierung nach + * Monat bzw. Jahr fällt der fehlende laufende Tag nicht auf. + * + * Beide Quellen liefern Wattstunden, weil die Diagramme auf der y-Achse durch + * 1.000.000 teilen und MWh beschriften. Die Rohdaten sind Momentanleistungen + * alle fünf Minuten, daher /12; im Archiv stehen bereits kWh, daher *1000. + * Gegengerechnet über einen kompletten Monat stimmen beide auf 0,003 % überein + * - der Rest ist die /12-Näherung gegenüber der echten Zeitgewichtung. + * + * pv_fehlbetrag_kwh wird zur erfassten Erzeugung addiert. Wenn Wechselrichter + * die Verbindung zur OpenDTU verlieren, fehlt ihr Beitrag in pvP bzw. pv_kwh - + * die Einspeisung, die Batterieladung und der Heizstab sind aber vollständig + * gemessen. Ohne die Korrektur wird der Direktverbrauch als Differenz negativ + * und der Balken bleibt leer, etwa am 03.08.2026 mit -0,5 statt +15,9 kWh. + * Im Rohdaten-Zweig kommt der Betrag über eine nach Tagen vorverdichtete + * abgeleitete Tabelle dazu, sonst zählte ihn jede der zwölf Zeilen je Stunde + * erneut; für den laufenden Tag gibt es ihn noch nicht, dann bleibt er 0. + * + * Die Einspeisung kommt in beiden Fällen aus gridPfeed, dem Zählerregister, + * nicht aus dem Saldo gridP. Innerhalb eines Fensters kommen Bezug und + * Einspeisung gleichzeitig vor; der Saldo löscht kurze Bezüge weg und passt + * dann nicht mehr zu dem, was abgerechnet wird. */ require_once("../helper.php"); require_once("./chartData.php"); -// Zeitraum -> [Format der Beschriftung, Filter auf datetime] +// Zeitraum -> [Format der Beschriftung, Quelle] $ranges = [ - "month" => ["'%d.%b.'", "DATE_FORMAT(datetime,'%Y%m%d') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 1 MONTH),'%Y%m%d')"], - "year" => ["'%b. %y'", "DATE_FORMAT(datetime,'%Y%m') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 13 MONTH),'%Y%m')"], - "decade" => ["'%Y'", "DATE_FORMAT(datetime,'%Y') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 10 YEAR),'%Y')"], + "month" => ["'%d.%b.'", "roh"], + "year" => ["'%b. %y'", "archiv"], + "decade" => ["'%Y'", "archiv"], +]; + +// Filter auf die Zeitspalte. ZEIT wird durch den Spaltennamen der Quelle ersetzt. +$filters = [ + "month" => "DATE_FORMAT(ZEIT,'%Y%m%d') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 1 MONTH),'%Y%m%d')", + "year" => "DATE_FORMAT(ZEIT,'%Y%m') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 13 MONTH),'%Y%m')", + "decade" => "DATE_FORMAT(ZEIT,'%Y') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 10 YEAR),'%Y')", ]; // Datenreihen je Diagramm. Beide teilen sich Solarertrag und Heizstab und // unterscheiden sich nur darin, wohin die Energie fließt (prod) bzw. woher // sie kommt (cons). -$series = [ - "prod" => "SUM(pvP/12 - IF(gridP < 0, -gridP/12,0) - IF(battP < 0, -battP/12,0) - heaterPwr/12) AS Direktverbrauch, +$quellen = [ + "roh" => [ + "tabelle" => "EnergyFlow", + "zeit" => "datetime", + "join" => "LEFT JOIN (SELECT DATE(stunde) AS tag, SUM(pv_fehlbetrag_kwh) AS fehlt + FROM EnergyFlow_hourly GROUP BY 1) f ON f.tag = DATE(datetime)", + "reihen" => [ + "prod" => "SUM(pvP/12 - gridPfeed/12 - IF(battP < 0, -battP/12,0) - heaterPwr/12) + + IFNULL(MAX(f.fehlt),0)*1000 AS Direktverbrauch, SUM(heaterPwr/12) AS Heizstab, SUM(IF(battP < 0, -battP/12,0)) AS Batterieladung, SUM(gridPfeed/12) AS Einspeisung", - "cons" => "SUM(pvP/12 - IF(gridP < 0, -gridP/12,0) - IF(battP < 0, -battP/12,0) - heaterPwr/12) AS Solarverbrauch, + "cons" => "SUM(pvP/12 - gridPfeed/12 - IF(battP < 0, -battP/12,0) - heaterPwr/12) + + IFNULL(MAX(f.fehlt),0)*1000 AS Solarverbrauch, SUM(heaterPwr/12) AS Heizstab, SUM(IF(battP > 0, battP/12,0)) AS Batteriebezug, SUM(gridPcons/12) AS Netzbezug", + ], + ], + "archiv" => [ + "tabelle" => "EnergyFlow_hourly", + "zeit" => "stunde", + "join" => "", + "reihen" => [ + "prod" => "SUM((pv_kwh + IFNULL(pv_fehlbetrag_kwh,0) - netz_einsp_kwh - batt_laden_kwh - heizstab_kwh)*1000) AS Direktverbrauch, + SUM(heizstab_kwh*1000) AS Heizstab, + SUM(batt_laden_kwh*1000) AS Batterieladung, + SUM(netz_einsp_kwh*1000) AS Einspeisung", + "cons" => "SUM((pv_kwh + IFNULL(pv_fehlbetrag_kwh,0) - netz_einsp_kwh - batt_laden_kwh - heizstab_kwh)*1000) AS Solarverbrauch, + SUM(heizstab_kwh*1000) AS Heizstab, + SUM(batt_entladen_kwh*1000) AS Batteriebezug, + SUM(netz_bezug_kwh*1000) AS Netzbezug", + ], + ], ]; $linecolors["Direktverbrauch"] = "#FFFF00"; @@ -46,14 +110,17 @@ $linecolors["Vorhersage"] = "#2222FF"; header('Content-Type: application/json; charset=utf-8'); -$rangeKey = isset($_GET["range"]) ? $_GET["range"] : ""; +$rangeKey = isset($_GET["range"]) ? $_GET["range"] : ""; $seriesKey = isset($_GET["series"]) ? $_GET["series"] : ""; -if (!isset($ranges[$rangeKey]) || !isset($series[$seriesKey])) { +if (!isset($ranges[$rangeKey]) || !in_array($seriesKey, ["prod", "cons"], true)) { http_response_code(400); echo json_encode(["error" => "Unbekannter Zeitraum oder Datenreihe."]); exit; } -list($labelFormat, $filter) = $ranges[$rangeKey]; +list($labelFormat, $quelleKey) = $ranges[$rangeKey]; +$quelle = $quellen[$quelleKey]; +$zeit = $quelle["zeit"]; +$filter = str_replace("ZEIT", $zeit, $filters[$rangeKey]); $obj = (object)[]; $obj->labels = []; @@ -62,12 +129,12 @@ $obj->datasets = []; if (checkLogin()) { $mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB); $query = "SELECT - DATE_FORMAT(datetime, $labelFormat) AS 'time', - " . $series[$seriesKey] . " -FROM EnergyFlow + DATE_FORMAT($zeit, $labelFormat) AS 'time', + " . $quelle["reihen"][$seriesKey] . " +FROM " . $quelle["tabelle"] . " " . $quelle["join"] . " WHERE $filter - GROUP BY DATE_FORMAT(datetime, $labelFormat) -ORDER BY datetime ; "; + GROUP BY DATE_FORMAT($zeit, $labelFormat) +ORDER BY MIN($zeit) ; "; $obj = buildChartData($mysql, $query, $linecolors); } diff --git a/ajax/getStats.php b/ajax/getStats.php index 54d9ca4..c146b5e 100644 --- a/ajax/getStats.php +++ b/ajax/getStats.php @@ -1,6 +1,7 @@ 0, battP,0))*cost/12000 as 'Verbrauchsersparnis', - SUM((gridPfeed*gain)/12000) as 'Einspeisevergütung', - SUM(IF(gridP < 500, heaterPwr, 0)*0.063/12000) as 'Ersparnis Heizung', - SUM(IF(battP > 0, battP, 0)*cost/12000) as 'Ersparnis Batterie', - AVG(autonomy) AS 'Ø Autarkie', - (SUM(PL1_EV+PL2_EV+PL3_EV))/12 AS 'Autoladung ges. EG', - (((SUM(IF(gridP>100,0,PL1_EV+PL2_EV+PL3_EV)))/(SUM(PL1_EV+PL2_EV+PL3_EV))))*100 AS 'Autoladung Solar EG', - (SUM(IF(gridP>100,0,(PL1_EV+PL2_EV+PL3_EV)*cost))/12) AS 'Ersparnis Solarladung EG', - (SUM(PL1_EV+PL2_EV+PL3_EV))/24.5 AS 'Benzin gespart EG', - (SUM(PL1_EVog+PL2_EVog+PL3_EVog))/12 AS 'Autoladung ges. OG', - (((SUM(IF(gridP>100,0,PL1_EVog+PL2_EVog+PL3_EVog)))/(SUM(PL1_EVog+PL2_EVog+PL3_EVog))))*100 AS 'Autoladung Solar OG', - (SUM(IF(gridP>100,0,(PL1_EVog+PL2_EVog+PL3_EVog)*cost))/12) AS 'Ersparnis Solarladung OG', - (SUM(PL1_EVog+PL2_EVog+PL3_EVog))/24.5 AS 'Benzin gespart OG' -FROM EnergyFlow JOIN gridCosts ON DATE(datetime) >= DATE(gridCosts.active_date) AND DATE(datetime) <= DATE(gridCosts.end_date) -WHERE year(datetime) = ".$_GET["year"].";"; +/** + * Die Jahresstatistik als eine Abfrage - jetzt auf stats_daily. + * + * Gerechnet wird nicht mehr ueber die Rohdaten aus EnergyFlow, sondern ueber + * die Tageswerte. Zwei Gruende: + * + * 1. Die Rohdaten werden nach zwoelf Monaten ausgeduennt bzw. ins + * Stundenarchiv verschoben (solarlog-rollup.sh). Eine Kachel, die auf + * EnergyFlow rechnet, zeigt fuer alte Jahre danach zu wenig an. + * 2. Ein Jahr sind 365 Zeilen statt rund 105.000. + * + * Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt + * LEAD() beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie + * zwei Tarife haben - die Verdichtung auf Tage verliert hier also nichts. + * + * Was in stats_daily vorberechnet sein MUSS, weil es eine Bedingung je + * Messwert auswertet und sich aus Tagessummen nicht rekonstruieren laesst: + * + * eigenverbrauch_kwh pv - Einspeisung - Batterieladung + * - Heizstab + Batterieentladung. Dazu addiert + * wird pv_fehlbetrag_kwh: bei Ausfall einzelner + * Wechselrichter fehlt deren Beitrag in pv, alle + * Senken sind dagegen gemessen - die Differenz + * faellt sonst zu klein oder negativ aus. + * heizstab_solar_kwh Heizstab, anteilig nach autonomy + * auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom + * Geraet in feiner Aufloesung gerechneten + * Nicht-Netz-Anteil der Zufuhr + * + * Einheiten sind in stats_daily bereits bereinigt: PL*_EV und PL*_EVog + * liefern Kilowatt, alle uebrigen Leistungsspalten Watt. In der alten + * Abfrage stand das als /12 gegen /12000 nebeneinander. + * + * Die Solaranteile teilen jetzt durch NULLIF(...): ohne Ladung im Jahr kam + * vorher 0/0 heraus. + * + * "Ersparnis Batterie" rechnet den Nutzen netto: was die entladene Energie + * aus dem Netz gekostet haette, abzueglich der Verguetung, die fuer den zum + * Laden verwendeten Strom entgangen ist. Brutto waere die Kachel zu + * freundlich - der Speicher ist nicht umsonst zu fuellen. + * + * Achtung beim Lesen: "Verbrauchsersparnis" ist der GESAMTE Eigenverbrauch + * zum Strompreis. "Ersparnis Batterie" und "Ersparnis Solarladung" sind + * Teilmengen davon, nach Herkunft bzw. Verwendung herausgegriffen. Sie + * duerfen nicht addiert werden. + * + * Der Grundpreis fehlt weiterhin bewusst - er haengt an den Tagen des + * Jahres, nicht an einem Messwert, und kommt aus grundpreisImJahr() dazu. + */ +function statistikAbfrage($jahr) +{ + return "WITH + preis AS (SELECT active_date, cost, gain, + LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gridCosts), + gas AS (SELECT active_date, cost, kwhPerLitre, + LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gasCosts), + sprit AS (SELECT active_date, cost, lPer100km, kwhPer100km, + LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM fuelCosts) +SELECT + SUM(d.netz_bezug_kwh) AS 'Stromverbrauch', + SUM(d.netz_bezug_kwh * preis.cost) AS 'Stromkosten', + SUM((d.eigenverbrauch_kwh + IFNULL(d.pv_fehlbetrag_kwh,0)) * preis.cost) AS 'Verbrauchsersparnis', + SUM(d.netz_einsp_kwh * preis.gain) AS 'Einspeisevergütung', + SUM(d.heizstab_solar_kwh * gas.cost / NULLIF(gas.kwhPerLitre,0)) AS 'Ersparnis Heizung', + SUM(d.batt_entladen_kwh * preis.cost - d.batt_laden_kwh * preis.gain) AS 'Ersparnis Batterie', + SUM(d.autarkie_avg * d.stunden_erfasst) / NULLIF(SUM(d.stunden_erfasst),0) AS 'Ø Autarkie', + SUM(d.auto_eg_kwh) AS 'Autoladung ges. EG', + SUM(d.auto_eg_solar_kwh) / NULLIF(SUM(d.auto_eg_kwh),0) * 100 AS 'Autoladung Solar EG', + SUM(d.auto_eg_solar_kwh * preis.cost) AS 'Ersparnis Solarladung EG', + SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart EG', + SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin EG', + SUM(d.auto_og_kwh) AS 'Autoladung ges. OG', + SUM(d.auto_og_solar_kwh) / NULLIF(SUM(d.auto_og_kwh),0) * 100 AS 'Autoladung Solar OG', + SUM(d.auto_og_solar_kwh * preis.cost) AS 'Ersparnis Solarladung OG', + SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart OG', + SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin OG' +FROM stats_daily d + JOIN preis ON d.tag >= preis.active_date AND (preis.folgt IS NULL OR d.tag < preis.folgt) + LEFT JOIN gas ON d.tag >= gas.active_date AND (gas.folgt IS NULL OR d.tag < gas.folgt) + LEFT JOIN sprit ON d.tag >= sprit.active_date AND (sprit.folgt IS NULL OR d.tag < sprit.folgt) +WHERE YEAR(d.tag) = " . intval($jahr) . ";"; +} -$PrevQuery = "SELECT - SUM(gridPcons/12000) as 'Stromverbrauch', - SUM((gridPcons*cost)/12000)+140 as 'Stromkosten (140€ fix)', - SUM(pvP - IF(gridP < 0, -gridP,0) - IF(battP < 0, -battP,0)- heaterPwr + IF(battP > 0, battP,0))*cost/12000 as 'Verbrauchsersparnis', - SUM((gridPfeed*gain)/12000) as 'Einspeisevergütung', - SUM(IF(gridP < 500, heaterPwr, 0)*0.063/12000) as 'Ersparnis Heizung', - SUM(IF(battP > 0, battP, 0)*cost/12000) as 'Ersparnis Batterie', - AVG(autonomy) AS 'Ø Autarkie', - (SUM(PL1_EV+PL2_EV+PL3_EV))/12 AS 'Autoladung ges. EG', - (((SUM(IF(gridP>100,0,PL1_EV+PL2_EV+PL3_EV)))/(SUM(PL1_EV+PL2_EV+PL3_EV))))*100 AS 'Autoladung Solar EG', - (SUM(IF(gridP>100,0,(PL1_EV+PL2_EV+PL3_EV)*cost))/12) AS 'Ersparnis Solarladung EG', - (SUM(PL1_EV+PL2_EV+PL3_EV))/24.5 AS 'Benzin gespart EG', - (SUM(PL1_EVog+PL2_EVog+PL3_EVog))/12 AS 'Autoladung ges. OG', - (((SUM(IF(gridP>100,0,PL1_EVog+PL2_EVog+PL3_EVog)))/(SUM(PL1_EVog+PL2_EVog+PL3_EVog))))*100 AS 'Autoladung Solar OG', - (SUM(IF(gridP>100,0,(PL1_EVog+PL2_EVog+PL3_EVog)*cost))/12) AS 'Ersparnis Solarladung OG', - (SUM(PL1_EVog+PL2_EVog+PL3_EVog))/24.5 AS 'Benzin gespart OG' -FROM EnergyFlow JOIN gridCosts ON DATE(datetime) >= DATE(gridCosts.active_date) AND DATE(datetime) <= DATE(gridCosts.end_date) -WHERE year(datetime) = ".($_GET["year"]-1).";"; +$Query = statistikAbfrage($_GET["year"]); +$PrevQuery = statistikAbfrage($_GET["year"] - 1); - -$units = Array("kWh","€","€","€","€","€","€","%","kWh","%","€","L","kWh","%","€","L"); -$LessIsBetter = Array(true,true,false,false,false,false,false,false,false,false,false,false,false,false,false,false); +// Reihenfolge wie in der Abfrage, mit "Verg.+Einsp. Strom" an Stelle 4. +$units = Array("kWh","€","€","€","€","€","€","%", + "kWh","%","€","L","€", + "kWh","%","€","L","€"); +// Nur bei Verbrauch und Kosten ist weniger besser. +$LessIsBetter = array_merge([true, true], array_fill(0, count($units) - 2, false)); function array_insert($array,$values,$offset) { return array_slice($array, 0, $offset, true) + $values + array_slice($array, $offset, NULL, true); @@ -67,7 +119,7 @@ function array_insert($array,$values,$offset) { if (checkLogin()) { $html = "
"; - $mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB); + $mysql = solarDb(); $Res = mysqli_query($mysql,$Query); $ResPrev = mysqli_query($mysql,$PrevQuery); if(!$Res || !$ResPrev){ @@ -77,13 +129,17 @@ if (checkLogin()) { $i = 0; $row = $Res->fetch_assoc(); $rowPrev = $ResPrev->fetch_assoc(); + // Der Grundpreis faellt unabhaengig vom Verbrauch an und laesst sich + // deshalb nicht aus den Messwerten summieren. + $row["Stromkosten"] += grundpreisImJahr($_GET["year"]); + $rowPrev["Stromkosten"] += grundpreisImJahr($_GET["year"] - 1); //$row["Vergütung+Einsparung Strom"] = $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"]; $row = array_insert($row,["Verg.+Einsp. Strom" => $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"]],4); $rowPrev = array_insert($rowPrev,["Verg.+Einsp. Strom" => $rowPrev["Einspeisevergütung"]+$rowPrev["Verbrauchsersparnis"]],4); $i = 0; foreach ($row as $name => $value) { if(str_starts_with($name,"Autoladung ges.")){ - $html .= "

"; + $html .= "
"; $html .= "
"; } /*$html .= "
diff --git a/ajax/settings.php b/ajax/settings.php new file mode 100644 index 0000000..2977bc9 --- /dev/null +++ b/ajax/settings.php @@ -0,0 +1,66 @@ + "Konnte die Anfrage nicht lesen."], 400); + } + try { + switch ($action) { + case "save": + $anzahl = preiseSpeichern($body["reihe"] ?? "", $body["zeilen"] ?? []); + // Die gespeicherte Fassung zurueckgeben: die Maske zeigt danach + // die abgeleiteten Enddaten, nicht die eingetippten. + jsonAntwort([ + "gespeichert" => $anzahl, + "zeilen" => preiseLaden($body["reihe"]), + ]); + default: + jsonAntwort(["error" => "Unbekannte Aktion."], 400); + } + } catch (InvalidArgumentException $e) { + jsonAntwort(["error" => $e->getMessage()], 400); + } catch (Throwable $e) { + jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500); + } +} + +if ($action === "list") { + try { + jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]); + } catch (InvalidArgumentException $e) { + jsonAntwort(["error" => $e->getMessage()], 400); + } catch (Throwable $e) { + jsonAntwort(["error" => "Laden fehlgeschlagen: " . $e->getMessage()], 500); + } +} + +jsonAntwort(["error" => "Unbekannte Aktion."], 400); diff --git a/ajax/skoda.php b/ajax/skoda.php new file mode 100644 index 0000000..5685c9e --- /dev/null +++ b/ajax/skoda.php @@ -0,0 +1,366 @@ + "Nicht angemeldet."]); + exit; +} + +$was = isset($_GET["was"]) ? $_GET["was"] : "live"; +$tage = isset($_GET["tage"]) ? max(1, min(3650, intval($_GET["tage"]))) : 90; + +$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB); +if ($mysql->connect_errno) { + http_response_code(500); + echo json_encode(["error" => "Datenbank nicht erreichbar."]); + exit; +} +$mysql->set_charset("utf8mb4"); + +/* + * Ladevorgänge aus den Einzelzeilen schneiden. Aufeinanderfolgende Zeilen mit + * chg_state = 'CHARGING' gehören zusammen, eine Pause über 30 Minuten trennt + * zwei Vorgänge. Wird von mehreren Abschnitten gebraucht, deshalb einmal hier. + * + * Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer + * Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde. + * Steht der Zähler still, wurde auswärts geladen (Schnellladesäule); dann + * bleibt wh null und die Kapazitätsrechnung lässt die Ladung aus. + */ +function sitzungenSQL($seit) +{ + return " + SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende, + MIN(soc) AS soc_von, MAX(soc) AS soc_bis, + CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc) AS SIGNED) AS zuwachs, + MAX(chg_type) AS art, MAX(target_soc) AS ziel, + ROUND(AVG(chg_kw), 2) AS kw_mittel, ROUND(MAX(chg_kw), 2) AS kw_spitze, + MAX(wb_wh_total) - MIN(wb_wh_total) AS wh, + ROUND(SUM(kwh_teil), 3) AS kwh_fahrzeug, + ROUND(AVG(pv_kw), 2) AS pv_kw, COUNT(*) AS punkte + FROM ( + SELECT s.*, SUM(s.neu) OVER (ORDER BY s.datetime) AS sitzung FROM ( + SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc, + wb_wh_total, pv_kw, + CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING' + AND TIMESTAMPDIFF(MINUTE, LAG(datetime) OVER w, datetime) <= 30 + THEN 0 ELSE 1 END AS neu, + /* + * Fahrzeugseitige Energie: chg_kw ist die Ladeleistung, die + * das Fahrzeug selbst meldet - gemessen HINTER dem Ladegerät. + * Die API kennt keine Kilowattstunden, deshalb wird hier über + * die Zeit integriert, je Schritt mit dem Mittel aus vorigem + * und aktuellem Wert (Trapez). Beim Laden mit Wechselstrom + * liegt die Leistung fast konstant an, da ist das genau genug. + * + * Die Bedingung hält die Summe innerhalb einer Ladung: über + * eine Lücke von mehr als 30 Minuten hinweg wäre die Fläche + * unter der Kurve frei erfunden. + */ + CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING' + AND TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) + BETWEEN 1 AND 1800 + THEN (COALESCE(chg_kw, 0) + COALESCE(LAG(chg_kw) OVER w, 0)) / 2 + * TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) / 3600 + ELSE 0 END AS kwh_teil + FROM skoda WHERE datetime >= $seit + WINDOW w AS (ORDER BY datetime)) s) t + WHERE chg_state = 'CHARGING' + GROUP BY sitzung"; +} + +/* + * Rückfallwert für den Ladewirkungsgrad, solange er sich nicht messen lässt. + * Beim Laden mit Wechselstrom gehen im Ladegerät des Fahrzeugs grob zehn + * Prozent verloren. + */ +const LADE_WIRKUNGSGRAD_ANNAHME = 0.90; + +/** Ab so vielen Ladungen gilt der gemessene Wirkungsgrad als belastbar. */ +const WIRKUNGSGRAD_MIN_LADUNGEN = 5; + +/** + * Wie viele Kilowattstunden ein Ladestandsprozent wert ist. + * + * Zwei Energien je Ladung stehen zur Verfügung, und der Unterschied zwischen + * ihnen ist genau das, was das Ladegerät verheizt: + * + * wh Differenz der Wallbox-Zählerstände - vor dem Ladegerät + * kwh_fahrzeug chg_kw über die Zeit integriert - hinter dem Ladegerät + * + * Für den Fahrverbrauch zählt, was die Batterie verlässt, also die zweite. + * Sie wird bevorzugt; erst wenn zu wenige Ladungen sie hergeben, wird der + * Zählerwert mit dem angenommenen Wirkungsgrad heruntergerechnet. + * + * Der Median statt des Mittels, weil einzelne Ladungen durch die Rundung des + * Ladestands weit ausreissen können. + * + * Gibt null zurück, solange es zu wenige Ladungen gibt. Die Oberfläche zeigt + * dann keine Kilowattstunden an, statt eine erfundene Zahl. + */ +function kapazitaetSchaetzen($mysql) +{ + $sql = "SELECT ROUND(wh/1000/zuwachs*100, 3) AS je100_zaehler, + CASE WHEN kwh_fahrzeug > 0 + THEN ROUND(kwh_fahrzeug/zuwachs*100, 3) END AS je100_fahrzeug + FROM (" . sitzungenSQL("NOW() - INTERVAL 365 DAY") . ") v + WHERE zuwachs >= 20 AND wh > 1000"; + $res = $mysql->query($sql); + if (!$res) { + return null; + } + $zaehler = []; + $fahrzeug = []; + $paare = []; // beide Werte derselben Ladung, für den Wirkungsgrad + while ($z = $res->fetch_assoc()) { + $zaehler[] = floatval($z["je100_zaehler"]); + if ($z["je100_fahrzeug"] !== null) { + $fahrzeug[] = floatval($z["je100_fahrzeug"]); + $paare[] = floatval($z["je100_fahrzeug"]) / floatval($z["je100_zaehler"]); + } + } + if (count($zaehler) < 3) { + return null; + } + + $gemessen = count($fahrzeug) >= WIRKUNGSGRAD_MIN_LADUNGEN; + if ($gemessen) { + $netto = median($fahrzeug); + $grad = median($paare); + } else { + $netto = median($zaehler) * LADE_WIRKUNGSGRAD_ANNAHME; + $grad = LADE_WIRKUNGSGRAD_ANNAHME; + } + + return [ + "je100_zaehler" => round(median($zaehler), 2), + "je100_netto" => round($netto, 2), + "wirkungsgrad" => round($grad, 3), + "gemessen" => $gemessen, + "je_soc_netto" => round($netto / 100, 4), + "n" => count($zaehler), + "n_fahrzeug" => count($fahrzeug), + ]; +} + +/** Median eines nicht leeren Zahlenfeldes. */ +function median($werte) +{ + sort($werte); + return $werte[intdiv(count($werte), 2)]; +} + +$antwort = []; + +switch ($was) { + +/* ------------------------------------------------------------------ live -- */ +case "live": + $res = $mysql->query("SELECT * FROM skoda ORDER BY id DESC LIMIT 1"); + $zeile = $res ? $res->fetch_assoc() : null; + $antwort = ["stand" => $zeile]; + + // Wie lange schweigt das Fahrzeug schon? Sagt mehr als der Zeitstempel + // allein, weil die Seite auch aus dem Zwischenspeicher kommen kann. + if ($zeile) { + $res = $mysql->query("SELECT TIMESTAMPDIFF(SECOND, '" + . $mysql->real_escape_string($zeile["datetime"]) . "', NOW()) AS alter_s"); + $r = $res ? $res->fetch_assoc() : null; + $antwort["alter_s"] = $r ? intval($r["alter_s"]) : null; + } + + // Kennzahlen über den gesamten Bestand, für die Kopfzeile. + $res = $mysql->query("SELECT COUNT(*) AS zeilen, MIN(datetime) AS seit, + MAX(odo_km) AS km FROM skoda"); + $antwort["bestand"] = $res ? $res->fetch_assoc() : null; + break; + +/* -------------------------------------------------------------- ladungen -- */ +case "ladungen": + $sql = "SELECT * FROM (" . sitzungenSQL("NOW() - INTERVAL $tage DAY") . ") v + ORDER BY beginn DESC LIMIT 200"; + $res = $mysql->query($sql); + $antwort["ladungen"] = []; + while ($res && $z = $res->fetch_assoc()) { + $antwort["ladungen"][] = $z; + } + break; + +/* ----------------------------------------------------------------- kurve -- */ +case "kurve": + // Verlauf einer Ladung. Ohne Parameter die jüngste. + $von = isset($_GET["von"]) ? $_GET["von"] : null; + $bis = isset($_GET["bis"]) ? $_GET["bis"] : null; + if (!$von || !$bis) { + $sql = "SELECT beginn, ende FROM (" . sitzungenSQL("NOW() - INTERVAL 365 DAY") . ") v + ORDER BY beginn DESC LIMIT 1"; + $res = $mysql->query($sql); + $z = $res ? $res->fetch_assoc() : null; + if (!$z) { $antwort["punkte"] = []; break; } + $von = $z["beginn"]; + $bis = $z["ende"]; + } + // Eine Zeile Vorlauf und Nachlauf, damit die Kurve nicht in der Luft endet. + $stmt = $mysql->prepare( + "SELECT datetime, soc, chg_kw, wb_kw, pv_kw, chg_state, wb_wh_total + FROM skoda + WHERE datetime BETWEEN (? - INTERVAL 15 MINUTE) AND (? + INTERVAL 15 MINUTE) + ORDER BY datetime"); + $stmt->bind_param("ss", $von, $bis); + $stmt->execute(); + $res = $stmt->get_result(); + $antwort["punkte"] = []; + while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; } + $antwort["von"] = $von; + $antwort["bis"] = $bis; + break; + +/* ------------------------------------------------------------ gesundheit -- */ +case "gesundheit": + /* + * Kilowattstunden je hundert Ladestandsprozent. Der Wert liegt über der + * nutzbaren Kapazität, weil die Ladeverluste mitgezählt werden - absolut + * also nur eine Näherung, sein Verlauf über die Jahre dagegen die + * eigentliche Aussage. + * + * Nur lange Ladungen an der eigenen Wallbox taugen dazu: bei zwanzig + * Prozentpunkten Zuwachs wiegt ein gerundeter Ladestandspunkt fünf + * Prozent, und eine Schnellladung unterwegs hat auf unserer Seite keinen + * Zähler. + */ + $sql = "SELECT beginn, soc_von, soc_bis, zuwachs, art, wh, + ROUND(wh/1000/zuwachs*100, 2) AS je100 + FROM (" . sitzungenSQL("NOW() - INTERVAL $tage DAY") . ") v + WHERE zuwachs >= 20 AND wh > 1000 + ORDER BY beginn"; + $res = $mysql->query($sql); + $antwort["ladungen"] = []; + while ($res && $z = $res->fetch_assoc()) { $antwort["ladungen"][] = $z; } + + // Ausgleichsgerade über die Einzelwerte, x in Jahren. Die Steigung sagt, + // ob der Wert wandert; ihr Fehler, ab welcher Alterung man überhaupt + // etwas sehen könnte. + $n = count($antwort["ladungen"]); + $antwort["trend"] = null; + if ($n >= 6) { + $t0 = strtotime($antwort["ladungen"][0]["beginn"]); + $xs = []; $ys = []; + foreach ($antwort["ladungen"] as $l) { + $xs[] = (strtotime($l["beginn"]) - $t0) / (365.25 * 86400); + $ys[] = floatval($l["je100"]); + } + $mx = array_sum($xs) / $n; + $my = array_sum($ys) / $n; + $sxx = 0; $sxy = 0; + for ($i = 0; $i < $n; $i++) { + $sxx += ($xs[$i] - $mx) ** 2; + $sxy += ($xs[$i] - $mx) * ($ys[$i] - $my); + } + if ($sxx > 0) { + $steigung = $sxy / $sxx; + $achse = $my - $steigung * $mx; + $rest = 0; + for ($i = 0; $i < $n; $i++) { + $rest += ($ys[$i] - ($achse + $steigung * $xs[$i])) ** 2; + } + $streu = $n > 2 ? sqrt($rest / ($n - 2)) : 0; + $fehler = $streu / sqrt($sxx); + $antwort["trend"] = [ + "steigung" => round($steigung, 3), + "fehler" => round($fehler, 3), + "achse" => round($achse, 2), + "prozent" => $achse != 0 ? round(100 * $steigung / $achse, 2) : null, + "nachweisbar" => $achse != 0 ? round(100 * 2 * $fehler / $achse, 1) : null, + "spanne_jahre" => round(max($xs), 2), + ]; + } + } + + // Median je Jahr - der Stand, um den die Wolke streut. + $jahre = []; + foreach ($antwort["ladungen"] as $l) { + $j = substr($l["beginn"], 0, 4); + $jahre[$j][] = floatval($l["je100"]); + } + $antwort["jahre"] = []; + foreach ($jahre as $j => $werte) { + sort($werte); + $k = count($werte); + $antwort["jahre"][] = [ + "jahr" => $j, + "n" => $k, + "q1" => round($werte[intdiv($k, 4)], 2), + "median" => round($werte[intdiv($k, 2)], 2), + "q3" => round($werte[intdiv(3 * $k, 4)], 2), + ]; + } + break; + +/* --------------------------------------------------------------- fahrten -- */ +case "fahrten": + /* + * Eine Fahrt liegt zwischen zwei Parkständen. Die API liefert nur die + * letzte bekannte PARKposition - fährt das Fahrzeug, kommt allein + * IN_MOTION ohne Koordinaten. Start und Ziel sind damit bekannt, die + * Strecke dazwischen nicht. + * + * CAST(... AS SIGNED) ist nötig: soc und odo_km sind vorzeichenlos, und + * eine Differenz unter null bricht die Abfrage sonst ab - genau das + * passiert, sobald zwischen zwei Ständen geladen wurde. + */ + $sql = "SELECT * FROM ( + SELECT datetime AS ankunft, address AS nach, lat, lon, odo_km, soc, + LAG(datetime) OVER w AS abfahrt, + LAG(address) OVER w AS von, + LAG(lat) OVER w AS von_lat, + LAG(lon) OVER w AS von_lon, + CAST(odo_km AS SIGNED) - LAG(CAST(odo_km AS SIGNED)) OVER w AS km, + LAG(CAST(soc AS SIGNED)) OVER w - CAST(soc AS SIGNED) AS soc_ab + FROM skoda + WHERE park_state = 'PARKED' AND odo_km IS NOT NULL + AND datetime >= NOW() - INTERVAL $tage DAY + WINDOW w AS (ORDER BY datetime)) f + WHERE km > 0 + ORDER BY ankunft DESC LIMIT 100"; + $res = $mysql->query($sql); + $antwort["fahrten"] = []; + while ($res && $z = $res->fetch_assoc()) { $antwort["fahrten"][] = $z; } + + // Aktueller Standort, für die Karte. + $res = $mysql->query("SELECT datetime, lat, lon, address, park_state + FROM skoda WHERE lat IS NOT NULL + ORDER BY id DESC LIMIT 1"); + $antwort["standort"] = $res ? $res->fetch_assoc() : null; + + $antwort["kapazitaet"] = kapazitaetSchaetzen($mysql); + break; + +default: + http_response_code(400); + $antwort = ["error" => "Unbekannter Abschnitt."]; +} + +echo json_encode($antwort, JSON_UNESCAPED_UNICODE); diff --git a/ajax/skodaCmd.php b/ajax/skodaCmd.php new file mode 100644 index 0000000..5208302 --- /dev/null +++ b/ajax/skodaCmd.php @@ -0,0 +1,269 @@ + (nur bei klima_start) + * + * Warum über den Server und nicht unmittelbar aus dem Browser? Der API- + * Schlüssel darf die Seite nicht verlassen. Er steht in skoda.conf neben + * gatherSkodaData.py, wird hier gelesen und geht nur von hier nach draußen. + * + * Nur POST. Ein Befehl, der sich über einen Link auslösen liesse, wäre über + * ein vorausgeladenes Bild oder einen Klick auf eine fremde Seite fernsteuerbar. + * + * --------------------------------------------------------------------------- + * Zwei Schlüssel, zwei Kontingente + * + * Die API begrenzt auf 20 Anfragen je Stunde **und Schlüssel**, und Befehle + * zählen genauso mit wie Abrufe. Steht in skoda.conf ein eigener + * CMD_API_KEY, hat die Steuerung damit ihr eigenes Kontingent und nimmt dem + * Abruf des Fahrzeugzustands nichts weg. Beide Schlüssel werden in der + * MyŠkoda-App angelegt. + * + * Ohne eigenen Schlüssel greift dieses Skript auf API_KEY zurück - dann + * teilen sich beide ein Kontingent, und weil sie in getrennten Prozessen + * laufen und keinen gemeinsamen Zähler haben, ist es fest aufgeteilt: + * + * eigener CMD_API_KEY nur API_KEY + * Abruf 18 / Stunde 14 / Stunde + * Befehle 15 / Stunde 4 / Stunde + * Reserve 2 / Stunde 2 / Stunde + * + * gatherSkodaData.py stellt sich selbst um, sobald der zweite Schlüssel in + * skoda.conf auftaucht - dort ist nichts von Hand nachzuziehen. + * + * Der zweite Schlüssel ist kein Freibrief, den Abruf zu beschleunigen: die + * Kachelrichtlinie zielt auf die Last, nicht auf die Buchführung. + * --------------------------------------------------------------------------- + * + * Nicht angebunden ist die Standheizung. Ihr Start verlangt laut Spec die + * Sicherheits-PIN des Fahrzeugs ("spin"). Die dauerhaft auf dem NAS zu + * hinterlegen, nur damit ein Knopf im Dashboard funktioniert, ist eine andere + * Größenordnung als ein API-Schlüssel - das sollte bewusst entschieden werden, + * nicht nebenbei. + */ + +require_once("../helper.php"); + +header('Content-Type: application/json; charset=utf-8'); + +const API_BASIS = "https://public.api.connect.skoda-auto.cz/api/v1/vehicles/"; +const KONFIG = "/volume1/homes/wagner/SolarManager/skoda.conf"; +const KENNUNG = "SmartController-SkodaSteuerung/1.0 (+https://nas.el-wa.org/smart)"; + +/** Befehle je Stunde - siehe Kopfkommentar. Hängt davon ab, ob die + * Steuerung einen eigenen Schlüssel hat oder ihn mit dem Abruf teilt. */ +const BEFEHLE_EIGENER_SCHLUESSEL = 15; +const BEFEHLE_GETEILT = 4; + +/** Wo der Stundenzähler liegt. Muss für den Webserver beschreibbar sein. */ +const ZAEHLER_ORTE = [ + "/volume1/web/smart/tiles", // falls per synoacltool freigegeben + "/var/services/tmp/smart-tiles", // sonst +]; + +/* + * Die erlaubten Befehle. Der Schlüssel kommt aus dem Formular, der Pfad + * niemals - so kann über den Parameter kein beliebiger Endpunkt der API + * angesprochen werden. + */ +const BEFEHLE = [ + "laden_start" => ["pfad" => "charging/start", "text" => "Laden gestartet"], + "laden_stop" => ["pfad" => "charging/stop", "text" => "Laden gestoppt"], + "klima_start" => ["pfad" => "air-conditioning/start", "text" => "Klimatisierung gestartet"], + "klima_stop" => ["pfad" => "air-conditioning/stop", "text" => "Klimatisierung gestoppt"], + "lueftung_start" => ["pfad" => "active-ventilation/start", "text" => "Lüftung gestartet"], + "lueftung_stop" => ["pfad" => "active-ventilation/stop", "text" => "Lüftung gestoppt"], +]; + +/* ------------------------------------------------------------------ Zugang */ + +if (!checkLogin()) { + antwort(403, false, "Nicht angemeldet."); +} +if ($_SERVER["REQUEST_METHOD"] !== "POST") { + antwort(405, false, "Nur per POST."); +} + +$name = isset($_POST["befehl"]) ? $_POST["befehl"] : ""; +if (!isset(BEFEHLE[$name])) { + antwort(400, false, "Unbekannter Befehl."); +} +$befehl = BEFEHLE[$name]; + +$konfig = konfigLesen(); +if (!$konfig) { + antwort(503, false, "Kein API-Schlüssel hinterlegt - skoda.conf fehlt."); +} + +$grenze = $konfig["EIGENER"] ? BEFEHLE_EIGENER_SCHLUESSEL : BEFEHLE_GETEILT; +if (!budgetFrei($grenze)) { + antwort(429, false, "Stündliches Kontingent für Befehle erschöpft (" + . $grenze . " je Stunde)." + . ($konfig["EIGENER"] ? "" : " Ein eigener CMD_API_KEY in skoda.conf" + . " würde der Steuerung ein eigenes Kontingent geben.") + . " Später erneut versuchen."); +} + +/* ---------------------------------------------------------------- Ausführen */ + +$rumpf = null; +if ($name === "klima_start") { + // Die Zieltemperatur kommt aus dem Formular und geht in einen Rumpf, den + // die API auswertet - deshalb hart begrenzen statt nur zu vertrauen. + $grad = isset($_POST["temperatur"]) ? floatval($_POST["temperatur"]) : 21.0; + $grad = max(16.0, min(29.5, round($grad * 2) / 2)); // halbe Grad + $rumpf = ["targetTemperature" => ["value" => $grad, "unit" => "CELSIUS"]]; +} + +list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf); + +if ($status === 202) { + antwort(200, true, $befehl["text"] . ". Das Fahrzeug führt den Befehl aus; " + . "in der Anzeige erscheint es beim nächsten Abruf."); +} +antwort(200, false, fehlertext($status, $inhalt)); + +/* ------------------------------------------------------------- Bausteine -- */ + +/** Antwort ausgeben und beenden. */ +function antwort($code, $ok, $text) +{ + http_response_code($code); + echo json_encode(["ok" => $ok, "text" => $text], JSON_UNESCAPED_UNICODE); + exit; +} + +/** API-Schlüssel und VIN aus skoda.conf lesen. Format: NAME=wert je Zeile. */ +function konfigLesen() +{ + if (!is_readable(KONFIG)) { + return null; + } + $werte = []; + foreach (file(KONFIG, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $zeile) { + $zeile = trim($zeile); + if ($zeile === "" || $zeile[0] === "#" || strpos($zeile, "=") === false) { + continue; + } + list($k, $v) = explode("=", $zeile, 2); + $werte[strtoupper(trim($k))] = trim(trim($v), "\"'"); + } + if (empty($werte["API_KEY"]) || empty($werte["VIN"])) { + return null; + } + // Eigener Schluessel fuer die Steuerung, wenn hinterlegt - sonst der + // gemeinsame. Welcher es wurde, entscheidet unten ueber das Budget. + $werte["EIGENER"] = !empty($werte["CMD_API_KEY"]); + $werte["SCHLUESSEL"] = $werte["EIGENER"] ? $werte["CMD_API_KEY"] : $werte["API_KEY"]; + return $werte; +} + +/** + * Stundenzähler für Befehle. Gleiches Verfahren wie die Bremse in tile.php: + * eine kleine Datei, gegen gleichzeitige Zugriffe mit flock abgesichert. + */ +function budgetFrei($grenze) +{ + $ort = null; + foreach (ZAEHLER_ORTE as $kandidat) { + if (is_dir($kandidat) && is_writable($kandidat)) { + $ort = $kandidat; + break; + } + } + if (!$ort) { + // Ohne Zähler lieber durchlassen als die Steuerung blockieren; die + // API bremst zur Not selbst mit 429. + return true; + } + + $griff = fopen($ort . "/.befehle.json", "c+"); + if (!$griff || !flock($griff, LOCK_EX)) { + return true; + } + $stand = json_decode(stream_get_contents($griff), true); + if (!is_array($stand)) { + $stand = ["fenster" => 0, "n" => 0]; + } + $jetzt = time(); + if ($jetzt - $stand["fenster"] >= 3600) { + $stand = ["fenster" => $jetzt, "n" => 0]; + } + $frei = $stand["n"] < $grenze; + if ($frei) { + $stand["n"]++; + } + ftruncate($griff, 0); + rewind($griff); + fwrite($griff, json_encode($stand)); + flock($griff, LOCK_UN); + fclose($griff); + return $frei; +} + +/** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */ +function senden($konfig, $pfad, $rumpf) +{ + $url = API_BASIS . rawurlencode($konfig["VIN"]) . "/" . $pfad; + $kopf = [ + "X-API-Key: " . $konfig["SCHLUESSEL"], + "Accept: application/json", + ]; + $ch = curl_init($url); + $optionen = [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_USERAGENT => KENNUNG, + CURLOPT_TIMEOUT => 20, + CURLOPT_CONNECTTIMEOUT => 5, + ]; + if ($rumpf !== null) { + $kopf[] = "Content-Type: application/json"; + $optionen[CURLOPT_POSTFIELDS] = json_encode($rumpf); + } else { + $optionen[CURLOPT_POSTFIELDS] = ""; + } + $optionen[CURLOPT_HTTPHEADER] = $kopf; + curl_setopt_array($ch, $optionen); + + $inhalt = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $fehler = curl_error($ch); + curl_close($ch); + + if ($inhalt === false) { + error_log("skodaCmd: $url nicht erreichbar: $fehler"); + return [0, null]; + } + return [$status, $inhalt]; +} + +/** + * Aus dem Statuscode eine Auskunft machen, mit der man etwas anfangen kann. + * Die API liefert dazu einen Problembericht nach RFC 9457; dessen "detail" + * ist oft die eigentliche Erklärung. + */ +function fehlertext($status, $inhalt) +{ + $detail = ""; + $bericht = json_decode((string)$inhalt, true); + if (is_array($bericht) && !empty($bericht["detail"])) { + $detail = " (" . $bericht["detail"] . ")"; + } + + switch ($status) { + case 0: return "Die Škoda-API ist nicht erreichbar."; + case 400: return "Die API hat den Befehl abgelehnt." . $detail; + case 401: return "Der API-Schlüssel ist abgelaufen - in der MyŠkoda-App erneuern."; + case 403: return "Der Schlüssel darf diesen Befehl nicht ausführen." . $detail; + case 404: return "Fahrzeug nicht gefunden - stimmt die VIN in skoda.conf?"; + case 422: return "Das Fahrzeug kann das gerade nicht." . $detail; + case 429: return "Kontingent der API erschöpft - später erneut versuchen."; + case 503: + case 504: return "Die API antwortet nicht rechtzeitig. Später erneut versuchen."; + default: return "Unerwartete Antwort der API (HTTP " . $status . ")." . $detail; + } +} diff --git a/ajax/tile.php b/ajax/tile.php new file mode 100644 index 0000000..7039c79 --- /dev/null +++ b/ajax/tile.php @@ -0,0 +1,290 @@ + ZOOM_MAX + || $x === false || $x === null || $x < 0 || $x >= $grenze + || $y === false || $y === null || $y < 0 || $y >= $grenze) { + http_response_code(400); + exit; +} + +$pfad = speicherOrt() . "/$z/$x/$y.png"; + +/* -------------------------------------------------------------- Ausliefern */ + +$vorhanden = is_file($pfad); +$veraltet = $vorhanden && (time() - filemtime($pfad) > AUFFRISCHEN_NACH); + +if (!$vorhanden || $veraltet) { + if (holeUndSpeichere($z, $x, $y, $pfad)) { + $vorhanden = true; + } +} + +if ($vorhanden) { + liefereDatei($pfad); +} else { + liefereErsatzkachel(); +} +exit; + +/* ------------------------------------------------------------- Bausteine -- */ + +/** + * Der erste beschreibbare Ort aus SPEICHER_ORTE, einmal je Aufruf ermittelt. + * Existiert keiner, wird der letzte angelegt - der liegt im Systemverzeichnis + * für flüchtige Dateien und ist immer beschreibbar. + */ +function speicherOrt() +{ + static $ort = null; + if ($ort !== null) { + return $ort; + } + foreach (SPEICHER_ORTE as $kandidat) { + if (is_dir($kandidat) && is_writable($kandidat)) { + return $ort = $kandidat; + } + } + $ort = SPEICHER_ORTE[count(SPEICHER_ORTE) - 1]; + if (!is_dir($ort)) { + @mkdir($ort, 0755, true); + } + return $ort; +} + +/** + * Eine Kachel bei der Quelle holen und ablegen. + * Gibt true zurück, wenn danach eine brauchbare Datei vorliegt. + */ +function holeUndSpeichere($z, $x, $y, $pfad) +{ + if (!bremseErlaubt()) { + return is_file($pfad); // notfalls die veraltete Kachel + } + + $url = str_replace(["{z}", "{x}", "{y}"], [$z, $x, $y], KACHEL_QUELLE); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_USERAGENT => KENNUNG, + CURLOPT_TIMEOUT => 8, + CURLOPT_CONNECTTIMEOUT => 4, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 2, + ]); + $inhalt = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + // Auf den PNG-Kopf prüfen, statt nur auf den Statuscode: eine Fehlerseite + // mit Status 200 wäre sonst als Kachel im Speicher gelandet und bliebe + // dort für immer. + if ($status !== 200 || $inhalt === false || strncmp($inhalt, "\x89PNG", 4) !== 0) { + return is_file($pfad); + } + + if (!is_dir(dirname($pfad)) && !mkdir(dirname($pfad), 0755, true)) { + return is_file($pfad); + } + + // Erst daneben schreiben, dann umbenennen. Sonst könnte ein zweiter + // Aufruf eine halb geschriebene Datei ausliefern. + $temp = $pfad . "." . getmypid() . ".teil"; + if (file_put_contents($temp, $inhalt) === false) { + return is_file($pfad); + } + if (!rename($temp, $pfad)) { + unlink($temp); + return is_file($pfad); + } + return true; +} + +/** + * Darf jetzt noch eine Kachel geholt werden? + * + * Gezählt wird in einer kleinen Datei, gegen gleichzeitige Zugriffe mit + * flock abgesichert. Zwei Fenster: eines je Minute, eines je Stunde. Läuft + * ein Fenster ab, fängt sein Zähler wieder bei null an. + */ +function bremseErlaubt() +{ + $datei = speicherOrt() . "/.bremse.json"; + + $griff = fopen($datei, "c+"); + if (!$griff) { + return true; + } + if (!flock($griff, LOCK_EX)) { + fclose($griff); + return true; + } + + $stand = json_decode(stream_get_contents($griff), true); + if (!is_array($stand)) { + $stand = ["minute" => 0, "min_n" => 0, "stunde" => 0, "std_n" => 0]; + } + + $jetzt = time(); + if ($jetzt - $stand["minute"] >= 60) { $stand["minute"] = $jetzt; $stand["min_n"] = 0; } + if ($jetzt - $stand["stunde"] >= 3600) { $stand["stunde"] = $jetzt; $stand["std_n"] = 0; } + + $erlaubt = $stand["min_n"] < HOLEN_JE_MINUTE && $stand["std_n"] < HOLEN_JE_STUNDE; + if ($erlaubt) { + $stand["min_n"]++; + $stand["std_n"]++; + } + + ftruncate($griff, 0); + rewind($griff); + fwrite($griff, json_encode($stand)); + flock($griff, LOCK_UN); + fclose($griff); + + if (!$erlaubt) { + error_log("tile.php: Bremse greift, keine weiteren Kacheln in diesem Fenster."); + } + return $erlaubt; +} + +/** Eine gespeicherte Kachel ausliefern, mit Unterstützung für 304. */ +function liefereDatei($pfad) +{ + $zeit = filemtime($pfad); + $marke = '"' . md5($pfad . $zeit) . '"'; + + header("Content-Type: image/png"); + header("Cache-Control: public, max-age=" . BROWSER_FRIST); + header("ETag: $marke"); + header("Last-Modified: " . gmdate("D, d M Y H:i:s", $zeit) . " GMT"); + + $bekannt = isset($_SERVER["HTTP_IF_NONE_MATCH"]) ? trim($_SERVER["HTTP_IF_NONE_MATCH"]) : ""; + if ($bekannt === $marke) { + http_response_code(304); + return; + } + + header("Content-Length: " . filesize($pfad)); + readfile($pfad); +} + +/** + * Graue Ersatzkachel, wenn nichts zu holen war - kein Internet, Quelle + * gestört oder Bremse aktiv. Ein leeres Feld ist besser als ein kaputtes + * Bildsymbol, und es wird bewusst nicht zwischengespeichert. + */ +function liefereErsatzkachel() +{ + header("Content-Type: image/png"); + header("Cache-Control: no-store"); + // 1x1 Punkt in Grau, von Leaflet auf Kachelgröße gezogen. + echo base64_decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk" + . "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="); +} diff --git a/css/images/layers-2x.png b/css/images/layers-2x.png new file mode 100644 index 0000000..200c333 Binary files /dev/null and b/css/images/layers-2x.png differ diff --git a/css/images/layers.png b/css/images/layers.png new file mode 100644 index 0000000..1a72e57 Binary files /dev/null and b/css/images/layers.png differ diff --git a/css/images/marker-icon-2x.png b/css/images/marker-icon-2x.png new file mode 100644 index 0000000..88f9e50 Binary files /dev/null and b/css/images/marker-icon-2x.png differ diff --git a/css/images/marker-icon.png b/css/images/marker-icon.png new file mode 100644 index 0000000..950edf2 Binary files /dev/null and b/css/images/marker-icon.png differ diff --git a/css/images/marker-shadow.png b/css/images/marker-shadow.png new file mode 100644 index 0000000..9fd2979 Binary files /dev/null and b/css/images/marker-shadow.png differ diff --git a/css/leaflet.css b/css/leaflet.css new file mode 100644 index 0000000..9ade8dc --- /dev/null +++ b/css/leaflet.css @@ -0,0 +1,661 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } diff --git a/css/solar.css b/css/solar.css index c13f101..20851b9 100644 --- a/css/solar.css +++ b/css/solar.css @@ -375,3 +375,534 @@ input[type='range']::-webkit-slider-runnable-track { fill: currentColor; opacity: 0.85; } + +/* --- Einstellungen: Preistabellen ------------------------------------ */ + +/* Der Spaltenkopf steht ueber der ersten Zeile und muss deren Einrueckung + treffen, sonst rutschen Beschriftung und Feld auseinander. Er bleibt + einzeilig, braucht also keinen senkrechten Gutter - und ohne den entfaellt + auch Bootstraps negative Marge von selbst. */ +.preis-kopf { + --bs-gutter-y: 0; + padding: 0 0.25rem 0.35rem; +} + +/* Eine Zeile ist selbst eine .row mit g-2. Bootstrap zieht so eine Reihe um + die Gutter-Hoehe nach oben und gibt jeder Spalte dieselbe Marge nach unten + zurueck - gedacht fuer Reihen, die nebeneinander im Raster stehen. Hier + stehen sie untereinander, und der Zug nach oben frisst den unteren + Innenabstand der vorigen Zeile auf: auf dem Handy, wo der Papierkorb in + einer eigenen Zeile landet, klebte er am Trennstrich. + + Die senkrechte Gutter-Marge wird hier gebraucht - sie haelt die umbrochenen + Feldzeilen auseinander -, der Zug nach oben nicht. Die Regel braucht eine + Klasse mehr als Bootstraps .row: solar.css wird vor AdminLTE geladen und + verliert bei gleicher Spezifitaet. */ +.preis-zeilen .preis-zeile { + margin-top: 0; +} + +/* Oben um die Gutter-Hoehe weniger Innenabstand, damit ueber der ersten und + unter der letzten Feldzeile gleich viel Luft steht. */ +.preis-zeile { + padding: 0.15rem 0.25rem 0.65rem; + border-top: 1px solid rgba(128, 128, 128, 0.25); +} +/* Die Einheit ist Beiwerk und darf der Zahl nicht den Platz nehmen: hinter + "kWh/100 km" bliebe in voller Schrift kaum ein Drittel der Spalte fuer das + Eingabefeld uebrig. */ +.preis-zeile .input-group-text { + font-size: 0.75rem; + padding-left: 0.4rem; + padding-right: 0.4rem; +} + +/* Der Zeitraum, der heute gilt - der einzige, den man beim Nachschlagen + wirklich sucht. */ +.preis-heute { + background: rgba(0, 120, 143, 0.12); + border-radius: 0.4rem; +} + + +/* ========================================================================== + Skoda-Seite (restricted/skoda.php) + + Farben kommen aus den Bootstrap-Variablen des Themes, damit die Seite dem + Hell-/Dunkelmodus folgt, ohne eine zweite Palette zu pflegen. Nur drei + Bedeutungsfarben stehen fuer sich: bernstein fuer "offen", gelb fuer + eingeschaltetes Licht, gruen fuer Laden. + ========================================================================== */ + +.skoda-auto, +.skoda-teile, +.skoda-karte { + --skoda-offen: #e0904f; + --skoda-licht: #f2d16b; + --skoda-laden: #4caf7d; + --skoda-blech: 128, 138, 148; +} + +/* --- Fahrzeug in Draufsicht ---------------------------------------------- + Massstaeblich: 4648 x 1877 mm ergeben das Seitenverhaeltnis 2,48:1, der + Radstand von 2765 mm die Lage der Raeder. */ +.skoda-auto { + width: 100%; + max-width: 215px; + height: auto; +} + +.skoda-auto .karosserie { + fill: rgba(var(--skoda-blech), 0.17); + stroke: rgba(var(--skoda-blech), 0.55); + stroke-width: 2; +} + +.skoda-auto .rad rect { fill: rgba(18, 20, 24, 0.9); } +.skoda-auto .spiegel rect { fill: rgba(var(--skoda-blech), 0.55); } +.skoda-auto .reling rect { fill: rgba(var(--skoda-blech), 0.62); } +.skoda-auto .spoiler { fill: rgba(var(--skoda-blech), 0.5); } +.skoda-auto .sicke { + fill: none; + stroke: rgba(var(--skoda-blech), 0.35); + stroke-width: 1.5; +} + +/* Das Tech-Deck Face: beim Enyaq eine glaenzend schwarze Blende ueber die + volle Bugbreite. Von oben sieht man ihre Oberkante. */ +.skoda-auto .techdeck { + fill: rgba(12, 14, 18, 0.92); + stroke: rgba(var(--skoda-blech), 0.4); + stroke-width: 1; +} + +.skoda-auto .scheibe, +.skoda-auto .dach { + fill: rgba(var(--skoda-blech), 0.28); + stroke: rgba(var(--skoda-blech), 0.45); + stroke-width: 1.5; +} + +/* Grundzustand aller schaltbaren Teile: geschlossen und unauffaellig. */ +.skoda-auto .teil > *, +.skoda-auto .teil > g > * { + fill: rgba(var(--skoda-blech), 0.34); + stroke: rgba(var(--skoda-blech), 0.6); + stroke-width: 1.5; + transition: fill .25s, stroke .25s, filter .25s; +} + +.skoda-auto .teil-fenster > rect { fill: rgba(var(--skoda-blech), 0.5); } + +/* Offen: bernstein und leicht leuchtend. Die Farbe allein traegt die Aussage + nicht - die Zeile unter dem Bild benennt jedes offene Teil zusaetzlich. */ +.skoda-auto .teil.offen > *, +.skoda-auto .teil.offen > g > * { + fill: var(--skoda-offen); + stroke: var(--skoda-offen); + filter: drop-shadow(0 0 6px rgba(224, 144, 79, 0.65)); +} + +/* Licht: vorn die vier LED-Elemente je Seite, hinten das Leuchtenband. */ +.skoda-auto .teil-licht.an > rect { + fill: var(--skoda-licht); + stroke: var(--skoda-licht); + filter: drop-shadow(0 0 8px rgba(242, 209, 107, 0.75)); +} +.skoda-auto .teil-licht.an > rect.rueck { + fill: #e06060; + stroke: #e06060; + filter: drop-shadow(0 0 8px rgba(224, 96, 96, 0.7)); +} + +.skoda-auto .teil-stecker > path { fill: none; stroke-width: 2; } +.skoda-auto .teil-stecker.laedt > circle { + fill: var(--skoda-laden); + stroke: var(--skoda-laden); + animation: skodaPuls 1.8s ease-in-out infinite; +} +.skoda-auto .teil-stecker.laedt > path { stroke: #0d1210; } + +@keyframes skodaPuls { + 0%, 100% { filter: drop-shadow(0 0 3px rgba(76, 175, 125, 0.5)); } + 50% { filter: drop-shadow(0 0 12px rgba(76, 175, 125, 0.95)); } +} + +/* Hervorheben, wenn der zugehoerige Eintrag darunter beruehrt wird - und + umgekehrt. Sichtbarer Fokus, damit das auch mit der Tastatur geht. */ +.skoda-auto .teil.hervor > *, +.skoda-auto .teil.hervor > g > * { + stroke: var(--bs-primary, #0d6efd); + stroke-width: 2.5; +} +@media (hover: hover) { + .skoda-auto .teil:hover > *, + .skoda-auto .teil > g:hover > * { + stroke: var(--bs-primary, #0d6efd); + stroke-width: 2.5; + } +} +.skoda-auto .teil:focus, +.skoda-auto .teil > g:focus { outline: none; } +.skoda-auto .teil:focus-visible > *, +.skoda-auto .teil > g:focus-visible > * { + stroke: var(--bs-primary, #0d6efd); + stroke-width: 3; +} + +@media (prefers-reduced-motion: reduce) { + .skoda-auto .teil > *, + .skoda-auto .teil > g > * { transition: none; } + .skoda-auto .teil-stecker.laedt > circle { animation: none; } +} + +/* --- Zustandszeile unter dem Fahrzeug ------------------------------------ */ +.skoda-teile { + display: flex; + flex-wrap: wrap; + gap: .35rem; + justify-content: center; + margin-top: .9rem; +} +.skoda-teile .chip { + display: inline-flex; + align-items: center; + gap: .35rem; + font-size: .78rem; + padding: .2rem .55rem; + border-radius: 1rem; + border: 1px solid var(--bs-border-color, rgba(128, 128, 128, .35)); + color: var(--bs-secondary-color, #9aa0a6); + cursor: default; + white-space: nowrap; +} +.skoda-teile .chip.offen { border-color: var(--skoda-offen); color: var(--skoda-offen); } +.skoda-teile .chip.hervor { + border-color: var(--bs-primary, #0d6efd); + color: var(--bs-body-color, #e8ede9); +} + +/* Auf Beruehrungsgeraeten sind die Ziele zu klein: eine Fingerkuppe deckt + rund 9 mm ab, die Chips waren keine 6 mm hoch. Gleiches gilt fuer die + Fahrtzeilen und den Umschalter. */ +@media (pointer: coarse) { + .skoda-teile { gap: .5rem; } + .skoda-teile .chip { + font-size: .85rem; + padding: .45rem .8rem; + cursor: pointer; + } + .skoda-liste .zeile { padding: .75rem .9rem; } + .skoda-umschalter button { font-size: .85rem; padding: .5rem .9rem; } +} +.skoda-teile .chip.an { border-color: var(--skoda-licht); color: var(--skoda-licht); } + +/* --- Ladestandsring ------------------------------------------------------ */ +.skoda-ring { width: 100%; max-width: 190px; height: auto; } +.skoda-ring .ring-bahn { + fill: none; + stroke: rgba(128, 138, 148, 0.22); + stroke-width: 16; +} +.skoda-ring .ring-wert { + fill: none; + stroke: #4caf7d; + stroke-width: 16; + stroke-linecap: round; + transform: rotate(-90deg); + transform-origin: 100px 100px; + transition: stroke-dashoffset .6s ease, stroke .3s; +} +.skoda-ring .ring-ziel { + stroke: var(--bs-body-color, #e8ede9); + stroke-width: 3; + opacity: .75; + transform-origin: 100px 100px; +} +.skoda-ring .ring-zahl { + fill: var(--bs-body-color, #e8ede9); + font-size: 46px; + font-weight: 600; + text-anchor: middle; + font-variant-numeric: tabular-nums; +} +.skoda-ring .ring-einheit { + fill: var(--bs-secondary-color, #9aa0a6); + font-size: 14px; + letter-spacing: .12em; + text-anchor: middle; + text-transform: uppercase; +} + +/* --- Wertetabelle -------------------------------------------------------- */ +.skoda-werte { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); + gap: .75rem 1.25rem; +} +.skoda-wert { display: flex; flex-direction: column; } +.skoda-wert .k { + font-size: .7rem; + letter-spacing: .09em; + text-transform: uppercase; + color: var(--bs-secondary-color, #9aa0a6); +} +.skoda-wert .v { + font-size: 1.15rem; + font-variant-numeric: tabular-nums; +} + +/* --- Herkunft des Ladestroms --------------------------------------------- */ +.skoda-balken { + display: flex; + height: .7rem; + border-radius: .35rem; + overflow: hidden; + background: rgba(128, 138, 148, 0.2); +} +.skoda-balken > div { transition: width .5s ease; } +.skoda-balken .teil-pv { background: #f0b429; } +.skoda-balken .teil-netz { background: #7a8791; } + +/* --- Listen (Ladungen, Fahrten) ------------------------------------------ */ +/* Der Innenabstand oben trennt die erste Zeile von der Ueberschrift. Ohne + ihn klebt sie am Umschalter, was auf dem Handy besonders auffaellt. */ +.skoda-liste { + max-height: 400px; + overflow-y: auto; + padding-top: .5rem; +} +.skoda-liste .zeile { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: .75rem; + padding: .5rem .9rem; + border-top: 1px solid var(--bs-border-color, rgba(128, 128, 128, .25)); +} +.skoda-liste .zeile:first-child { border-top: none; } +@media (hover: hover) { + .skoda-liste .zeile:hover { background: rgba(128, 138, 148, 0.08); } +} +.skoda-liste .wann { font-size: .82rem; color: var(--bs-secondary-color, #9aa0a6); } +.skoda-liste .was { font-size: .95rem; font-variant-numeric: tabular-nums; text-align: right; } +.skoda-liste .neben { display: block; font-size: .75rem; color: var(--bs-secondary-color, #9aa0a6); } +.skoda-liste .leer { padding: 1.5rem .9rem; color: var(--bs-secondary-color, #9aa0a6); } + +/* Umschalter kWh <-> Prozent in der Ueberschrift der Fahrtenliste. Welche + Einheit sichtbar ist, entscheidet die Klasse auf der Kachel - so bleibt die + Auswahl bestehen, wenn die Liste neu gebaut wird. */ +.zeigt-kwh .v-prozent, +.zeigt-prozent .v-kwh, +.zeigt-prozent #skodaVerbrauchBasis { display: none; } + +.skoda-umschalter { + display: inline-flex; + border: 1px solid var(--bs-border-color, rgba(128, 128, 128, .35)); + border-radius: .3rem; + overflow: hidden; +} +.skoda-umschalter button { + background: none; + border: none; + color: var(--bs-secondary-color, #9aa0a6); + font-size: .72rem; + line-height: 1; + padding: .25rem .5rem; + cursor: pointer; +} +.skoda-umschalter button + button { + border-left: 1px solid var(--bs-border-color, rgba(128, 128, 128, .35)); +} +.skoda-umschalter button:hover { background: rgba(128, 138, 148, .12); } +.skoda-umschalter button.aktiv { + background: rgba(128, 138, 148, .22); + color: var(--bs-body-color, #e8ede9); +} +.skoda-umschalter button:focus-visible { + outline: 2px solid var(--bs-primary, #0d6efd); + outline-offset: -2px; +} + +/* Fahrtzeilen, die sich auf der Karte zeigen lassen. Der Zeiger und die + Markierung sind der einzige Hinweis darauf - ohne den waere die Kopplung + nicht zu erraten. */ +.skoda-liste .zeile.anklickbar { cursor: pointer; } +.skoda-liste .zeile.anklickbar:focus-visible { + outline: 2px solid var(--bs-primary, #0d6efd); + outline-offset: -2px; +} +/* Dieselbe Farbe wie die gewaehlte Linie auf der Karte - die Zuordnung + zwischen Zeile und Linie soll ohne Nachdenken erkennbar sein. */ +.skoda-liste .zeile.gewaehlt { + background: rgba(224, 144, 79, 0.14); + box-shadow: inset 3px 0 0 #e0904f; +} + +/* --- Karte (Leaflet) ----------------------------------------------------- + Die Kacheln kommen ueber ajax/tile.php aus dem eigenen Zwischenspeicher. + OpenStreetMap-Kacheln sind hell; im dunklen Dashboard wuerden sie wie ein + Scheinwerfer wirken. invert + hue-rotate dreht sie ins Dunkle, ohne einen + zweiten Anbieter samt eigener Bedingungen ins Spiel zu bringen. Der Filter + sitzt auf den Kacheln allein - Marker, Linien und Bedienelemente behalten + ihre Farben. */ +.skoda-karte { + height: 420px; + width: 100%; + border-radius: .4rem; + background: rgba(128, 138, 148, 0.12); + z-index: 0; /* sonst legt sich die Karte ueber die Seitenleiste */ +} + +.skoda-karte .leaflet-tile-pane { + filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(0.9); +} + +/* Bedienelemente und Sprechblasen an das dunkle Theme angleichen. */ +.skoda-karte .leaflet-bar a, +.skoda-karte .leaflet-bar a:hover { + background: #23282c; + color: var(--bs-body-color, #e8ede9); + border-bottom-color: #33393e; +} +.skoda-karte .leaflet-bar a:hover { background: #2d3338; } + +.skoda-karte .leaflet-popup-content-wrapper, +.skoda-karte .leaflet-popup-tip { + background: #23282c; + color: var(--bs-body-color, #e8ede9); + box-shadow: 0 3px 14px rgba(0, 0, 0, .5); +} +.skoda-karte .leaflet-popup-content { margin: .6rem .8rem; line-height: 1.5; } +.skoda-karte .leaflet-popup-close-button { color: var(--bs-secondary-color, #9aa0a6); } + +/* Die Nennung von OpenStreetMap ist Pflicht und darf nicht versteckt werden - + nur ruhiger gesetzt, damit sie das Bild nicht dominiert. + + Ohne Hintergrundkasten, dafuer mit Schattenwurf: ein durchsichtiger + Hintergrund allein liesse den Text ueber hellen Kartenstellen verschwinden, + und unlesbar waere so gut wie versteckt. Der Schatten haelt ihn auf jedem + Untergrund erkennbar, ohne einen Balken ins Bild zu setzen. */ +/* Der doppelte Klassenname ist noetig, nicht Zierde: Leaflet setzt den + Hintergrund ueber .leaflet-container .leaflet-control-attribution, also mit + derselben Spezifitaet wie ein einfaches .skoda-karte davor - und leaflet.css + wird spaeter geladen, gewinnt bei Gleichstand also. Der Kartencontainer + traegt beide Klassen, damit sticht diese Regel. */ +.skoda-karte.leaflet-container .leaflet-control-attribution { + background: transparent; + color: #c9d0d6; + font-size: .68rem; + padding: 0 .4rem; + text-shadow: 0 1px 2px rgba(0, 0, 0, .9), 0 0 4px rgba(0, 0, 0, .75); +} +.skoda-karte.leaflet-container .leaflet-control-attribution a { + color: #a9c8f0; + text-shadow: inherit; +} + +.skoda-kartenlegende { + display: inline-flex; + gap: .9rem; + font-size: .75rem; + color: var(--bs-secondary-color, #9aa0a6); +} +.skoda-kartenlegende span { display: inline-flex; align-items: center; gap: .3rem; } +.skoda-kartenlegende .pkt { width: .55rem; height: .55rem; border-radius: 50%; } +.skoda-kartenlegende .pkt.jetzt { background: #4caf7d; } +.skoda-kartenlegende .pkt.halt { background: #8a949c; } +.skoda-kartenlegende .lin { + width: 1.1rem; + border-top: 2px dashed #8a949c; +} + +/* --- Befund -------------------------------------------------------------- */ +.skoda-befund .zahl { + font-size: 2.4rem; + font-weight: 600; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} +.skoda-befund .zahl .einheit { + font-size: .95rem; + font-weight: 400; + color: var(--bs-secondary-color, #9aa0a6); + margin-left: .35rem; +} +.skoda-befund dl { margin: 1rem 0 0; display: grid; gap: .5rem; } +.skoda-befund dl > div { display: flex; justify-content: space-between; gap: 1rem; } +.skoda-befund dt { + font-weight: 400; + color: var(--bs-secondary-color, #9aa0a6); + font-size: .85rem; +} +.skoda-befund dd { margin: 0; font-variant-numeric: tabular-nums; font-size: .9rem; } + +/* --- Steuerung ---------------------------------------------------------- + Die Befehle sitzen im Modal, das ueber die anklickbaren Stellen im + Fahrzeugbild aufgeht. */ +.skoda-steuerung .knoepfe { display: flex; flex-wrap: wrap; gap: .5rem; } +.skoda-steuerung .knoepfe button { flex: 1 1 11rem; } +.skoda-steuerung .temperatur { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; + font-size: .9rem; +} +.skoda-steuerung .temperatur span { display: flex; align-items: center; gap: .35rem; } +.skoda-steuerung .temperatur input { width: 5.5rem; } + +@media (pointer: coarse) { + .skoda-steuerung .knoepfe button { padding: .7rem 1rem; } +} + +.skoda-rueckmeldung { + font-size: .9rem; + padding: .5rem .75rem; + border-radius: .35rem; + display: none; +} +.skoda-rueckmeldung.warte, +.skoda-rueckmeldung.gut, +.skoda-rueckmeldung.schlecht { display: block; } +.skoda-rueckmeldung.warte { background: rgba(128, 138, 148, .16); color: var(--bs-secondary-color, #9aa0a6); } +.skoda-rueckmeldung.gut { background: rgba(76, 175, 125, .16); color: #6fd3a0; } +.skoda-rueckmeldung.schlecht { background: rgba(224, 96, 96, .16); color: #f08a8a; } + +/* --- Anklickbare Stellen im Fahrzeugbild -------------------------------- + Der Ring unterscheidet Bedienung von Anzeige: die uebrigen Teile zeigen + nur ihren Zustand, diese beiden aendern etwas. */ +.skoda-auto .steuer { cursor: pointer; } +.skoda-auto .steuer .ring { + fill: none; + stroke: var(--bs-primary, #4b8fd6); + stroke-width: 1.5; + stroke-dasharray: 3 3; + opacity: .55; + transition: opacity .2s, stroke-width .2s; +} +.skoda-auto .steuer .flaeche { + fill: rgba(75, 143, 214, .22); + stroke: var(--bs-primary, #4b8fd6); + stroke-width: 1.5; +} +.skoda-auto .steuer .glyphe { + fill: none; + stroke: var(--bs-primary, #4b8fd6); + stroke-width: 1.8; + stroke-linecap: round; +} +@media (hover: hover) { + .skoda-auto .steuer:hover .ring { opacity: 1; stroke-width: 2.5; } +} +.skoda-auto .steuer:focus { outline: none; } +.skoda-auto .steuer:focus-visible .ring { opacity: 1; stroke-width: 3; } + +.skoda-hinweis { + margin: .6rem 0 0; + font-size: .75rem; + color: var(--bs-secondary-color, #9aa0a6); + text-align: center; +} + +.skoda-alter { font-size: .85rem; } diff --git a/index.php b/index.php index f8c371f..e6763e0 100644 --- a/index.php +++ b/index.php @@ -1,132 +1,136 @@ - - - - Smarthome control - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ENDE; - } -} else if (checkLogin()) { - /* - * Seitentabelle: Vorlage, Seitenskripte und welche Bibliotheken die Seite - * ueberhaupt braucht. Frueher wurden Chart.js, MQTT & Co. auf jeder Seite - * geladen - die Home-Seite etwa zeichnet gar keine Diagramme. - */ - $pages = [ - "solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, - "scripts" => ["js/solar/solarMQTT.js"]], - "home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, - "scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]], - "heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, - "scripts" => ["js/solar/heatMQTT.js"]], - "history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, - "scripts" => ["js/solar/historyMQTT.js"]], - "weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true, - "scripts" => ["js/solar/weatherMQTT.js"]], - ]; - - // Unbekannte oder fehlende Aktion faellt auf die Solar-Seite zurueck. - if (!isset($_GET["action"]) || !isset($pages[$_GET["action"]])) { - $_GET["action"] = "solar"; - } - $page = $pages[$_GET["action"]]; - - include "restricted/header.php"; - include "restricted/" . $page["template"]; - include "restricted/footer.php"; -} else { - echo << - - - - - -ENDE; -} + + + + Smarthome control + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ENDE; + } +} else if (checkLogin()) { + /* + * Seitentabelle: Vorlage, Seitenskripte und welche Bibliotheken die Seite + * ueberhaupt braucht. Frueher wurden Chart.js, MQTT & Co. auf jeder Seite + * geladen - die Home-Seite etwa zeichnet gar keine Diagramme. + */ + $pages = [ + "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, "leaflet" => false, + "scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]], + "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, "leaflet" => false, + "scripts" => ["js/solar/historyMQTT.js"]], + "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. + if (!isset($_GET["action"]) || !isset($pages[$_GET["action"]])) { + $_GET["action"] = "solar"; + } + $page = $pages[$_GET["action"]]; + + include "restricted/header.php"; + include "restricted/" . $page["template"]; + include "restricted/footer.php"; +} else { + echo << + + + + + +ENDE; +} diff --git a/js/leaflet.js b/js/leaflet.js new file mode 100644 index 0000000..a3bf693 --- /dev/null +++ b/js/leaflet.js @@ -0,0 +1,6 @@ +/* @preserve + * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1 "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 = "
"; + + html += "
" + + "" + + "" + + "
"; + + // 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 += "
" + + "" + + "
" + + "" + (bis === null ? "offen" : datumLesbar(bis)) + "" + + (gilt ? "gilt heute" : "") + + "
"; + + 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 += "
" + + "" + + "
" + + "" + + "" + feld.einheit + "" + + "
"; + }); + + html += "
"; + html += "" + + "
"; + 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 += "
" + + "" + + warnung.join(" ") + "
"; + } + + 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); + }); +}); diff --git a/js/solar/skodaMQTT.js b/js/solar/skodaMQTT.js new file mode 100644 index 0000000..bf88daa --- /dev/null +++ b/js/solar/skodaMQTT.js @@ -0,0 +1,1174 @@ +/* + * Škoda-Seite. + * + * Zwei Quellen, bewusst getrennt: + * + * MQTT Ladestand, Verriegelung, Reichweite, Ladezustand, Restladezeit und + * die Wallbox-Leistung kommen laufend über solarManager/# herein und + * aktualisieren die Anzeige sofort. + * + * Ajax Alles, was nur in der Tabelle skoda steht - Türen, Fenster, Licht, + * Klappen, Kilometerstand, Position, Klima, Ladeziel - sowie sämtliche + * Auswertungen. Beim Laden einmal, danach minütlich. + * + * Das Fahrzeug meldet Türen und Licht nur, wenn es sich meldet; zwischen zwei + * Abrufen liegen im Ruhezustand bis zu einer Stunde. Die Anzeige nennt deshalb + * immer auch das Alter des Standes, statt einen alten Wert als aktuell + * auszugeben. + */ + +var mqttData = {}; + +const solarMQTT = { + getMQTT: function () { + const connection = "wss://mqtt.nas.el-wa.org:443"; + const client = mqtt.connect(connection, { rejectUnauthorized: false }); + + client.on("message", function (topic, message) { + mqttData[topic] = message; + if (topic.startsWith("solarManager/ev") || topic === "solarManager/P_PV" + || topic === "solarManager/P_Grid") { + planeLiveAnzeige(); + } + }); + client.on("connect", function () { client.subscribe("solarManager/#"); }); + client.on("error", function () { /* stille Wiederverbindung durch mqtt.js */ }); + } +}; + +/* ------------------------------------------------------------------ Hilfen */ + +const $ = (id) => document.getElementById(id); + +/** MQTT-Nutzlast als Zahl. Fehlt das Thema noch, kommt null. */ +function mqZahl(name) { + const v = mqttData["solarManager/" + name]; + if (v === undefined || v === null) return null; + const z = Number(String(v)); + return Number.isFinite(z) ? z : null; +} +/** MQTT-Nutzlast als Text. */ +function mqText(name) { + const v = mqttData["solarManager/" + name]; + return (v === undefined || v === null) ? null : String(v); +} + +const nf = (n, k = 0) => (n === null || n === undefined || !Number.isFinite(Number(n))) + ? "–" : Number(n).toLocaleString("de-DE", { minimumFractionDigits: k, maximumFractionDigits: k }); + +/** Sekunden als "vor 3 min" / "vor 2 h 15 min" / "vor 4 Tagen". */ +function seit(sekunden) { + if (sekunden === null || sekunden === undefined) return ""; + const s = Math.max(0, Math.round(sekunden)); + if (s < 90) return "vor " + s + " s"; + const m = Math.round(s / 60); + if (m < 90) return "vor " + m + " min"; + const h = Math.floor(s / 3600); + if (h < 36) return "vor " + h + " h " + Math.round((s - h * 3600) / 60) + " min"; + return "vor " + Math.round(s / 86400) + " Tagen"; +} + +function zeitpunkt(iso, mitJahr = false) { + if (!iso) return "–"; + const d = new Date(String(iso).replace(" ", "T")); + if (isNaN(d)) return "–"; + return d.toLocaleString("de-DE", { + day: "2-digit", month: "2-digit", year: mitJahr ? "numeric" : undefined, + hour: "2-digit", minute: "2-digit" + }); +} + +/** Minuten als "1 h 35 min". */ +function dauer(minuten) { + const m = Math.round(Number(minuten)); + if (!Number.isFinite(m) || m <= 0) return "–"; + if (m < 60) return m + " min"; + return Math.floor(m / 60) + " h " + (m % 60) + " min"; +} + +async function hole(was, extra = "") { + const antwort = await fetch("ajax/skoda.php?was=" + was + extra, { cache: "no-store" }); + if (!antwort.ok) throw new Error("HTTP " + antwort.status); + return antwort.json(); +} + +function leer(text) { + return '
' + text + "
"; +} + +/* ------------------------------------------------------- Zustand des Autos */ + +/* + * Die Zustandswörter der API in Anzeige übersetzen. UNKNOWN ist ausdrücklich + * kein Nein: das Fahrzeug sagt dann, dass es den Zustand selbst nicht kennt, + * und die Anzeige darf daraus kein beruhigendes "geschlossen" machen. + */ +const OFFEN_WERTE = ["OPEN", "OPENED", "TRUNK_OPENED", "NO", "UNLOCKED"]; + +const ZUSTANDSWORT = { + OPEN: "offen", OPENED: "offen", TRUNK_OPENED: "Kofferraum offen", + CLOSED: "zu", LOCKED: "verriegelt", UNLOCKED: "entriegelt", + ON: "an", OFF: "aus", YES: "ja", NO: "nein", + UNKNOWN: "unbekannt", UNSUPPORTED: "nicht vorhanden" +}; + +const TEILE = [ + { id: "teil-doors", feld: "doors", name: "Türen", zu: "CLOSED" }, + { id: "teil-windows", feld: "windows", name: "Fenster", zu: "CLOSED" }, + { id: "teil-sunroof", feld: "sunroof", name: "Panoramadach", zu: "CLOSED" }, + { id: "teil-trunk", feld: "trunk", name: "Heckklappe", zu: "CLOSED" }, + { id: "teil-bonnet", feld: "bonnet", name: "Motorhaube", zu: "CLOSED" }, + { id: "teil-lights", feld: "lights", name: "Licht", zu: "OFF" } +]; + +/** + * Die Teile in der Fahrzeuggrafik mit ihren Einträgen koppeln - dieselbe + * Wirkung wie umgekehrt, nur von der Grafik aus angestossen. Die Gruppen + * stehen fest im Markup, also genügt einmaliges Verdrahten beim Seitenaufbau. + */ +function koppleFahrzeugTeile() { + document.querySelectorAll("#skodaAuto .teil[id]").forEach(function (knoten) { + const id = knoten.id; + knoten.addEventListener("pointerenter", function (e) { + if (e.pointerType === "mouse") hervorheben(id, true); + }); + knoten.addEventListener("pointerleave", function (e) { + if (e.pointerType === "mouse") hervorheben(id, false); + }); + knoten.addEventListener("click", () => hervorheben(id, "um")); + knoten.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); hervorheben(id, "um"); } + }); + }); +} + +/** + * Ein Fahrzeugteil und seinen Eintrag gemeinsam hervorheben. + * + * @param an true / false, oder "um" zum Umschalten (Tippen) + */ +function hervorheben(id, an) { + const knoten = $(id); + const chip = document.querySelector('.skoda-teile .chip[data-teil="' + id + '"]'); + const soll = (an === "um") ? !(knoten && knoten.classList.contains("hervor")) : an; + if (knoten) knoten.classList.toggle("hervor", soll); + if (chip) chip.classList.toggle("hervor", soll); +} + +function zeichneTeile(stand) { + const leiste = $("skodaTeile"); + if (!leiste) return; + leiste.innerHTML = ""; + + TEILE.forEach(function (t) { + const wert = stand ? stand[t.feld] : null; + const knoten = $(t.id); + const anzeige = (wert === null || wert === undefined || wert === "") + ? "unbekannt" + : (ZUSTANDSWORT[wert] || String(wert).toLowerCase()); + + const licht = t.feld === "lights" && wert === "ON"; + const offen = !licht && wert && OFFEN_WERTE.indexOf(wert) >= 0; + + if (knoten) { + knoten.classList.toggle("offen", !!offen); + knoten.classList.toggle("an", !!licht); + } + + const chip = document.createElement("span"); + chip.className = "chip" + (offen ? " offen" : "") + (licht ? " an" : ""); + chip.textContent = t.name + ": " + anzeige; + chip.dataset.teil = t.id; + + /* + * Eintrag und Fahrzeugteil zeigen aufeinander, damit auch ohne + * Farbunterscheidung klar ist, was gemeint ist. + * + * Mit Maus genügt das Überfahren. Auf einem Berührungsgerät gibt es das + * nicht - dort bleibt ein :hover nach dem Antippen sogar hängen. Deshalb + * schaltet ein Tippen die Hervorhebung um, und zwar auf beiden Seiten. + */ + chip.addEventListener("pointerenter", function (e) { + if (e.pointerType === "mouse") hervorheben(t.id, true); + }); + chip.addEventListener("pointerleave", function (e) { + if (e.pointerType === "mouse") hervorheben(t.id, false); + }); + chip.addEventListener("click", () => hervorheben(t.id, "um")); + leiste.appendChild(chip); + }); +} + +/* -------------------------------------------------------------- Live-Block */ + +let livePlanung = null; +function planeLiveAnzeige() { + if (livePlanung) return; // höchstens fünfmal je Sekunde + livePlanung = setTimeout(function () { livePlanung = null; zeichneLive(); }, 200); +} + +let letzterStand = null; // letzte Zeile aus der Tabelle + +function zeichneLive() { + const stand = letzterStand; + + // Ladestand: MQTT hat Vorrang, die Tabelle ist der Rückfall. + const soc = mqZahl("evSOC") ?? (stand ? Number(stand.soc) : null); + const ziel = stand && stand.target_soc ? Number(stand.target_soc) : null; + const zustand = mqText("evChgState") || (stand ? stand.chg_state : null); + const laedt = zustand === "CHARGING"; + + // Ring: der Umfang ist 2*pi*r, der Rest ergibt sich aus dem Ladestand. + const ring = $("skodaRing"); + if (ring) { + const umfang = 2 * Math.PI * 82; + ring.style.strokeDasharray = umfang; + ring.style.strokeDashoffset = umfang * (1 - (soc || 0) / 100); + ring.style.stroke = laedt ? "#4caf7d" : (soc !== null && soc < 15 ? "#e06060" : "#7aa7c7"); + } + const socText = $("skodaSocText"); + if (socText) socText.textContent = soc === null ? "–" : nf(soc); + + // Zielmarke auf dem Ring + const zielLinie = $("skodaZiel"); + if (zielLinie) { + zielLinie.style.display = ziel ? "" : "none"; + if (ziel) zielLinie.setAttribute("transform", "rotate(" + (ziel * 3.6) + " 100 100)"); + } + $("skodaZielText").textContent = ziel ? nf(ziel) + " %" : "–"; + + // Reichweite. range_m trägt den Rohwert der API; über 1500 kann nur Meter + // gemeint sein, ein Akku trägt keine 1500 Kilometer. + let km = mqZahl("evRange"); + if (km === null && stand && stand.range_m !== null && stand.range_m !== undefined) { + const roh = Number(stand.range_m); + km = roh > 1500 ? roh / 1000 : roh; + } + $("skodaReichweite").textContent = km === null ? "–" : nf(km) + " km"; + + // Ladeleistung: das Fahrzeug meldet DC-seitig, die Wallbox zählt am + // Hausanschluss. Beim Laden ist die Wallbox der belastbarere Wert. + const wbKw = mqZahl("evPower"); + const carKw = stand && stand.chg_kw !== null ? Number(stand.chg_kw) : null; + const kw = laedt ? (wbKw || carKw) : (wbKw && wbKw > 0.2 ? wbKw : null); + $("skodaLeistung").textContent = kw ? nf(kw, 1) + " kW" : "–"; + + const restMin = mqZahl("evRemChrgTime") ?? (stand ? Number(stand.chg_rem_min) : null); + $("skodaRestzeit").textContent = laedt ? dauer(restMin) : "–"; + + $("skodaKm").textContent = stand && stand.odo_km ? nf(stand.odo_km) + " km" : "–"; + + const klima = stand ? stand.ac_state : null; + const KLIMA = { OFF: "aus", COOLING: "kühlt", HEATING: "heizt", + HEATING_AUXILIARY: "Zuheizer", VENTILATION: "lüftet", + COMPLETED: "fertig", UNKNOWN: "unbekannt", UNSUPPORTED: "–" }; + $("skodaKlima").textContent = klima ? (KLIMA[klima] || klima) : "–"; + + // Ladezustand als Kennzeichen + const ZUSTAND = { + CHARGING: ["lädt", "text-bg-success"], + CONSERVING: ["hält Ladung", "text-bg-secondary"], + READY_FOR_CHARGING: ["bereit", "text-bg-info"], + CONNECT_CABLE: ["nicht angesteckt", "text-bg-secondary"], + CHARGING_INTERRUPTED: ["unterbrochen", "text-bg-warning"], + DISCHARGING: ["entlädt", "text-bg-warning"] + }; + const kz = $("skodaLadeZustand"); + const eintrag = ZUSTAND[zustand] || [zustand || "–", "text-bg-secondary"]; + kz.textContent = eintrag[0]; + kz.className = "badge " + eintrag[1]; + + // Verriegelung. null heißt: das Fahrzeug meldet UNKNOWN. + const schloss = $("skodaSchloss"); + const zu = stand ? stand.locked : null; + if (zu === null || zu === undefined) { + schloss.textContent = "Schloss unbekannt"; + schloss.className = "badge text-bg-secondary"; + } else if (Number(zu) === 1) { + schloss.textContent = "verriegelt"; + schloss.className = "badge text-bg-success"; + } else { + schloss.textContent = "offen"; + schloss.className = "badge text-bg-warning"; + } + + // Ladeanschluss pulst beim Laden + const stecker = $("teil-plug"); + if (stecker) stecker.classList.toggle("laedt", laedt); + + // Herkunft des Ladestroms: was die PV im Augenblick liefert, gegen das, + // was die Wallbox zieht. Mehr als die Ladeleistung kann die PV nicht + // beitragen, auch wenn gerade mehr Sonne da ist. + const box = $("skodaQuelleBox"); + const pvW = mqZahl("P_PV"); + if (laedt && kw && pvW !== null) { + const pvKw = Math.max(0, Math.min(kw, pvW / 1000)); + const anteil = Math.round(100 * pvKw / kw); + $("skodaAnteilPV").style.width = anteil + "%"; + $("skodaAnteilNetz").style.width = (100 - anteil) + "%"; + $("skodaQuelleText").textContent = anteil + " % Sonne, " + (100 - anteil) + " % Netz"; + box.style.display = ""; + } else { + box.style.display = "none"; + } +} + +/* ------------------------------------------------------------- Diagramme */ + +let kurveChart = null; +let gesundChart = null; + +const achsenFarbe = () => getComputedStyle(document.body).getPropertyValue("--bs-secondary-color") || "#9aa0a6"; +const netzFarbe = "rgba(128,138,148,0.18)"; + +function baueKurve() { + kurveChart = new Chart($("skodaKurve"), { + type: "line", + data: { datasets: [] }, + options: { + maintainAspectRatio: false, + animation: false, + interaction: { mode: "index", intersect: false }, + scales: { + // 'timestack' ist die Zeitachse, die diese Seite mitbringt + // (js/chartjs-scale-timestack.min.js). Chart.js' eingebautes 'time' + // braeuchte einen Datums-Adapter, den es hier nicht gibt. + x: { type: "timestack", adapters: { date: { locale: "DE-de" } }, + grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } }, + y: { title: { display: true, text: "kW", color: achsenFarbe() }, + beginAtZero: true, grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } }, + y1: { position: "right", min: 0, max: 100, + title: { display: true, text: "Ladestand %", color: achsenFarbe() }, + grid: { drawOnChartArea: false }, ticks: { color: achsenFarbe() } } + }, + plugins: { legend: { labels: { color: achsenFarbe() } } } + } + }); +} + +function baueGesundheit() { + gesundChart = new Chart($("skodaGesundheit"), { + type: "scatter", + data: { datasets: [] }, + options: { + maintainAspectRatio: false, + animation: false, + scales: { + x: { type: "timestack", adapters: { date: { locale: "DE-de" } }, + grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } }, + y: { title: { display: true, text: "kWh / 100 %", color: achsenFarbe() }, + grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } } + }, + plugins: { + legend: { labels: { color: achsenFarbe() } }, + tooltip: { + callbacks: { + label: (c) => { + const p = c.raw; + if (p.soc_von === undefined) return c.dataset.label + ": " + nf(c.parsed.y, 1); + return nf(c.parsed.y, 1) + " kWh/100 % (" + p.soc_von + " → " + p.soc_bis + + " %, " + nf(p.wh / 1000, 2) + " kWh)"; + } + } + } + } + } + }); +} + +/* ------------------------------------------------------------------ Karte */ + +/* + * Leaflet-Karte mit den Kacheln aus dem eigenen Zwischenspeicher. + * + * Die Kachel-URL zeigt bewusst auf ajax/tile.php und nicht unmittelbar auf + * tile.openstreetmap.org: so verlässt die Parkposition - also die + * Wohnadresse - das Haus nur beim allerersten Betrachten einer Gegend, und + * einmal geholte Kacheln bleiben auch ohne Internet verfügbar. Warum das so + * gebaut ist und was die Kachelrichtlinie verlangt, steht in tile.php. + * + * Die Fahrtlinien sind gestrichelt. Die API liefert nur die letzte bekannte + * PARKposition, also Start und Ziel einer Fahrt; die gefahrene Strecke kennt + * niemand. Eine durchgezogene Linie auf einer Straßenkarte würde behaupten, + * das Auto sei genau dort langgefahren. + */ + +const KARTE_ZOOM_MIN = 6; // muss zu ZOOM_MIN/ZOOM_MAX in tile.php passen +const KARTE_ZOOM_MAX = 18; + +let karte = null; // die Leaflet-Karte, einmal angelegt +let karteEbene = null; // Marker und Linien, wird bei jeder Auffrischung ersetzt +let karteAusgerichtet = false; // beim ersten Mal auf die Daten zoomen, danach nicht mehr + +/* + * Verknüpfung zwischen der Fahrtenliste und der Karte: Nummer der Fahrt im + * Feld letzteFahrten -> ihre Linie samt der beiden Stilvarianten. Wird bei + * jedem Füllen der Karte neu aufgebaut, weil die Linien dabei verworfen + * werden. + */ +let fahrtLinien = {}; + +/** Nummer der gerade gewählten Fahrt, oder null. */ +let gewaehlteFahrt = null; + +/* + * Die drei Zustände einer Fahrtlinie. + * + * Blau kennzeichnet die jüngste Fahrt, damit man sie ohne Suchen findet. + * Bernstein ist die Auswahl und muss das Blau überstimmen können - sonst + * bliebe unklar, welche Linie zur angeklickten Zeile gehört, sobald die + * jüngste gewählt ist. Bernstein steht auf dieser Seite ohnehin schon für + * "sieh hier hin": in derselben Farbe leuchten offene Türen am Fahrzeug. + */ +const FAHRT_STIL = { + normal: { color: "#8a949c", weight: 2, opacity: 0.55 }, + juengste: { color: "#4b8fd6", weight: 3, opacity: 0.95 }, + gewaehlt: { color: "#e0904f", weight: 5, opacity: 1 } +}; + +/** + * Den Stil einer Linie setzen. Die Auswahl gewinnt vor dem Überfahren, das + * Überfahren vor dem Grundzustand. + * + * Ohne diese eine Stelle für alle drei Zustände würde ein Mausklick auf eine + * gewählte Linie sie beim Verlassen wieder zurücksetzen - der Behandler für + * mouseout weiss sonst nichts von der Auswahl. + */ +function stilAnwenden(nummer, ueberfahren) { + const eintrag = fahrtLinien[nummer]; + if (!eintrag) return; + if (nummer === gewaehlteFahrt) { + eintrag.linie.setStyle(FAHRT_STIL.gewaehlt).bringToFront(); + } else if (ueberfahren) { + eintrag.linie.setStyle(Object.assign({}, eintrag.grund, + { weight: eintrag.grund.weight + 2, opacity: 1 })); + } else { + eintrag.linie.setStyle(eintrag.grund); + } +} + +/** + * Die Karte anlegen. Wird einmal beim Seitenaufbau gerufen; danach kümmert + * sich karteFuellen() um den Inhalt. + */ +function karteAufbauen() { + const feld = document.getElementById("skodaKarte"); + if (!feld || typeof L === "undefined") return; + + karte = L.map(feld, { + minZoom: KARTE_ZOOM_MIN, + maxZoom: KARTE_ZOOM_MAX, + zoomControl: true, + // Das Mausrad gehört auf dieser langen Seite dem Scrollen. Zoomen geht + // mit den Schaltflächen, per Doppelklick oder mit Strg und Mausrad. + scrollWheelZoom: false + }).setView([51.0, 10.0], 6); + + // Leaflets Vorspann enthält von Haus aus eine Flaggengrafik. Die Nennung + // der Bibliothek bleibt, das Bild kommt weg. + karte.attributionControl.setPrefix( + 'Leaflet'); + + L.tileLayer("ajax/tile.php?z={z}&x={x}&y={y}", { + minZoom: KARTE_ZOOM_MIN, + maxZoom: KARTE_ZOOM_MAX, + // Die Richtlinie verlangt sichtbare Nennung - nicht verstecken. + attribution: '© OpenStreetMap-Mitwirkende' + }).addTo(karte); + + karteEbene = L.layerGroup().addTo(karte); + + const heim = document.getElementById("skodaKarteHeim"); + if (heim) heim.addEventListener("click", karteAufStandort); + + // Beim Anlegen steht das Kartenfeld noch im Seitenaufbau. Leaflet misst + // dann eine falsche Größe, lässt rechts einen leeren Streifen stehen und + // wählt beim Ausrichten eine zu grobe Zoomstufe. Ein Nachmessen, sobald das + // Layout steht, räumt beides aus. + setTimeout(() => karte && karte.invalidateSize(), 0); +} + +/** Karte auf den aktuellen Standort zentrieren. */ +function karteAufStandort() { + if (karte && letzterOrt && letzterOrt.lat) { + karte.setView([+letzterOrt.lat, +letzterOrt.lon], 15); + } +} + +/** + * Marker und Linien neu setzen. + * + * Ausgerichtet wird nur beim ersten Mal. Sonst risse die minütliche + * Auffrischung dem Betrachter die Karte unter der Hand weg, sobald er + * irgendwo hingezoomt hat. + */ +function karteFuellen(fahrten, standort) { + if (!karte || !karteEbene) return; + karteEbene.clearLayers(); + + const punkte = []; + fahrtLinien = {}; + + // Fahrten von alt nach neu, damit die jüngste obenauf liegt. Die Nummer im + // Ursprungsfeld wird mitgeführt, damit die Fahrtenliste daneben ihre Zeile + // der passenden Linie zuordnen kann. + fahrten.slice().reverse().forEach(function (f, i, alle) { + if (!f.von_lat || !f.lat) return; + const nummer = fahrten.length - 1 - i; + const von = [+f.von_lat, +f.von_lon]; + const nach = [+f.lat, +f.lon]; + const jung = (i === alle.length - 1); + punkte.push(von, nach); + + const grund = jung ? FAHRT_STIL.juengste : FAHRT_STIL.normal; + + const linie = L.polyline([von, nach], Object.assign({ + dashArray: "6 6" // gestrichelt: Luftlinie, keine Route + }, grund)).bindPopup( + "" + textSicher(f.von || "?") + " → " + textSicher(f.nach || "?") + "
" + + nf(f.km) + " km · " + zeitpunkt(f.ankunft, true) + ); + linie.on("mouseover", () => stilAnwenden(nummer, true)); + linie.on("mouseout", () => stilAnwenden(nummer, false)); + linie.on("click", () => waehleFahrt(nummer, false)); + karteEbene.addLayer(linie); + + fahrtLinien[nummer] = { linie: linie, grund: grund, ecken: [von, nach] }; + }); + + // Eine zuvor gewählte Fahrt nach dem Neuaufbau wieder hervorheben. + if (gewaehlteFahrt !== null) { + stilAnwenden(gewaehlteFahrt, false); + } + + // Halte + fahrten.forEach(function (f) { + if (!f.lat) return; + karteEbene.addLayer(L.circleMarker([+f.lat, +f.lon], { + radius: 5, color: "#161a1d", weight: 1.5, + fillColor: "#8a949c", fillOpacity: 0.9 + }).bindPopup( + "" + textSicher(f.nach || "Halt") + "
" + + zeitpunkt(f.ankunft, true) + "
" + + nf(f.km) + " km gefahren" + )); + }); + + // Aktueller Standort: heller Punkt mit Hof, damit er sich von den Halten + // abhebt, auch wenn er auf einem davon liegt. + if (standort && standort.lat) { + const hier = [+standort.lat, +standort.lon]; + punkte.push(hier); + karteEbene.addLayer(L.circleMarker(hier, { + radius: 14, color: "#4caf7d", weight: 2, opacity: 0.45, fill: false + })); + karteEbene.addLayer(L.circleMarker(hier, { + radius: 7, color: "#161a1d", weight: 2, + fillColor: "#4caf7d", fillOpacity: 1 + }).bindPopup( + "" + textSicher(standort.address || "Aktuelle Position") + "
" + + (standort.park_state === "IN_MOTION" + ? "unterwegs – letzte bekannte Parkposition" + : "geparkt seit " + zeitpunkt(standort.datetime, true)) + )); + } + + if (!karteAusgerichtet && punkte.length) { + karteAusgerichtet = true; + // Vor dem Ausrichten nachmessen: die Zoomstufe folgt aus der + // Containergröße, und die stimmt erst, wenn das Layout steht. + karte.invalidateSize(); + // animate: false ist hier kein Schönheitsfehler, sondern Absicht. Eine + // Zoomfahrt von der Startansicht bis zum Ziel fordert jede Zwischenstufe + // an Kacheln an - bei einem Sprung über mehrere Stufen also ein Vielfaches + // dessen, was am Ende zu sehen ist. Alles davon ginge einmal nach draußen. + if (punkte.length === 1) { + karte.setView(punkte[0], 15, { animate: false }); + } else { + // maxZoom, damit eine einzelne eng beieinanderliegende Gruppe nicht + // bis auf Hausnummernebene aufgezogen wird. + karte.fitBounds(L.latLngBounds(punkte), { + padding: [24, 24], maxZoom: 14, animate: false + }); + } + } +} + +/** + * Eine Fahrt aus der Liste auf der Karte zeigen: Ausschnitt auf ihre beiden + * Enden, Linie hervorheben, Sprechblase öffnen. + * + * Ohne animate: false zöge die Karte über jede Zwischenstufe hinweg und + * fordert dabei deren Kacheln an - siehe die Begründung bei karteFuellen(). + */ +/** + * Eine Fahrt auswählen - der einzige Weg dorthin, gleich ob der Klick in der + * Liste oder auf der Karte kam. Beide Seiten werden hier zusammen gesetzt, + * sonst laufen Zeilenmarkierung und Linienfarbe auseinander. + * + * @param nummer Nummer der Fahrt in letzteFahrten + * @param hinzoomen Ausschnitt auf die Fahrt legen. Beim Klick auf der Karte + * unerwünscht: dort sieht man sie ja schon, und der Sprung + * risse einem den selbst gewählten Ausschnitt weg. + */ +function waehleFahrt(nummer, hinzoomen) { + const eintrag = fahrtLinien[nummer]; + if (!karte || !eintrag) return; + + const vorher = gewaehlteFahrt; + gewaehlteFahrt = nummer; + if (vorher !== null && vorher !== nummer) { + stilAnwenden(vorher, false); // alte Auswahl zurücksetzen + } + stilAnwenden(nummer, false); + + if (hinzoomen) { + karte.fitBounds(L.latLngBounds(eintrag.ecken), { + padding: [40, 40], maxZoom: 13, animate: false + }); + } + eintrag.linie.openPopup(); + markiereFahrtZeile(nummer); +} + +/* Vom Betrachter gewählte Einheit: "kwh" oder "prozent". */ +const EINHEIT_SPEICHER = "skodaVerbrauchEinheit"; + +/** + * Einheit umschalten. Sichtbar wird die Auswahl über eine Klasse auf der + * Kachel; die Zeilen selbst bleiben unberührt und überstehen damit die + * minütliche Auffrischung der Liste. + * + * Die Wahl liegt im Browser des Betrachters, nicht auf dem Server - sie geht + * niemanden sonst etwas an. Lesen und Schreiben stehen in try/catch, weil + * der Zugriff in privaten Fenstern und bei gesperrten Seitendaten wirft. + */ +function setzeEinheit(einheit, merken) { + const kachel = $("skodaFahrtenKachel"); + if (!kachel) return; + const kwh = einheit !== "prozent"; + kachel.classList.toggle("zeigt-kwh", kwh); + kachel.classList.toggle("zeigt-prozent", !kwh); + + const schalter = $("skodaEinheit"); + if (schalter) { + schalter.querySelectorAll("button").forEach(function (b) { + const aktiv = (b.dataset.einheit === "kwh") === kwh; + b.classList.toggle("aktiv", aktiv); + b.setAttribute("aria-pressed", aktiv ? "true" : "false"); + }); + } + if (merken) { + try { localStorage.setItem(EINHEIT_SPEICHER, kwh ? "kwh" : "prozent"); } catch (e) { } + } +} + +/** Umschalter verdrahten und die zuletzt gewählte Einheit wiederherstellen. */ +function koppleEinheitSchalter() { + const schalter = $("skodaEinheit"); + if (!schalter) return; + schalter.addEventListener("click", function (e) { + const knopf = e.target.closest("button[data-einheit]"); + if (knopf) setzeEinheit(knopf.dataset.einheit, true); + }); + let gemerkt = null; + try { gemerkt = localStorage.getItem(EINHEIT_SPEICHER); } catch (e) { } + setzeEinheit(gemerkt || "kwh", false); +} + +/** + * In der Überschrift der Fahrtenliste offenlegen, worauf die + * Kilowattstunden beruhen. Ohne das steht dort eine Zahl, deren Herkunft + * niemand mehr nachvollziehen kann - sie ist ja nicht gemessen, sondern aus + * dem Ladestand und der eigenen Ladehistorie gerechnet. + */ +function zeigeVerbrauchsbasis() { + const feld = $("skodaVerbrauchBasis"); + if (!feld) return; + + const schalter = $("skodaEinheit"); + if (!letzteKapazitaet) { + // Ohne Kapazität gibt es nichts umzuschalten: dann bleibt nur Prozent. + feld.textContent = ""; + feld.removeAttribute("title"); + if (schalter) schalter.style.display = "none"; + setzeEinheit("prozent", false); + return; + } + if (schalter) schalter.style.display = ""; + + const k = letzteKapazitaet; + feld.textContent = nf(k.je_soc_netto * 1000, 0) + " Wh je Prozent"; + + // Woher der Wirkungsgrad stammt, gehört dazu: gemessen ist er aus dem + // Verhältnis zweier Energien derselben Ladung, angenommen ist er geraten. + feld.title = "Aus " + k.n + " Ladungen des letzten Jahres: " + + nf(k.je100_zaehler, 1) + " kWh je 100 Prozentpunkte ab Wallbox-Zähler, " + + nf(k.je100_netto, 1) + " kWh davon in der Batterie. Ladewirkungsgrad " + + nf(k.wirkungsgrad * 100, 1) + " %, " + + (k.gemessen + ? "gemessen aus " + k.n_fahrzeug + " Ladungen — der Zähler misst vor dem " + + "Ladegerät, das Fahrzeug meldet dahinter." + : "angenommen, weil noch zu wenige Ladungen die Ladeleistung des " + + "Fahrzeugs mitgeliefert haben."); +} + +/** Die gewählte Zeile in der Fahrtenliste kenntlich machen. */ +function markiereFahrtZeile(nummer) { + const liste = $("skodaFahrten"); + if (!liste) return; + liste.querySelectorAll(".zeile.gewaehlt").forEach(z => z.classList.remove("gewaehlt")); + const zeile = liste.querySelector('.zeile[data-fahrt="' + nummer + '"]'); + if (zeile) { + zeile.classList.add("gewaehlt"); + zeile.scrollIntoView({ block: "nearest" }); // bei Klick auf der Karte + } +} + +/** + * Text für eine Sprechblase entschärfen. Adressen kommen von einem fremden + * Dienst; ohne das ginge fremdes Markup ungefiltert ins Dokument. + */ +function textSicher(s) { + const knoten = document.createElement("div"); + knoten.textContent = String(s); + return knoten.innerHTML; +} + +/* ------------------------------------------------------------- Abschnitte */ + +async function ladeLive() { + try { + const d = await hole("live"); + letzterStand = d.stand; + zeichneTeile(d.stand); + zeichneLive(); + + const alterText = $("skodaAlter"); + if (!d.stand) { + alterText.textContent = "Noch kein Fahrzeug angebunden"; + } else { + alterText.textContent = "Stand des Fahrzeugs: " + seit(d.alter_s) + + " (" + zeitpunkt(d.stand.datetime) + ")"; + } + } catch (e) { + $("skodaAlter").textContent = "Daten nicht abrufbar"; + } +} + +async function ladeLadungen() { + try { + const d = await hole("ladungen", "&tage=180"); + const liste = $("skodaLadungen"); + if (!d.ladungen.length) { liste.innerHTML = leer("Noch keine Ladung aufgezeichnet."); return; } + liste.innerHTML = d.ladungen.map(function (l) { + const kwh = l.wh > 0 ? nf(l.wh / 1000, 1) + " kWh" : "auswärts"; + return '
' + zeitpunkt(l.beginn) + + '' + l.soc_von + " → " + l.soc_bis + " % · " + + (l.art || "–") + "" + + '' + kwh + '' + + (l.kw_spitze ? nf(l.kw_spitze, 1) + " kW" : "") + "
"; + }).join(""); + } catch (e) { + $("skodaLadungen").innerHTML = leer("Ladungen nicht abrufbar."); + } +} + +async function ladeKurve() { + try { + const d = await hole("kurve"); + if (!d.punkte || !d.punkte.length) { + $("skodaKurveZeit").textContent = "noch keine Ladung"; + kurveChart.data.datasets = []; + kurveChart.update(); + return; + } + $("skodaKurveZeit").textContent = zeitpunkt(d.von) + " – " + zeitpunkt(d.bis); + // Die Zeitachse 'timestack' rechnet mit Millisekunden, nicht mit + // Date-Objekten - die nimmt sie stillschweigend nicht an und zeigt + // stattdessen den heutigen Tag. + const zeit = (p) => new Date(String(p.datetime).replace(" ", "T")).getTime(); + kurveChart.data.datasets = [ + { label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)", + borderWidth: 2, pointRadius: 0, fill: "origin", tension: .25, + data: d.punkte.map(p => ({ x: zeit(p), y: p.wb_kw === null ? null : +p.wb_kw })) }, + { label: "Fahrzeug", yAxisID: "y", borderColor: "#7aa7c7", borderWidth: 2, + pointRadius: 0, borderDash: [4, 3], tension: .25, + data: d.punkte.map(p => ({ x: zeit(p), y: p.chg_kw === null ? null : +p.chg_kw })) }, + { label: "Ladestand", yAxisID: "y1", borderColor: "#f0b429", borderWidth: 2, + pointRadius: 0, tension: .1, + data: d.punkte.map(p => ({ x: zeit(p), y: p.soc === null ? null : +p.soc })) } + ]; + kurveChart.update(); + } catch (e) { + $("skodaKurveZeit").textContent = "nicht abrufbar"; + } +} + +async function ladeGesundheit() { + try { + const d = await hole("gesundheit", "&tage=3650"); + const befund = $("skodaBefund"); + + if (!d.ladungen.length) { + gesundChart.data.datasets = []; + gesundChart.update(); + befund.innerHTML = '

Noch keine auswertbare Ladung. ' + + 'Gezählt werden Ladungen an der eigenen Wallbox mit mindestens 20 Prozentpunkten Zuwachs.

'; + return; + } + + const punkte = d.ladungen.map(l => ({ + x: new Date(String(l.beginn).replace(" ", "T")).getTime(), y: +l.je100, + soc_von: +l.soc_von, soc_bis: +l.soc_bis, wh: +l.wh + })); + + const saetze = [{ label: "Ladung", data: punkte, backgroundColor: "#4caf7d", pointRadius: 4 }]; + + // Ausgleichsgerade, wenn genug Punkte da sind + if (d.trend) { + const t0 = punkte[0].x; + const t1 = punkte[punkte.length - 1].x; + const JAHR = 365.25 * 864e5; + saetze.push({ + label: "Trend", type: "line", borderColor: "#9aa0a6", borderWidth: 1.5, + borderDash: [5, 4], pointRadius: 0, fill: false, + data: [{ x: t0, y: d.trend.achse }, + { x: t1, y: d.trend.achse + d.trend.steigung * (t1 - t0) / JAHR }] + }); + } + gesundChart.data.datasets = saetze; + gesundChart.update(); + + // Befund in Worten + const t = d.trend; + let html = ""; + if (t && t.prozent !== null) { + const eindeutig = Math.abs(t.steigung) > 2 * t.fehler; + html += '
' + (t.prozent > 0 ? "+" : "−") + nf(Math.abs(t.prozent), 2) + + '% pro Jahr
'; + html += '

' + + (eindeutig + ? "Der Wert wandert messbar." + : "Von null nicht zu unterscheiden — keine Alterung nachweisbar.") + + "

"; + html += "
" + + "
Nachweisbar wäre ab
" + nf(t.nachweisbar, 1) + " % im Jahr
" + + "
Beobachtungszeitraum
" + nf(t.spanne_jahre, 1) + " Jahre
"; + } else { + html += '

Für einen Trend sind es noch zu wenige Ladungen.

'; + } + const j = d.jahre[d.jahre.length - 1]; + if (j) { + html += "
Median " + j.jahr + "
" + nf(j.median, 1) + " kWh/100 %
"; + } + html += "
Ausgewertete Ladungen
" + d.ladungen.length + "
"; + befund.innerHTML = html; + } catch (e) { + $("skodaBefund").innerHTML = '

Auswertung nicht abrufbar.

'; + } +} + +let letzteFahrten = []; +let letzterOrt = null; + +/* + * Wie viele Kilowattstunden ein Ladestandsprozent wert ist, aus den eigenen + * Ladungen bestimmt und um die Ladeverluste bereinigt - siehe + * kapazitaetSchaetzen() in ajax/skoda.php. Bleibt null, solange es zu wenige + * Ladungen gibt; dann zeigt die Fahrtenliste nur Prozent. + */ +let letzteKapazitaet = null; + +async function ladeFahrten() { + try { + const d = await hole("fahrten", "&tage=180"); + letzteFahrten = d.fahrten || []; + letzterOrt = d.standort || null; + letzteKapazitaet = d.kapazitaet || null; + zeigeVerbrauchsbasis(); + + const liste = $("skodaFahrten"); + if (!letzteFahrten.length) { + liste.innerHTML = leer("Noch keine Fahrt aufgezeichnet."); + } else { + // data-fahrt trägt die Nummer im Feld letzteFahrten. Darüber findet der + // Klick weiter unten die passende Linie auf der Karte. + liste.innerHTML = letzteFahrten.map(function (f, nummer) { + // Der Ladestandsverbrauch stimmt nur, wenn zwischendurch nicht + // geladen wurde - sonst ist er zu klein oder negativ. + const proz = Number(f.soc_ab); + const brauchbar = proz > 0 && f.km > 0; + const jeProzent = brauchbar ? proz / f.km * 100 : null; + + /* + * Beide Masse werden ausgegeben, sichtbar ist je nach Umschalter nur + * eines. Die Prozent sind gemessen, die Kilowattstunden daraus + * gerechnet - wer der Umrechnung nicht traut, schaltet um. + * + * Ohne bekannte Kapazität gibt es keine Kilowattstunden - lieber + * weniger anzeigen als eine Zahl, die auf einem geratenen Akku beruht. + */ + let verbrauch = ""; + if (brauchbar) { + if (letzteKapazitaet) { + verbrauch = '' + + nf(jeProzent * letzteKapazitaet.je_soc_netto, 1) + " kWh/100 km"; + } + verbrauch += '' + nf(jeProzent, 1) + " %/100 km"; + } + + const zeigbar = f.von_lat && f.lat; // ohne Koordinaten nichts zu zeigen + const teile = []; + if (brauchbar && letzteKapazitaet) { + teile.push(nf(proz * letzteKapazitaet.je_soc_netto, 1) + " kWh für die Fahrt"); + } + if (brauchbar) { + teile.push(proz + " Prozentpunkte Ladestand"); + } + if (zeigbar) { + teile.push("klicken, um die Fahrt auf der Karte zu zeigen"); + } + const hinweis = teile.join(" – "); + return '
' + zeitpunkt(f.ankunft) + + '' + textSicher(f.von || "?") + " → " + + textSicher(f.nach || "?") + "" + + '' + nf(f.km) + " km" + + '' + verbrauch + "
"; + }).join(""); + } + + const ort = $("skodaStandort"); + if (letzterOrt) { + ort.textContent = (letzterOrt.park_state === "IN_MOTION" ? "unterwegs — zuletzt: " : "") + + (letzterOrt.address || nf(letzterOrt.lat, 5) + ", " + nf(letzterOrt.lon, 5)); + const link = $("skodaOsmLink"); + if (link) { + link.href = "https://www.openstreetmap.org/?mlat=" + letzterOrt.lat + + "&mlon=" + letzterOrt.lon + "#map=17/" + letzterOrt.lat + "/" + letzterOrt.lon; + } + } else { + ort.textContent = "Keine Position bekannt"; + } + karteFuellen(letzteFahrten, letzterOrt); + } catch (e) { + $("skodaFahrten").innerHTML = leer("Fahrten nicht abrufbar."); + } +} + +/** + * Klicks in der Fahrtenliste auf die Karte legen. + * + * Der Behandler sitzt einmalig am Listenbehälter, nicht an den Zeilen: die + * werden bei jeder Auffrischung neu gebaut, einzeln angeheftete Behandler + * gingen dabei verloren. + */ +function koppleFahrtenListe() { + const liste = $("skodaFahrten"); + if (!liste) return; + + function ausloesen(ziel) { + const zeile = ziel.closest(".zeile.anklickbar"); + if (!zeile) return; + waehleFahrt(Number(zeile.dataset.fahrt), true); + + // Die Karte steht weiter unten als die angeklickte Zeile - ohne das + // passierte der Sprung ausserhalb des Blickfelds. + const feld = $("skodaKarte"); + if (feld && feld.getBoundingClientRect().bottom > window.innerHeight) { + feld.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + } + + liste.addEventListener("click", (e) => ausloesen(e.target)); + liste.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); // Leertaste soll nicht die Seite scrollen + ausloesen(e.target); + } + }); +} + +/* ------------------------------------------------------------- Steuerung */ + +/* + * Die Befehle sitzen an den Stellen im Fahrzeugbild, um die es geht: der + * Ladeanschluss steuert das Laden, der Innenraum Klima und Lüftung. Ein + * Antippen öffnet das Modal, das auch die Solarseite benutzt (#modalEV aus + * restricted/footer.php). + * + * Der Speichern-Knopf des Modals wird ausgeblendet - hier gibt es nichts zu + * speichern, jeder Knopf wirkt sofort. + */ +const STEUERUNG = { + laden: { + titel: "Laden", + zeile: "Startet oder stoppt den Ladevorgang am Fahrzeug. Die Wallbox " + + "bleibt davon unberührt - sie gibt weiterhin frei, was sie freigibt.", + knoepfe: [ + { befehl: "laden_start", text: "Laden starten", stil: "success", zeichen: "play-fill" }, + { befehl: "laden_stop", text: "Laden stoppen", stil: "secondary", zeichen: "stop-fill" } + ] + }, + klima: { + titel: "Klima und Lüftung", + zeile: "Klimatisiert den Innenraum auf die eingestellte Temperatur oder " + + "lüftet ihn durch. Beides geht auch ohne angestecktes Kabel.", + temperatur: true, + knoepfe: [ + { befehl: "klima_start", text: "Klima starten", stil: "info", zeichen: "thermometer-half" }, + { befehl: "klima_stop", text: "Klima stoppen", stil: "secondary", zeichen: "stop-fill" }, + { befehl: "lueftung_start", text: "Lüftung starten", stil: "info", zeichen: "wind" }, + { befehl: "lueftung_stop", text: "Lüftung stoppen", stil: "secondary", zeichen: "stop-fill" } + ] + } +}; + +let steuerModal = null; + +/** Die anklickbaren Stellen im Fahrzeugbild mit dem Modal verbinden. */ +function koppleSteuerung() { + const knoten = document.querySelectorAll("#skodaAuto .steuer[data-steuer]"); + if (!knoten.length || typeof bootstrap === "undefined") return; + steuerModal = new bootstrap.Modal(document.getElementById("modalEV"), { keyboard: true }); + + knoten.forEach(function (k) { + const oeffnen = function (e) { + e.stopPropagation(); // nicht zusätzlich die Hervorhebung umschalten + zeigeSteuerung(k.dataset.steuer); + }; + k.addEventListener("click", oeffnen); + k.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); oeffnen(e); } + }); + }); +} + +/** Das Modal mit den Befehlen eines Bereichs füllen und zeigen. */ +function zeigeSteuerung(bereich) { + const b = STEUERUNG[bereich]; + if (!b || !steuerModal) return; + + document.getElementById("modal-title").textContent = b.titel; + const speichern = document.getElementById("modalSaveBtn"); + if (speichern) speichern.style.display = "none"; + + const rumpf = document.getElementById("modal-body"); + let html = '
' + + '

' + b.zeile + "

"; + + if (b.temperatur) { + html += ''; + } + + html += '
'; + b.knoepfe.forEach(function (k) { + html += '"; + }); + html += "
"; + + html += '
'; + html += '

Vier Befehle je Stunde. ' + + "Was das Fahrzeug ausführt, erscheint in der Anzeige erst beim nächsten Abruf.

"; + if (bereich === "klima") { + html += '

Die Standheizung fehlt bewusst: ' + + "ihr Start verlangt die Sicherheits-PIN des Fahrzeugs.

"; + } + html += "
"; + + rumpf.innerHTML = html; + verdrahteBefehle(rumpf); + steuerModal.show(); +} + +/** + * Befehlsknöpfe im Modal verdrahten. + * + * Während ein Befehl unterwegs ist, werden alle Knöpfe gesperrt. Ein Befehl + * kostet eine Anfrage aus einem knappen Stundenkontingent - ein + * versehentlicher Doppelklick wäre die Hälfte davon. + */ +function verdrahteBefehle(bereich) { + const knoepfe = bereich.querySelectorAll("button[data-befehl]"); + const meldung = bereich.querySelector("#skodaBefehlAntwort"); + + knoepfe.forEach(function (knopf) { + knopf.addEventListener("click", async function () { + const daten = new URLSearchParams({ befehl: knopf.dataset.befehl }); + const feld = bereich.querySelector("#skodaKlimaTemp"); + if (knopf.dataset.befehl === "klima_start" && feld) { + daten.set("temperatur", feld.value); + } + + knoepfe.forEach(k => { k.disabled = true; }); + meldung.className = "skoda-rueckmeldung mt-3 warte"; + meldung.textContent = "Befehl wird gesendet …"; + + try { + const antwort = await fetch("ajax/skodaCmd.php", { + method: "POST", body: daten, cache: "no-store" + }); + const d = await antwort.json(); + meldung.className = "skoda-rueckmeldung mt-3 " + (d.ok ? "gut" : "schlecht"); + meldung.textContent = d.text; + // Der Zustand ändert sich im Fahrzeug, nicht in der Tabelle. Ein + // Abruf gleich danach brächte noch den alten Stand; das Modul holt + // ihn nach seinem eigenen Takt. + if (d.ok) setTimeout(ladeLive, 20000); + } catch (e) { + meldung.className = "skoda-rueckmeldung mt-3 schlecht"; + meldung.textContent = "Der Server hat nicht geantwortet."; + } finally { + knoepfe.forEach(k => { k.disabled = false; }); + } + }); + }); +} + +/* ------------------------------------------------------------------ Start */ + +function alleLaden() { + ladeLive(); + ladeLadungen(); + ladeKurve(); + ladeGesundheit(); + ladeFahrten(); +} + +document.addEventListener("readystatechange", function () { + if (document.readyState !== "complete") return; + + baueKurve(); + baueGesundheit(); + karteAufbauen(); + koppleFahrtenListe(); + koppleEinheitSchalter(); + koppleFahrzeugTeile(); + koppleSteuerung(); + zeichneTeile(null); + zeichneLive(); + alleLaden(); + solarMQTT.getMQTT(); + + // Die Tabelle wächst im Takt des Fahrzeugs, nicht im Takt des Betrachters: + // beim Parken steht dort stundenlang dasselbe. Einmal pro Minute genügt. + setInterval(ladeLive, 60000); + setInterval(function () { ladeLadungen(); ladeKurve(); ladeFahrten(); }, 300000); + setInterval(ladeGesundheit, 900000); + + // Leaflet rechnet die Kachelanordnung selbst neu, sobald es erfährt, dass + // sein Container eine andere Größe hat. + let umbruch = null; + window.addEventListener("resize", function () { + clearTimeout(umbruch); + umbruch = setTimeout(() => karte && karte.invalidateSize(), 200); + }); +}); diff --git a/restricted/costs.php b/restricted/costs.php new file mode 100644 index 0000000..099bb8b --- /dev/null +++ b/restricted/costs.php @@ -0,0 +1,344 @@ +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); +} diff --git a/restricted/footer.php b/restricted/footer.php index 06f0082..02f2a15 100644 --- a/restricted/footer.php +++ b/restricted/footer.php @@ -62,6 +62,11 @@ + + + + diff --git a/restricted/header.php b/restricted/header.php index 898c148..6482ab2 100644 --- a/restricted/header.php +++ b/restricted/header.php @@ -44,6 +44,12 @@ if (!isset($_GET["floor"])) + + + + + + @@ -142,6 +148,12 @@ if (!isset($_GET["floor"]))

Wetter

+ + + diff --git a/restricted/history.php b/restricted/history.php index 69b5ecd..8b62ceb 100644 --- a/restricted/history.php +++ b/restricted/history.php @@ -74,7 +74,7 @@ $statistics = [
-
+
diff --git a/restricted/settings.php b/restricted/settings.php new file mode 100644 index 0000000..e9bf174 --- /dev/null +++ b/restricted/settings.php @@ -0,0 +1,109 @@ + $reihe) { + $daten[$key] = [ + "felder" => $reihe["felder"], + "spalten" => preisSpalten($reihe["felder"]), + "zeilen" => preiseLaden($key), + ]; + } +} catch (Throwable $e) { + $fehler = $e->getMessage(); +} +?> + +
+ +
+
+
+
+

Einstellungen

+
+
+
+
+ + +
+
+ +
Die Einstellungen sind nicht erreichbar:
+ + $reihe): ?> + +
+
+
+
+

me-2">

+
+ + +
+
+
+

+ + +
+
">Gültig ab
+
">Gültig bis
+ $feld): ?> +
">
+ +
">
+
+ +
+ + +
+ +
+
+
+ +
+
+ +
+ + diff --git a/restricted/skoda.php b/restricted/skoda.php new file mode 100644 index 0000000..17987a8 --- /dev/null +++ b/restricted/skoda.php @@ -0,0 +1,383 @@ + + +
+
+
+
+
+

Škoda

+
+
+   +
+
+
+
+ +
+
+ + +
+ + +
+
+
+

Fahrzeug

+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Beleuchtung + + + + + + Motorhaube + + + + + + + + + + + Panoramadach + + + + + + + + + + + + + + + Heckklappe + + + + + Tür vorn links + Tür hinten links + Tür vorn rechts + Tür hinten rechts + + + + + + + Seitenfenster + + + + + + + + + + + + + + + + Laden steuern + + + + + + + + Klima und Lüftung steuern + + + +
+

+ Ladeanschluss und Innenraum antippen, um zu steuern +

+
+
+
+ + +
+
+
+

Batterie und Ladung

+
+ +
+
+
+
+ + +
+ + + + + + Prozent + +
+ +
+
+
Reichweite
+
Ladeleistung
+
Restzeit
+
Ladeziel
+
Kilometerstand
+
Klima
+
+ + +
+
+ Ladestrom +
+
+
+
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+

Verlauf der letzten Ladung

+
+ +
+
+
+
+
+
+
+
+
+
+

Ladungen

+
+
+
+
+
+
+
+ + +
+
+
+
+

Akkugesundheit

+
+ kWh je 100 Ladestandsprozent +
+
+
+
+
+
+
+
+
+
+

Befund

+
+
+
+

Noch keine ausgewerteten Ladungen.

+
+

+ 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 weniger + Kilowattstunden für dieselben hundert Prozentpunkte. +

+
+
+
+
+ + + +
+
+
+
+

Standort und Fahrten

+
+ + + + +
+
+
+
+
+ + + jetzt + Halt + Luftlinie + +
+
+
+
+
+ +
+
+

Fahrten

+
+ + + + + + +
+
+
+
+
+
+
+
+ +
+
+
+ diff --git a/restricted/solar.php b/restricted/solar.php index 3ab7144..06e1ecf 100644 --- a/restricted/solar.php +++ b/restricted/solar.php @@ -74,7 +74,7 @@ -
+
diff --git a/solarLog_costs.sql b/solarLog_costs.sql new file mode 100644 index 0000000..e9f36e4 --- /dev/null +++ b/solarLog_costs.sql @@ -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 -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`;