Einstellungsseite, Skoda-Seite und Karte
Sammelstand der offenen Arbeit im Dashboard. Einstellungen: eine Eingabemaske fuer die Energiepreise unter restricted/settings.php, dazu costs.php als Modell und ajax/settings.php als Endpunkt. Die Preise stehen jetzt in gridCosts, gasCosts und fuelCosts statt fest im Quelltext von getStats.php; die Jahresstatistik rechnet ueber LEAD() den jeweils gueltigen Zeitraum aus und schlaegt den anteiligen Grundpreis auf. Eingegeben wird in gewohnten Einheiten - l/100 km, kWh/100 km, Euro je Liter -, umgerechnet wird beim Speichern. solarLog_costs.sql beschreibt die Umstellung der Tabellen. Skoda: eigene Seite mit Live-Werten ueber MQTT und Historie ueber ajax/skoda.php, Kommandos ueber ajax/skodaCmd.php. Karte: Leaflet mit einem eigenen Kachel-Zwischenspeicher (ajax/tile.php), dessen Ablage unter tiles/ nicht ins Repository gehoert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,3 +48,7 @@ Thumbs.db
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.backup
|
||||||
|
|
||||||
|
# Kartenkacheln des Zwischenspeichers (ajax/tile.php).
|
||||||
|
# Wachsen mit dem, was angesehen wurde - gehoeren nicht ins Repository.
|
||||||
|
tiles/
|
||||||
|
|||||||
+82
-15
@@ -4,30 +4,94 @@
|
|||||||
* Ersetzt getConsData_{month,year,decade}.php und getProdData_{month,year,decade}.php.
|
* Ersetzt getConsData_{month,year,decade}.php und getProdData_{month,year,decade}.php.
|
||||||
*
|
*
|
||||||
* Aufruf: energyHistory.php?series=prod|cons&range=month|year|decade
|
* 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("../helper.php");
|
||||||
require_once("./chartData.php");
|
require_once("./chartData.php");
|
||||||
|
|
||||||
// Zeitraum -> [Format der Beschriftung, Filter auf datetime]
|
// Zeitraum -> [Format der Beschriftung, Quelle]
|
||||||
$ranges = [
|
$ranges = [
|
||||||
"month" => ["'%d.%b.'", "DATE_FORMAT(datetime,'%Y%m%d') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 1 MONTH),'%Y%m%d')"],
|
"month" => ["'%d.%b.'", "roh"],
|
||||||
"year" => ["'%b. %y'", "DATE_FORMAT(datetime,'%Y%m') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 13 MONTH),'%Y%m')"],
|
"year" => ["'%b. %y'", "archiv"],
|
||||||
"decade" => ["'%Y'", "DATE_FORMAT(datetime,'%Y') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 10 YEAR),'%Y')"],
|
"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
|
// Datenreihen je Diagramm. Beide teilen sich Solarertrag und Heizstab und
|
||||||
// unterscheiden sich nur darin, wohin die Energie fließt (prod) bzw. woher
|
// unterscheiden sich nur darin, wohin die Energie fließt (prod) bzw. woher
|
||||||
// sie kommt (cons).
|
// sie kommt (cons).
|
||||||
$series = [
|
$quellen = [
|
||||||
"prod" => "SUM(pvP/12 - IF(gridP < 0, -gridP/12,0) - IF(battP < 0, -battP/12,0) - heaterPwr/12) AS Direktverbrauch,
|
"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(heaterPwr/12) AS Heizstab,
|
||||||
SUM(IF(battP < 0, -battP/12,0)) AS Batterieladung,
|
SUM(IF(battP < 0, -battP/12,0)) AS Batterieladung,
|
||||||
SUM(gridPfeed/12) AS Einspeisung",
|
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(heaterPwr/12) AS Heizstab,
|
||||||
SUM(IF(battP > 0, battP/12,0)) AS Batteriebezug,
|
SUM(IF(battP > 0, battP/12,0)) AS Batteriebezug,
|
||||||
SUM(gridPcons/12) AS Netzbezug",
|
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";
|
$linecolors["Direktverbrauch"] = "#FFFF00";
|
||||||
@@ -46,14 +110,17 @@ $linecolors["Vorhersage"] = "#2222FF";
|
|||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
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"] : "";
|
$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);
|
http_response_code(400);
|
||||||
echo json_encode(["error" => "Unbekannter Zeitraum oder Datenreihe."]);
|
echo json_encode(["error" => "Unbekannter Zeitraum oder Datenreihe."]);
|
||||||
exit;
|
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 = (object)[];
|
||||||
$obj->labels = [];
|
$obj->labels = [];
|
||||||
@@ -62,12 +129,12 @@ $obj->datasets = [];
|
|||||||
if (checkLogin()) {
|
if (checkLogin()) {
|
||||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||||
$query = "SELECT
|
$query = "SELECT
|
||||||
DATE_FORMAT(datetime, $labelFormat) AS 'time',
|
DATE_FORMAT($zeit, $labelFormat) AS 'time',
|
||||||
" . $series[$seriesKey] . "
|
" . $quelle["reihen"][$seriesKey] . "
|
||||||
FROM EnergyFlow
|
FROM " . $quelle["tabelle"] . " " . $quelle["join"] . "
|
||||||
WHERE $filter
|
WHERE $filter
|
||||||
GROUP BY DATE_FORMAT(datetime, $labelFormat)
|
GROUP BY DATE_FORMAT($zeit, $labelFormat)
|
||||||
ORDER BY datetime ; ";
|
ORDER BY MIN($zeit) ; ";
|
||||||
$obj = buildChartData($mysql, $query, $linecolors);
|
$obj = buildChartData($mysql, $query, $linecolors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+97
-41
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once("../helper.php");
|
require_once("../helper.php");
|
||||||
|
require_once("../restricted/costs.php");
|
||||||
if(!isset($_GET["year"])){
|
if(!isset($_GET["year"])){
|
||||||
$_GET["year"] = date("Y");
|
$_GET["year"] = date("Y");
|
||||||
}else{
|
}else{
|
||||||
@@ -17,47 +18,98 @@ if(isset($_GET["type"])){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$Query = "SELECT
|
/**
|
||||||
SUM(gridPcons/12000) as 'Stromverbrauch',
|
* Die Jahresstatistik als eine Abfrage - jetzt auf stats_daily.
|
||||||
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',
|
* Gerechnet wird nicht mehr ueber die Rohdaten aus EnergyFlow, sondern ueber
|
||||||
SUM((gridPfeed*gain)/12000) as 'Einspeisevergütung',
|
* die Tageswerte. Zwei Gruende:
|
||||||
SUM(IF(gridP < 500, heaterPwr, 0)*0.063/12000) as 'Ersparnis Heizung',
|
*
|
||||||
SUM(IF(battP > 0, battP, 0)*cost/12000) as 'Ersparnis Batterie',
|
* 1. Die Rohdaten werden nach zwoelf Monaten ausgeduennt bzw. ins
|
||||||
AVG(autonomy) AS 'Ø Autarkie',
|
* Stundenarchiv verschoben (solarlog-rollup.sh). Eine Kachel, die auf
|
||||||
(SUM(PL1_EV+PL2_EV+PL3_EV))/12 AS 'Autoladung ges. EG',
|
* EnergyFlow rechnet, zeigt fuer alte Jahre danach zu wenig an.
|
||||||
(((SUM(IF(gridP>100,0,PL1_EV+PL2_EV+PL3_EV)))/(SUM(PL1_EV+PL2_EV+PL3_EV))))*100 AS 'Autoladung Solar EG',
|
* 2. Ein Jahr sind 365 Zeilen statt rund 105.000.
|
||||||
(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',
|
* Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt
|
||||||
(SUM(PL1_EVog+PL2_EVog+PL3_EVog))/12 AS 'Autoladung ges. OG',
|
* LEAD() beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie
|
||||||
(((SUM(IF(gridP>100,0,PL1_EVog+PL2_EVog+PL3_EVog)))/(SUM(PL1_EVog+PL2_EVog+PL3_EVog))))*100 AS 'Autoladung Solar OG',
|
* zwei Tarife haben - die Verdichtung auf Tage verliert hier also nichts.
|
||||||
(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'
|
* Was in stats_daily vorberechnet sein MUSS, weil es eine Bedingung je
|
||||||
FROM EnergyFlow JOIN gridCosts ON DATE(datetime) >= DATE(gridCosts.active_date) AND DATE(datetime) <= DATE(gridCosts.end_date)
|
* Messwert auswertet und sich aus Tagessummen nicht rekonstruieren laesst:
|
||||||
WHERE year(datetime) = ".$_GET["year"].";";
|
*
|
||||||
|
* 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
|
$Query = statistikAbfrage($_GET["year"]);
|
||||||
SUM(gridPcons/12000) as 'Stromverbrauch',
|
$PrevQuery = statistikAbfrage($_GET["year"] - 1);
|
||||||
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).";";
|
|
||||||
|
|
||||||
|
// Reihenfolge wie in der Abfrage, mit "Verg.+Einsp. Strom" an Stelle 4.
|
||||||
$units = Array("kWh","€","€","€","€","€","€","%","kWh","%","€","L","kWh","%","€","L");
|
$units = Array("kWh","€","€","€","€","€","€","%",
|
||||||
$LessIsBetter = Array(true,true,false,false,false,false,false,false,false,false,false,false,false,false,false,false);
|
"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) {
|
function array_insert($array,$values,$offset) {
|
||||||
return array_slice($array, 0, $offset, true) + $values + array_slice($array, $offset, NULL, true);
|
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()) {
|
if (checkLogin()) {
|
||||||
|
|
||||||
$html = "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
|
$html = "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
|
||||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
$mysql = solarDb();
|
||||||
$Res = mysqli_query($mysql,$Query);
|
$Res = mysqli_query($mysql,$Query);
|
||||||
$ResPrev = mysqli_query($mysql,$PrevQuery);
|
$ResPrev = mysqli_query($mysql,$PrevQuery);
|
||||||
if(!$Res || !$ResPrev){
|
if(!$Res || !$ResPrev){
|
||||||
@@ -77,13 +129,17 @@ if (checkLogin()) {
|
|||||||
$i = 0;
|
$i = 0;
|
||||||
$row = $Res->fetch_assoc();
|
$row = $Res->fetch_assoc();
|
||||||
$rowPrev = $ResPrev->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["Vergütung+Einsparung Strom"] = $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"];
|
||||||
$row = array_insert($row,["Verg.+Einsp. Strom" => $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"]],4);
|
$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);
|
$rowPrev = array_insert($rowPrev,["Verg.+Einsp. Strom" => $rowPrev["Einspeisevergütung"]+$rowPrev["Verbrauchsersparnis"]],4);
|
||||||
$i = 0;
|
$i = 0;
|
||||||
foreach ($row as $name => $value) {
|
foreach ($row as $name => $value) {
|
||||||
if(str_starts_with($name,"Autoladung ges.")){
|
if(str_starts_with($name,"Autoladung ges.")){
|
||||||
$html .= "</div><hr class='mt-2 mb-3 border-light' />";
|
$html .= "</div><hr class='mt-3 mb-3 border-light' />";
|
||||||
$html .= "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
|
$html .= "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
|
||||||
}
|
}
|
||||||
/*$html .= "<div class='col-12 col-sm-4 col-md-2 col-xl-1'>
|
/*$html .= "<div class='col-12 col-sm-4 col-md-2 col-xl-1'>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Endpunkt der Einstellungsseite.
|
||||||
|
*
|
||||||
|
* GET ?action=list&reihe=strom Zeitraeume einer Preistabelle
|
||||||
|
* POST ?action=save {"reihe": "strom", "zeilen": [ ... ]}
|
||||||
|
*
|
||||||
|
* Ein reiner Verteiler: geprueft und geschrieben wird in
|
||||||
|
* restricted/costs.php, damit Maske und Endpunkt dieselbe Wahrheit ueber
|
||||||
|
* einen Zeitraum haben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once("../helper.php");
|
||||||
|
require_once("../restricted/costs.php");
|
||||||
|
|
||||||
|
if (!checkLogin()) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonAntwort($data, $code = 200)
|
||||||
|
{
|
||||||
|
http_response_code($code);
|
||||||
|
header("Content-Type: application/json; charset=utf-8");
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $_GET["action"] ?? "list";
|
||||||
|
|
||||||
|
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||||
|
$body = json_decode(file_get_contents("php://input"), true);
|
||||||
|
if (!is_array($body)) {
|
||||||
|
jsonAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
switch ($action) {
|
||||||
|
case "save":
|
||||||
|
$anzahl = preiseSpeichern($body["reihe"] ?? "", $body["zeilen"] ?? []);
|
||||||
|
// Die gespeicherte Fassung zurueckgeben: die Maske zeigt danach
|
||||||
|
// die abgeleiteten Enddaten, nicht die eingetippten.
|
||||||
|
jsonAntwort([
|
||||||
|
"gespeichert" => $anzahl,
|
||||||
|
"zeilen" => preiseLaden($body["reihe"]),
|
||||||
|
]);
|
||||||
|
default:
|
||||||
|
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||||
|
}
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
jsonAntwort(["error" => $e->getMessage()], 400);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === "list") {
|
||||||
|
try {
|
||||||
|
jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
jsonAntwort(["error" => $e->getMessage()], 400);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
jsonAntwort(["error" => "Laden fehlgeschlagen: " . $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||||
+366
@@ -0,0 +1,366 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Daten der Škoda-Seite. Ein Endpunkt, mehrere Abschnitte:
|
||||||
|
*
|
||||||
|
* ?was=live letzter bekannter Stand, alle Spalten der jüngsten Zeile
|
||||||
|
* ?was=ladungen Ladevorgänge der letzten N Tage, aus Sitzungen gebildet
|
||||||
|
* ?was=kurve Verlauf einer einzelnen Ladung (SoC und kW über Zeit)
|
||||||
|
* ?was=gesundheit kWh je 100 Ladestandsprozent, je Ladung, mit Trend
|
||||||
|
* ?was=fahrten Fahrten zwischen zwei Parkständen, mit Koordinaten
|
||||||
|
*
|
||||||
|
* Die Fahrzeugdaten stehen in der Tabelle skoda, geschrieben von
|
||||||
|
* gatherSkodaData.py. Der Zeilentakt ist nicht fest: geschrieben wird, wenn
|
||||||
|
* das Fahrzeug etwas Neues meldet, mindestens stündlich. Alle Auswertungen
|
||||||
|
* hier müssen damit umgehen, dass die Abstände zwischen zwei Zeilen von vier
|
||||||
|
* Minuten bis zu einer Stunde reichen.
|
||||||
|
*
|
||||||
|
* Solange kein Fahrzeug angebunden ist, ist die Tabelle leer. Jeder Abschnitt
|
||||||
|
* liefert dann eine leere, aber wohlgeformte Antwort - die Seite zeigt
|
||||||
|
* daraufhin ihre Leerzustände und läuft nicht in Fehler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once("../helper.php");
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if (!checkLogin()) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(["error" => "Nicht angemeldet."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$was = isset($_GET["was"]) ? $_GET["was"] : "live";
|
||||||
|
$tage = isset($_GET["tage"]) ? max(1, min(3650, intval($_GET["tage"]))) : 90;
|
||||||
|
|
||||||
|
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||||
|
if ($mysql->connect_errno) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["error" => "Datenbank nicht erreichbar."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$mysql->set_charset("utf8mb4");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ladevorgänge aus den Einzelzeilen schneiden. Aufeinanderfolgende Zeilen mit
|
||||||
|
* chg_state = 'CHARGING' gehören zusammen, eine Pause über 30 Minuten trennt
|
||||||
|
* zwei Vorgänge. Wird von mehreren Abschnitten gebraucht, deshalb einmal hier.
|
||||||
|
*
|
||||||
|
* Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer
|
||||||
|
* Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
|
||||||
|
* Steht der Zähler still, wurde auswärts geladen (Schnellladesäule); dann
|
||||||
|
* bleibt wh null und die Kapazitätsrechnung lässt die Ladung aus.
|
||||||
|
*/
|
||||||
|
function sitzungenSQL($seit)
|
||||||
|
{
|
||||||
|
return "
|
||||||
|
SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
|
||||||
|
MIN(soc) AS soc_von, MAX(soc) AS soc_bis,
|
||||||
|
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc) AS SIGNED) AS zuwachs,
|
||||||
|
MAX(chg_type) AS art, MAX(target_soc) AS ziel,
|
||||||
|
ROUND(AVG(chg_kw), 2) AS kw_mittel, ROUND(MAX(chg_kw), 2) AS kw_spitze,
|
||||||
|
MAX(wb_wh_total) - MIN(wb_wh_total) AS wh,
|
||||||
|
ROUND(SUM(kwh_teil), 3) AS kwh_fahrzeug,
|
||||||
|
ROUND(AVG(pv_kw), 2) AS pv_kw, COUNT(*) AS punkte
|
||||||
|
FROM (
|
||||||
|
SELECT s.*, SUM(s.neu) OVER (ORDER BY s.datetime) AS sitzung FROM (
|
||||||
|
SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
|
||||||
|
wb_wh_total, pv_kw,
|
||||||
|
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
|
||||||
|
AND TIMESTAMPDIFF(MINUTE, LAG(datetime) OVER w, datetime) <= 30
|
||||||
|
THEN 0 ELSE 1 END AS neu,
|
||||||
|
/*
|
||||||
|
* Fahrzeugseitige Energie: chg_kw ist die Ladeleistung, die
|
||||||
|
* das Fahrzeug selbst meldet - gemessen HINTER dem Ladegerät.
|
||||||
|
* Die API kennt keine Kilowattstunden, deshalb wird hier über
|
||||||
|
* die Zeit integriert, je Schritt mit dem Mittel aus vorigem
|
||||||
|
* und aktuellem Wert (Trapez). Beim Laden mit Wechselstrom
|
||||||
|
* liegt die Leistung fast konstant an, da ist das genau genug.
|
||||||
|
*
|
||||||
|
* Die Bedingung hält die Summe innerhalb einer Ladung: über
|
||||||
|
* eine Lücke von mehr als 30 Minuten hinweg wäre die Fläche
|
||||||
|
* unter der Kurve frei erfunden.
|
||||||
|
*/
|
||||||
|
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
|
||||||
|
AND TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime)
|
||||||
|
BETWEEN 1 AND 1800
|
||||||
|
THEN (COALESCE(chg_kw, 0) + COALESCE(LAG(chg_kw) OVER w, 0)) / 2
|
||||||
|
* TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) / 3600
|
||||||
|
ELSE 0 END AS kwh_teil
|
||||||
|
FROM skoda WHERE datetime >= $seit
|
||||||
|
WINDOW w AS (ORDER BY datetime)) s) t
|
||||||
|
WHERE chg_state = 'CHARGING'
|
||||||
|
GROUP BY sitzung";
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Rückfallwert für den Ladewirkungsgrad, solange er sich nicht messen lässt.
|
||||||
|
* Beim Laden mit Wechselstrom gehen im Ladegerät des Fahrzeugs grob zehn
|
||||||
|
* Prozent verloren.
|
||||||
|
*/
|
||||||
|
const LADE_WIRKUNGSGRAD_ANNAHME = 0.90;
|
||||||
|
|
||||||
|
/** Ab so vielen Ladungen gilt der gemessene Wirkungsgrad als belastbar. */
|
||||||
|
const WIRKUNGSGRAD_MIN_LADUNGEN = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele Kilowattstunden ein Ladestandsprozent wert ist.
|
||||||
|
*
|
||||||
|
* Zwei Energien je Ladung stehen zur Verfügung, und der Unterschied zwischen
|
||||||
|
* ihnen ist genau das, was das Ladegerät verheizt:
|
||||||
|
*
|
||||||
|
* wh Differenz der Wallbox-Zählerstände - vor dem Ladegerät
|
||||||
|
* kwh_fahrzeug chg_kw über die Zeit integriert - hinter dem Ladegerät
|
||||||
|
*
|
||||||
|
* Für den Fahrverbrauch zählt, was die Batterie verlässt, also die zweite.
|
||||||
|
* Sie wird bevorzugt; erst wenn zu wenige Ladungen sie hergeben, wird der
|
||||||
|
* Zählerwert mit dem angenommenen Wirkungsgrad heruntergerechnet.
|
||||||
|
*
|
||||||
|
* Der Median statt des Mittels, weil einzelne Ladungen durch die Rundung des
|
||||||
|
* Ladestands weit ausreissen können.
|
||||||
|
*
|
||||||
|
* Gibt null zurück, solange es zu wenige Ladungen gibt. Die Oberfläche zeigt
|
||||||
|
* dann keine Kilowattstunden an, statt eine erfundene Zahl.
|
||||||
|
*/
|
||||||
|
function kapazitaetSchaetzen($mysql)
|
||||||
|
{
|
||||||
|
$sql = "SELECT ROUND(wh/1000/zuwachs*100, 3) AS je100_zaehler,
|
||||||
|
CASE WHEN kwh_fahrzeug > 0
|
||||||
|
THEN ROUND(kwh_fahrzeug/zuwachs*100, 3) END AS je100_fahrzeug
|
||||||
|
FROM (" . sitzungenSQL("NOW() - INTERVAL 365 DAY") . ") v
|
||||||
|
WHERE zuwachs >= 20 AND wh > 1000";
|
||||||
|
$res = $mysql->query($sql);
|
||||||
|
if (!$res) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$zaehler = [];
|
||||||
|
$fahrzeug = [];
|
||||||
|
$paare = []; // beide Werte derselben Ladung, für den Wirkungsgrad
|
||||||
|
while ($z = $res->fetch_assoc()) {
|
||||||
|
$zaehler[] = floatval($z["je100_zaehler"]);
|
||||||
|
if ($z["je100_fahrzeug"] !== null) {
|
||||||
|
$fahrzeug[] = floatval($z["je100_fahrzeug"]);
|
||||||
|
$paare[] = floatval($z["je100_fahrzeug"]) / floatval($z["je100_zaehler"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($zaehler) < 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gemessen = count($fahrzeug) >= WIRKUNGSGRAD_MIN_LADUNGEN;
|
||||||
|
if ($gemessen) {
|
||||||
|
$netto = median($fahrzeug);
|
||||||
|
$grad = median($paare);
|
||||||
|
} else {
|
||||||
|
$netto = median($zaehler) * LADE_WIRKUNGSGRAD_ANNAHME;
|
||||||
|
$grad = LADE_WIRKUNGSGRAD_ANNAHME;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"je100_zaehler" => round(median($zaehler), 2),
|
||||||
|
"je100_netto" => round($netto, 2),
|
||||||
|
"wirkungsgrad" => round($grad, 3),
|
||||||
|
"gemessen" => $gemessen,
|
||||||
|
"je_soc_netto" => round($netto / 100, 4),
|
||||||
|
"n" => count($zaehler),
|
||||||
|
"n_fahrzeug" => count($fahrzeug),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Median eines nicht leeren Zahlenfeldes. */
|
||||||
|
function median($werte)
|
||||||
|
{
|
||||||
|
sort($werte);
|
||||||
|
return $werte[intdiv(count($werte), 2)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$antwort = [];
|
||||||
|
|
||||||
|
switch ($was) {
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ live -- */
|
||||||
|
case "live":
|
||||||
|
$res = $mysql->query("SELECT * FROM skoda ORDER BY id DESC LIMIT 1");
|
||||||
|
$zeile = $res ? $res->fetch_assoc() : null;
|
||||||
|
$antwort = ["stand" => $zeile];
|
||||||
|
|
||||||
|
// Wie lange schweigt das Fahrzeug schon? Sagt mehr als der Zeitstempel
|
||||||
|
// allein, weil die Seite auch aus dem Zwischenspeicher kommen kann.
|
||||||
|
if ($zeile) {
|
||||||
|
$res = $mysql->query("SELECT TIMESTAMPDIFF(SECOND, '"
|
||||||
|
. $mysql->real_escape_string($zeile["datetime"]) . "', NOW()) AS alter_s");
|
||||||
|
$r = $res ? $res->fetch_assoc() : null;
|
||||||
|
$antwort["alter_s"] = $r ? intval($r["alter_s"]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kennzahlen über den gesamten Bestand, für die Kopfzeile.
|
||||||
|
$res = $mysql->query("SELECT COUNT(*) AS zeilen, MIN(datetime) AS seit,
|
||||||
|
MAX(odo_km) AS km FROM skoda");
|
||||||
|
$antwort["bestand"] = $res ? $res->fetch_assoc() : null;
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- ladungen -- */
|
||||||
|
case "ladungen":
|
||||||
|
$sql = "SELECT * FROM (" . sitzungenSQL("NOW() - INTERVAL $tage DAY") . ") v
|
||||||
|
ORDER BY beginn DESC LIMIT 200";
|
||||||
|
$res = $mysql->query($sql);
|
||||||
|
$antwort["ladungen"] = [];
|
||||||
|
while ($res && $z = $res->fetch_assoc()) {
|
||||||
|
$antwort["ladungen"][] = $z;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- kurve -- */
|
||||||
|
case "kurve":
|
||||||
|
// Verlauf einer Ladung. Ohne Parameter die jüngste.
|
||||||
|
$von = isset($_GET["von"]) ? $_GET["von"] : null;
|
||||||
|
$bis = isset($_GET["bis"]) ? $_GET["bis"] : null;
|
||||||
|
if (!$von || !$bis) {
|
||||||
|
$sql = "SELECT beginn, ende FROM (" . sitzungenSQL("NOW() - INTERVAL 365 DAY") . ") v
|
||||||
|
ORDER BY beginn DESC LIMIT 1";
|
||||||
|
$res = $mysql->query($sql);
|
||||||
|
$z = $res ? $res->fetch_assoc() : null;
|
||||||
|
if (!$z) { $antwort["punkte"] = []; break; }
|
||||||
|
$von = $z["beginn"];
|
||||||
|
$bis = $z["ende"];
|
||||||
|
}
|
||||||
|
// Eine Zeile Vorlauf und Nachlauf, damit die Kurve nicht in der Luft endet.
|
||||||
|
$stmt = $mysql->prepare(
|
||||||
|
"SELECT datetime, soc, chg_kw, wb_kw, pv_kw, chg_state, wb_wh_total
|
||||||
|
FROM skoda
|
||||||
|
WHERE datetime BETWEEN (? - INTERVAL 15 MINUTE) AND (? + INTERVAL 15 MINUTE)
|
||||||
|
ORDER BY datetime");
|
||||||
|
$stmt->bind_param("ss", $von, $bis);
|
||||||
|
$stmt->execute();
|
||||||
|
$res = $stmt->get_result();
|
||||||
|
$antwort["punkte"] = [];
|
||||||
|
while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; }
|
||||||
|
$antwort["von"] = $von;
|
||||||
|
$antwort["bis"] = $bis;
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ gesundheit -- */
|
||||||
|
case "gesundheit":
|
||||||
|
/*
|
||||||
|
* Kilowattstunden je hundert Ladestandsprozent. Der Wert liegt über der
|
||||||
|
* nutzbaren Kapazität, weil die Ladeverluste mitgezählt werden - absolut
|
||||||
|
* also nur eine Näherung, sein Verlauf über die Jahre dagegen die
|
||||||
|
* eigentliche Aussage.
|
||||||
|
*
|
||||||
|
* Nur lange Ladungen an der eigenen Wallbox taugen dazu: bei zwanzig
|
||||||
|
* Prozentpunkten Zuwachs wiegt ein gerundeter Ladestandspunkt fünf
|
||||||
|
* Prozent, und eine Schnellladung unterwegs hat auf unserer Seite keinen
|
||||||
|
* Zähler.
|
||||||
|
*/
|
||||||
|
$sql = "SELECT beginn, soc_von, soc_bis, zuwachs, art, wh,
|
||||||
|
ROUND(wh/1000/zuwachs*100, 2) AS je100
|
||||||
|
FROM (" . sitzungenSQL("NOW() - INTERVAL $tage DAY") . ") v
|
||||||
|
WHERE zuwachs >= 20 AND wh > 1000
|
||||||
|
ORDER BY beginn";
|
||||||
|
$res = $mysql->query($sql);
|
||||||
|
$antwort["ladungen"] = [];
|
||||||
|
while ($res && $z = $res->fetch_assoc()) { $antwort["ladungen"][] = $z; }
|
||||||
|
|
||||||
|
// Ausgleichsgerade über die Einzelwerte, x in Jahren. Die Steigung sagt,
|
||||||
|
// ob der Wert wandert; ihr Fehler, ab welcher Alterung man überhaupt
|
||||||
|
// etwas sehen könnte.
|
||||||
|
$n = count($antwort["ladungen"]);
|
||||||
|
$antwort["trend"] = null;
|
||||||
|
if ($n >= 6) {
|
||||||
|
$t0 = strtotime($antwort["ladungen"][0]["beginn"]);
|
||||||
|
$xs = []; $ys = [];
|
||||||
|
foreach ($antwort["ladungen"] as $l) {
|
||||||
|
$xs[] = (strtotime($l["beginn"]) - $t0) / (365.25 * 86400);
|
||||||
|
$ys[] = floatval($l["je100"]);
|
||||||
|
}
|
||||||
|
$mx = array_sum($xs) / $n;
|
||||||
|
$my = array_sum($ys) / $n;
|
||||||
|
$sxx = 0; $sxy = 0;
|
||||||
|
for ($i = 0; $i < $n; $i++) {
|
||||||
|
$sxx += ($xs[$i] - $mx) ** 2;
|
||||||
|
$sxy += ($xs[$i] - $mx) * ($ys[$i] - $my);
|
||||||
|
}
|
||||||
|
if ($sxx > 0) {
|
||||||
|
$steigung = $sxy / $sxx;
|
||||||
|
$achse = $my - $steigung * $mx;
|
||||||
|
$rest = 0;
|
||||||
|
for ($i = 0; $i < $n; $i++) {
|
||||||
|
$rest += ($ys[$i] - ($achse + $steigung * $xs[$i])) ** 2;
|
||||||
|
}
|
||||||
|
$streu = $n > 2 ? sqrt($rest / ($n - 2)) : 0;
|
||||||
|
$fehler = $streu / sqrt($sxx);
|
||||||
|
$antwort["trend"] = [
|
||||||
|
"steigung" => round($steigung, 3),
|
||||||
|
"fehler" => round($fehler, 3),
|
||||||
|
"achse" => round($achse, 2),
|
||||||
|
"prozent" => $achse != 0 ? round(100 * $steigung / $achse, 2) : null,
|
||||||
|
"nachweisbar" => $achse != 0 ? round(100 * 2 * $fehler / $achse, 1) : null,
|
||||||
|
"spanne_jahre" => round(max($xs), 2),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Median je Jahr - der Stand, um den die Wolke streut.
|
||||||
|
$jahre = [];
|
||||||
|
foreach ($antwort["ladungen"] as $l) {
|
||||||
|
$j = substr($l["beginn"], 0, 4);
|
||||||
|
$jahre[$j][] = floatval($l["je100"]);
|
||||||
|
}
|
||||||
|
$antwort["jahre"] = [];
|
||||||
|
foreach ($jahre as $j => $werte) {
|
||||||
|
sort($werte);
|
||||||
|
$k = count($werte);
|
||||||
|
$antwort["jahre"][] = [
|
||||||
|
"jahr" => $j,
|
||||||
|
"n" => $k,
|
||||||
|
"q1" => round($werte[intdiv($k, 4)], 2),
|
||||||
|
"median" => round($werte[intdiv($k, 2)], 2),
|
||||||
|
"q3" => round($werte[intdiv(3 * $k, 4)], 2),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- fahrten -- */
|
||||||
|
case "fahrten":
|
||||||
|
/*
|
||||||
|
* Eine Fahrt liegt zwischen zwei Parkständen. Die API liefert nur die
|
||||||
|
* letzte bekannte PARKposition - fährt das Fahrzeug, kommt allein
|
||||||
|
* IN_MOTION ohne Koordinaten. Start und Ziel sind damit bekannt, die
|
||||||
|
* Strecke dazwischen nicht.
|
||||||
|
*
|
||||||
|
* CAST(... AS SIGNED) ist nötig: soc und odo_km sind vorzeichenlos, und
|
||||||
|
* eine Differenz unter null bricht die Abfrage sonst ab - genau das
|
||||||
|
* passiert, sobald zwischen zwei Ständen geladen wurde.
|
||||||
|
*/
|
||||||
|
$sql = "SELECT * FROM (
|
||||||
|
SELECT datetime AS ankunft, address AS nach, lat, lon, odo_km, soc,
|
||||||
|
LAG(datetime) OVER w AS abfahrt,
|
||||||
|
LAG(address) OVER w AS von,
|
||||||
|
LAG(lat) OVER w AS von_lat,
|
||||||
|
LAG(lon) OVER w AS von_lon,
|
||||||
|
CAST(odo_km AS SIGNED) - LAG(CAST(odo_km AS SIGNED)) OVER w AS km,
|
||||||
|
LAG(CAST(soc AS SIGNED)) OVER w - CAST(soc AS SIGNED) AS soc_ab
|
||||||
|
FROM skoda
|
||||||
|
WHERE park_state = 'PARKED' AND odo_km IS NOT NULL
|
||||||
|
AND datetime >= NOW() - INTERVAL $tage DAY
|
||||||
|
WINDOW w AS (ORDER BY datetime)) f
|
||||||
|
WHERE km > 0
|
||||||
|
ORDER BY ankunft DESC LIMIT 100";
|
||||||
|
$res = $mysql->query($sql);
|
||||||
|
$antwort["fahrten"] = [];
|
||||||
|
while ($res && $z = $res->fetch_assoc()) { $antwort["fahrten"][] = $z; }
|
||||||
|
|
||||||
|
// Aktueller Standort, für die Karte.
|
||||||
|
$res = $mysql->query("SELECT datetime, lat, lon, address, park_state
|
||||||
|
FROM skoda WHERE lat IS NOT NULL
|
||||||
|
ORDER BY id DESC LIMIT 1");
|
||||||
|
$antwort["standort"] = $res ? $res->fetch_assoc() : null;
|
||||||
|
|
||||||
|
$antwort["kapazitaet"] = kapazitaetSchaetzen($mysql);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
http_response_code(400);
|
||||||
|
$antwort = ["error" => "Unbekannter Abschnitt."];
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($antwort, JSON_UNESCAPED_UNICODE);
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Befehle an das Fahrzeug.
|
||||||
|
*
|
||||||
|
* Aufruf per POST: befehl=laden_start | laden_stop | klima_start | klima_stop
|
||||||
|
* | lueftung_start | lueftung_stop
|
||||||
|
* temperatur=<Grad Celsius> (nur bei klima_start)
|
||||||
|
*
|
||||||
|
* Warum über den Server und nicht unmittelbar aus dem Browser? Der API-
|
||||||
|
* Schlüssel darf die Seite nicht verlassen. Er steht in skoda.conf neben
|
||||||
|
* gatherSkodaData.py, wird hier gelesen und geht nur von hier nach draußen.
|
||||||
|
*
|
||||||
|
* Nur POST. Ein Befehl, der sich über einen Link auslösen liesse, wäre über
|
||||||
|
* ein vorausgeladenes Bild oder einen Klick auf eine fremde Seite fernsteuerbar.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Zwei Schlüssel, zwei Kontingente
|
||||||
|
*
|
||||||
|
* Die API begrenzt auf 20 Anfragen je Stunde **und Schlüssel**, und Befehle
|
||||||
|
* zählen genauso mit wie Abrufe. Steht in skoda.conf ein eigener
|
||||||
|
* CMD_API_KEY, hat die Steuerung damit ihr eigenes Kontingent und nimmt dem
|
||||||
|
* Abruf des Fahrzeugzustands nichts weg. Beide Schlüssel werden in der
|
||||||
|
* MyŠkoda-App angelegt.
|
||||||
|
*
|
||||||
|
* Ohne eigenen Schlüssel greift dieses Skript auf API_KEY zurück - dann
|
||||||
|
* teilen sich beide ein Kontingent, und weil sie in getrennten Prozessen
|
||||||
|
* laufen und keinen gemeinsamen Zähler haben, ist es fest aufgeteilt:
|
||||||
|
*
|
||||||
|
* eigener CMD_API_KEY nur API_KEY
|
||||||
|
* Abruf 18 / Stunde 14 / Stunde
|
||||||
|
* Befehle 15 / Stunde 4 / Stunde
|
||||||
|
* Reserve 2 / Stunde 2 / Stunde
|
||||||
|
*
|
||||||
|
* gatherSkodaData.py stellt sich selbst um, sobald der zweite Schlüssel in
|
||||||
|
* skoda.conf auftaucht - dort ist nichts von Hand nachzuziehen.
|
||||||
|
*
|
||||||
|
* Der zweite Schlüssel ist kein Freibrief, den Abruf zu beschleunigen: die
|
||||||
|
* Kachelrichtlinie zielt auf die Last, nicht auf die Buchführung.
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
*
|
||||||
|
* Nicht angebunden ist die Standheizung. Ihr Start verlangt laut Spec die
|
||||||
|
* Sicherheits-PIN des Fahrzeugs ("spin"). Die dauerhaft auf dem NAS zu
|
||||||
|
* hinterlegen, nur damit ein Knopf im Dashboard funktioniert, ist eine andere
|
||||||
|
* Größenordnung als ein API-Schlüssel - das sollte bewusst entschieden werden,
|
||||||
|
* nicht nebenbei.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once("../helper.php");
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
const API_BASIS = "https://public.api.connect.skoda-auto.cz/api/v1/vehicles/";
|
||||||
|
const KONFIG = "/volume1/homes/wagner/SolarManager/skoda.conf";
|
||||||
|
const KENNUNG = "SmartController-SkodaSteuerung/1.0 (+https://nas.el-wa.org/smart)";
|
||||||
|
|
||||||
|
/** Befehle je Stunde - siehe Kopfkommentar. Hängt davon ab, ob die
|
||||||
|
* Steuerung einen eigenen Schlüssel hat oder ihn mit dem Abruf teilt. */
|
||||||
|
const BEFEHLE_EIGENER_SCHLUESSEL = 15;
|
||||||
|
const BEFEHLE_GETEILT = 4;
|
||||||
|
|
||||||
|
/** Wo der Stundenzähler liegt. Muss für den Webserver beschreibbar sein. */
|
||||||
|
const ZAEHLER_ORTE = [
|
||||||
|
"/volume1/web/smart/tiles", // falls per synoacltool freigegeben
|
||||||
|
"/var/services/tmp/smart-tiles", // sonst
|
||||||
|
];
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die erlaubten Befehle. Der Schlüssel kommt aus dem Formular, der Pfad
|
||||||
|
* niemals - so kann über den Parameter kein beliebiger Endpunkt der API
|
||||||
|
* angesprochen werden.
|
||||||
|
*/
|
||||||
|
const BEFEHLE = [
|
||||||
|
"laden_start" => ["pfad" => "charging/start", "text" => "Laden gestartet"],
|
||||||
|
"laden_stop" => ["pfad" => "charging/stop", "text" => "Laden gestoppt"],
|
||||||
|
"klima_start" => ["pfad" => "air-conditioning/start", "text" => "Klimatisierung gestartet"],
|
||||||
|
"klima_stop" => ["pfad" => "air-conditioning/stop", "text" => "Klimatisierung gestoppt"],
|
||||||
|
"lueftung_start" => ["pfad" => "active-ventilation/start", "text" => "Lüftung gestartet"],
|
||||||
|
"lueftung_stop" => ["pfad" => "active-ventilation/stop", "text" => "Lüftung gestoppt"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Zugang */
|
||||||
|
|
||||||
|
if (!checkLogin()) {
|
||||||
|
antwort(403, false, "Nicht angemeldet.");
|
||||||
|
}
|
||||||
|
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
|
||||||
|
antwort(405, false, "Nur per POST.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = isset($_POST["befehl"]) ? $_POST["befehl"] : "";
|
||||||
|
if (!isset(BEFEHLE[$name])) {
|
||||||
|
antwort(400, false, "Unbekannter Befehl.");
|
||||||
|
}
|
||||||
|
$befehl = BEFEHLE[$name];
|
||||||
|
|
||||||
|
$konfig = konfigLesen();
|
||||||
|
if (!$konfig) {
|
||||||
|
antwort(503, false, "Kein API-Schlüssel hinterlegt - skoda.conf fehlt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$grenze = $konfig["EIGENER"] ? BEFEHLE_EIGENER_SCHLUESSEL : BEFEHLE_GETEILT;
|
||||||
|
if (!budgetFrei($grenze)) {
|
||||||
|
antwort(429, false, "Stündliches Kontingent für Befehle erschöpft ("
|
||||||
|
. $grenze . " je Stunde)."
|
||||||
|
. ($konfig["EIGENER"] ? "" : " Ein eigener CMD_API_KEY in skoda.conf"
|
||||||
|
. " würde der Steuerung ein eigenes Kontingent geben.")
|
||||||
|
. " Später erneut versuchen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Ausführen */
|
||||||
|
|
||||||
|
$rumpf = null;
|
||||||
|
if ($name === "klima_start") {
|
||||||
|
// Die Zieltemperatur kommt aus dem Formular und geht in einen Rumpf, den
|
||||||
|
// die API auswertet - deshalb hart begrenzen statt nur zu vertrauen.
|
||||||
|
$grad = isset($_POST["temperatur"]) ? floatval($_POST["temperatur"]) : 21.0;
|
||||||
|
$grad = max(16.0, min(29.5, round($grad * 2) / 2)); // halbe Grad
|
||||||
|
$rumpf = ["targetTemperature" => ["value" => $grad, "unit" => "CELSIUS"]];
|
||||||
|
}
|
||||||
|
|
||||||
|
list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf);
|
||||||
|
|
||||||
|
if ($status === 202) {
|
||||||
|
antwort(200, true, $befehl["text"] . ". Das Fahrzeug führt den Befehl aus; "
|
||||||
|
. "in der Anzeige erscheint es beim nächsten Abruf.");
|
||||||
|
}
|
||||||
|
antwort(200, false, fehlertext($status, $inhalt));
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Bausteine -- */
|
||||||
|
|
||||||
|
/** Antwort ausgeben und beenden. */
|
||||||
|
function antwort($code, $ok, $text)
|
||||||
|
{
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode(["ok" => $ok, "text" => $text], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API-Schlüssel und VIN aus skoda.conf lesen. Format: NAME=wert je Zeile. */
|
||||||
|
function konfigLesen()
|
||||||
|
{
|
||||||
|
if (!is_readable(KONFIG)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$werte = [];
|
||||||
|
foreach (file(KONFIG, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $zeile) {
|
||||||
|
$zeile = trim($zeile);
|
||||||
|
if ($zeile === "" || $zeile[0] === "#" || strpos($zeile, "=") === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
list($k, $v) = explode("=", $zeile, 2);
|
||||||
|
$werte[strtoupper(trim($k))] = trim(trim($v), "\"'");
|
||||||
|
}
|
||||||
|
if (empty($werte["API_KEY"]) || empty($werte["VIN"])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Eigener Schluessel fuer die Steuerung, wenn hinterlegt - sonst der
|
||||||
|
// gemeinsame. Welcher es wurde, entscheidet unten ueber das Budget.
|
||||||
|
$werte["EIGENER"] = !empty($werte["CMD_API_KEY"]);
|
||||||
|
$werte["SCHLUESSEL"] = $werte["EIGENER"] ? $werte["CMD_API_KEY"] : $werte["API_KEY"];
|
||||||
|
return $werte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stundenzähler für Befehle. Gleiches Verfahren wie die Bremse in tile.php:
|
||||||
|
* eine kleine Datei, gegen gleichzeitige Zugriffe mit flock abgesichert.
|
||||||
|
*/
|
||||||
|
function budgetFrei($grenze)
|
||||||
|
{
|
||||||
|
$ort = null;
|
||||||
|
foreach (ZAEHLER_ORTE as $kandidat) {
|
||||||
|
if (is_dir($kandidat) && is_writable($kandidat)) {
|
||||||
|
$ort = $kandidat;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$ort) {
|
||||||
|
// Ohne Zähler lieber durchlassen als die Steuerung blockieren; die
|
||||||
|
// API bremst zur Not selbst mit 429.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$griff = fopen($ort . "/.befehle.json", "c+");
|
||||||
|
if (!$griff || !flock($griff, LOCK_EX)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$stand = json_decode(stream_get_contents($griff), true);
|
||||||
|
if (!is_array($stand)) {
|
||||||
|
$stand = ["fenster" => 0, "n" => 0];
|
||||||
|
}
|
||||||
|
$jetzt = time();
|
||||||
|
if ($jetzt - $stand["fenster"] >= 3600) {
|
||||||
|
$stand = ["fenster" => $jetzt, "n" => 0];
|
||||||
|
}
|
||||||
|
$frei = $stand["n"] < $grenze;
|
||||||
|
if ($frei) {
|
||||||
|
$stand["n"]++;
|
||||||
|
}
|
||||||
|
ftruncate($griff, 0);
|
||||||
|
rewind($griff);
|
||||||
|
fwrite($griff, json_encode($stand));
|
||||||
|
flock($griff, LOCK_UN);
|
||||||
|
fclose($griff);
|
||||||
|
return $frei;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */
|
||||||
|
function senden($konfig, $pfad, $rumpf)
|
||||||
|
{
|
||||||
|
$url = API_BASIS . rawurlencode($konfig["VIN"]) . "/" . $pfad;
|
||||||
|
$kopf = [
|
||||||
|
"X-API-Key: " . $konfig["SCHLUESSEL"],
|
||||||
|
"Accept: application/json",
|
||||||
|
];
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$optionen = [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_USERAGENT => KENNUNG,
|
||||||
|
CURLOPT_TIMEOUT => 20,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
|
];
|
||||||
|
if ($rumpf !== null) {
|
||||||
|
$kopf[] = "Content-Type: application/json";
|
||||||
|
$optionen[CURLOPT_POSTFIELDS] = json_encode($rumpf);
|
||||||
|
} else {
|
||||||
|
$optionen[CURLOPT_POSTFIELDS] = "";
|
||||||
|
}
|
||||||
|
$optionen[CURLOPT_HTTPHEADER] = $kopf;
|
||||||
|
curl_setopt_array($ch, $optionen);
|
||||||
|
|
||||||
|
$inhalt = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
$fehler = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($inhalt === false) {
|
||||||
|
error_log("skodaCmd: $url nicht erreichbar: $fehler");
|
||||||
|
return [0, null];
|
||||||
|
}
|
||||||
|
return [$status, $inhalt];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aus dem Statuscode eine Auskunft machen, mit der man etwas anfangen kann.
|
||||||
|
* Die API liefert dazu einen Problembericht nach RFC 9457; dessen "detail"
|
||||||
|
* ist oft die eigentliche Erklärung.
|
||||||
|
*/
|
||||||
|
function fehlertext($status, $inhalt)
|
||||||
|
{
|
||||||
|
$detail = "";
|
||||||
|
$bericht = json_decode((string)$inhalt, true);
|
||||||
|
if (is_array($bericht) && !empty($bericht["detail"])) {
|
||||||
|
$detail = " (" . $bericht["detail"] . ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($status) {
|
||||||
|
case 0: return "Die Škoda-API ist nicht erreichbar.";
|
||||||
|
case 400: return "Die API hat den Befehl abgelehnt." . $detail;
|
||||||
|
case 401: return "Der API-Schlüssel ist abgelaufen - in der MyŠkoda-App erneuern.";
|
||||||
|
case 403: return "Der Schlüssel darf diesen Befehl nicht ausführen." . $detail;
|
||||||
|
case 404: return "Fahrzeug nicht gefunden - stimmt die VIN in skoda.conf?";
|
||||||
|
case 422: return "Das Fahrzeug kann das gerade nicht." . $detail;
|
||||||
|
case 429: return "Kontingent der API erschöpft - später erneut versuchen.";
|
||||||
|
case 503:
|
||||||
|
case 504: return "Die API antwortet nicht rechtzeitig. Später erneut versuchen.";
|
||||||
|
default: return "Unerwartete Antwort der API (HTTP " . $status . ")." . $detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
+290
@@ -0,0 +1,290 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Kachel-Zwischenspeicher für die Karte.
|
||||||
|
*
|
||||||
|
* Aufruf: tile.php?z=16&x=34567&y=22456
|
||||||
|
*
|
||||||
|
* Liegt die Kachel im Zwischenspeicher, kommt sie von dort. Fehlt sie, holt
|
||||||
|
* das NAS sie einmal bei OpenStreetMap, legt sie ab und liefert aus. Danach
|
||||||
|
* verlässt für diese Kachel nie wieder eine Anfrage das Haus.
|
||||||
|
*
|
||||||
|
* Warum überhaupt ein Zwischenspeicher und nicht der direkte Weg vom Browser
|
||||||
|
* zu tile.openstreetmap.org? Drei Gründe:
|
||||||
|
*
|
||||||
|
* 1. Die Kachelrichtlinie der OSM Foundation verlangt Zwischenspeicherung
|
||||||
|
* von mindestens sieben Tagen. Der direkte Weg erfüllt das nur, soweit
|
||||||
|
* der Browser mitspielt.
|
||||||
|
* 2. Ohne Zwischenspeicher ginge die Parkposition des Fahrzeugs - also die
|
||||||
|
* Wohnadresse - bei jedem Seitenaufruf als Kachelanfrage nach draußen.
|
||||||
|
* 3. Einmal geholte Gegenden bleiben ohne Internet benutzbar.
|
||||||
|
*
|
||||||
|
* Was die Richtlinie ausdrücklich verbietet, ist Vorratsspeicherung: Kacheln
|
||||||
|
* auf Verdacht holen, Gebiete vorab abgrasen, Offline-Archive anlegen. Dieses
|
||||||
|
* Skript holt deshalb ausschließlich das, was gerade jemand ansieht, und die
|
||||||
|
* Bremse unten begrenzt zusätzlich, wie viel das pro Minute sein darf.
|
||||||
|
*
|
||||||
|
* Richtlinie: https://operations.osmfoundation.org/policies/tiles/
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once("../helper.php");
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Einstellungen */
|
||||||
|
|
||||||
|
/** Woher die Kacheln kommen. {z}/{x}/{y} werden ersetzt. */
|
||||||
|
const KACHEL_QUELLE = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Richtlinie verlangt einen eigenen, kontaktierbaren User-Agent. Vorgaben
|
||||||
|
* von Bibliotheken werden ohne Vorwarnung geblockt. Die Domain genügt als
|
||||||
|
* Kontakt; wer lieber eine Mailadresse hinterlegt, ersetzt sie hier.
|
||||||
|
*/
|
||||||
|
const KENNUNG = "SmartController-SkodaKarte/1.0 (+https://nas.el-wa.org/smart)";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wo die Kacheln liegen.
|
||||||
|
*
|
||||||
|
* Bevorzugt neben der Seite unter tiles/ - dort bleiben sie über Neustarts
|
||||||
|
* erhalten und liegen im Backup. PHP läuft auf dem NAS aber unter einem
|
||||||
|
* eigenen Benutzer, und DSM vergibt im Web-Verzeichnis ACLs, die ihm das
|
||||||
|
* Schreiben verwehren. Der POSIX-Modus täuscht: drwxrwxrwx mit einem "+"
|
||||||
|
* dahinter heisst, dass die ACL entscheidet, nicht die Rechtebits.
|
||||||
|
*
|
||||||
|
* Damit die Karte auch ohne Eingriff funktioniert, wird ersatzweise das
|
||||||
|
* Systemverzeichnis für flüchtige Dateien genommen. Das kostet nur den
|
||||||
|
* Bestand: es wird bei Gelegenheit geleert, die Kacheln müssen dann erneut
|
||||||
|
* geholt werden. Dauerhaft wird es mit einem Befehl als root:
|
||||||
|
*
|
||||||
|
* synoacltool -add /volume1/web/smart/tiles \
|
||||||
|
* "user:http:allow:rwxpdDaARWc--:fd--"
|
||||||
|
*
|
||||||
|
* Welcher Ort gerade benutzt wird, sagt speicherOrt() im Fehlerprotokoll,
|
||||||
|
* sobald zum ersten Mal eine Kachel geholt wird.
|
||||||
|
*/
|
||||||
|
const SPEICHER_ORTE = [
|
||||||
|
__DIR__ . "/../tiles", // dauerhaft, bevorzugt
|
||||||
|
"/var/services/tmp/smart-tiles", // flüchtig, aber immer beschreibbar
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Zoomstufen, die ausgeliefert werden. Feiner als Hausnummern braucht es nicht. */
|
||||||
|
const ZOOM_MIN = 6;
|
||||||
|
const ZOOM_MAX = 18;
|
||||||
|
|
||||||
|
/** Wie lange der Browser eine Kachel behalten darf (Sekunden). */
|
||||||
|
const BROWSER_FRIST = 7 * 24 * 3600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nach dieser Zeit wird eine Kachel beim nächsten Ansehen neu geholt. Die
|
||||||
|
* Karte ändert sich, aber nicht schnell. Scheitert das Nachholen, bleibt die
|
||||||
|
* alte Kachel - die Karte ist dann veraltet, aber nie kaputt.
|
||||||
|
*/
|
||||||
|
const AUFFRISCHEN_NACH = 60 * 24 * 3600;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Bremse gegen den eigenen Fehler. Ein Kartenfeld dieser Größe braucht rund
|
||||||
|
* zwölf Kacheln je Ansicht; wer sich durch mehrere Zoomstufen klickt, kommt
|
||||||
|
* auf einige Dutzend. Alles weit darüber wäre eine Schleife im Frontend - und
|
||||||
|
* die sähe für OSM wie Vorratsspeicherung aus. Ist die Grenze erreicht, wird
|
||||||
|
* eine graue Ersatzkachel geliefert statt weiter nach draußen zu gehen.
|
||||||
|
*/
|
||||||
|
const HOLEN_JE_MINUTE = 150;
|
||||||
|
const HOLEN_JE_STUNDE = 2000;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Zugriff */
|
||||||
|
|
||||||
|
if (!checkLogin()) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- Prüfung */
|
||||||
|
|
||||||
|
$z = filter_input(INPUT_GET, "z", FILTER_VALIDATE_INT);
|
||||||
|
$x = filter_input(INPUT_GET, "x", FILTER_VALIDATE_INT);
|
||||||
|
$y = filter_input(INPUT_GET, "y", FILTER_VALIDATE_INT);
|
||||||
|
|
||||||
|
// Ohne diese Prüfung liesse sich über z/x/y aus dem Speicherverzeichnis
|
||||||
|
// heraus schreiben - die Werte gehen schliesslich in einen Dateipfad.
|
||||||
|
$grenze = ($z === false || $z === null) ? 0 : (1 << $z);
|
||||||
|
if ($z === false || $z === null || $z < ZOOM_MIN || $z > ZOOM_MAX
|
||||||
|
|| $x === false || $x === null || $x < 0 || $x >= $grenze
|
||||||
|
|| $y === false || $y === null || $y < 0 || $y >= $grenze) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pfad = speicherOrt() . "/$z/$x/$y.png";
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- Ausliefern */
|
||||||
|
|
||||||
|
$vorhanden = is_file($pfad);
|
||||||
|
$veraltet = $vorhanden && (time() - filemtime($pfad) > AUFFRISCHEN_NACH);
|
||||||
|
|
||||||
|
if (!$vorhanden || $veraltet) {
|
||||||
|
if (holeUndSpeichere($z, $x, $y, $pfad)) {
|
||||||
|
$vorhanden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($vorhanden) {
|
||||||
|
liefereDatei($pfad);
|
||||||
|
} else {
|
||||||
|
liefereErsatzkachel();
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Bausteine -- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der erste beschreibbare Ort aus SPEICHER_ORTE, einmal je Aufruf ermittelt.
|
||||||
|
* Existiert keiner, wird der letzte angelegt - der liegt im Systemverzeichnis
|
||||||
|
* für flüchtige Dateien und ist immer beschreibbar.
|
||||||
|
*/
|
||||||
|
function speicherOrt()
|
||||||
|
{
|
||||||
|
static $ort = null;
|
||||||
|
if ($ort !== null) {
|
||||||
|
return $ort;
|
||||||
|
}
|
||||||
|
foreach (SPEICHER_ORTE as $kandidat) {
|
||||||
|
if (is_dir($kandidat) && is_writable($kandidat)) {
|
||||||
|
return $ort = $kandidat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ort = SPEICHER_ORTE[count(SPEICHER_ORTE) - 1];
|
||||||
|
if (!is_dir($ort)) {
|
||||||
|
@mkdir($ort, 0755, true);
|
||||||
|
}
|
||||||
|
return $ort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Kachel bei der Quelle holen und ablegen.
|
||||||
|
* Gibt true zurück, wenn danach eine brauchbare Datei vorliegt.
|
||||||
|
*/
|
||||||
|
function holeUndSpeichere($z, $x, $y, $pfad)
|
||||||
|
{
|
||||||
|
if (!bremseErlaubt()) {
|
||||||
|
return is_file($pfad); // notfalls die veraltete Kachel
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = str_replace(["{z}", "{x}", "{y}"], [$z, $x, $y], KACHEL_QUELLE);
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_USERAGENT => KENNUNG,
|
||||||
|
CURLOPT_TIMEOUT => 8,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 4,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 2,
|
||||||
|
]);
|
||||||
|
$inhalt = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
// Auf den PNG-Kopf prüfen, statt nur auf den Statuscode: eine Fehlerseite
|
||||||
|
// mit Status 200 wäre sonst als Kachel im Speicher gelandet und bliebe
|
||||||
|
// dort für immer.
|
||||||
|
if ($status !== 200 || $inhalt === false || strncmp($inhalt, "\x89PNG", 4) !== 0) {
|
||||||
|
return is_file($pfad);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir(dirname($pfad)) && !mkdir(dirname($pfad), 0755, true)) {
|
||||||
|
return is_file($pfad);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erst daneben schreiben, dann umbenennen. Sonst könnte ein zweiter
|
||||||
|
// Aufruf eine halb geschriebene Datei ausliefern.
|
||||||
|
$temp = $pfad . "." . getmypid() . ".teil";
|
||||||
|
if (file_put_contents($temp, $inhalt) === false) {
|
||||||
|
return is_file($pfad);
|
||||||
|
}
|
||||||
|
if (!rename($temp, $pfad)) {
|
||||||
|
unlink($temp);
|
||||||
|
return is_file($pfad);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Darf jetzt noch eine Kachel geholt werden?
|
||||||
|
*
|
||||||
|
* Gezählt wird in einer kleinen Datei, gegen gleichzeitige Zugriffe mit
|
||||||
|
* flock abgesichert. Zwei Fenster: eines je Minute, eines je Stunde. Läuft
|
||||||
|
* ein Fenster ab, fängt sein Zähler wieder bei null an.
|
||||||
|
*/
|
||||||
|
function bremseErlaubt()
|
||||||
|
{
|
||||||
|
$datei = speicherOrt() . "/.bremse.json";
|
||||||
|
|
||||||
|
$griff = fopen($datei, "c+");
|
||||||
|
if (!$griff) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!flock($griff, LOCK_EX)) {
|
||||||
|
fclose($griff);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stand = json_decode(stream_get_contents($griff), true);
|
||||||
|
if (!is_array($stand)) {
|
||||||
|
$stand = ["minute" => 0, "min_n" => 0, "stunde" => 0, "std_n" => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$jetzt = time();
|
||||||
|
if ($jetzt - $stand["minute"] >= 60) { $stand["minute"] = $jetzt; $stand["min_n"] = 0; }
|
||||||
|
if ($jetzt - $stand["stunde"] >= 3600) { $stand["stunde"] = $jetzt; $stand["std_n"] = 0; }
|
||||||
|
|
||||||
|
$erlaubt = $stand["min_n"] < HOLEN_JE_MINUTE && $stand["std_n"] < HOLEN_JE_STUNDE;
|
||||||
|
if ($erlaubt) {
|
||||||
|
$stand["min_n"]++;
|
||||||
|
$stand["std_n"]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
ftruncate($griff, 0);
|
||||||
|
rewind($griff);
|
||||||
|
fwrite($griff, json_encode($stand));
|
||||||
|
flock($griff, LOCK_UN);
|
||||||
|
fclose($griff);
|
||||||
|
|
||||||
|
if (!$erlaubt) {
|
||||||
|
error_log("tile.php: Bremse greift, keine weiteren Kacheln in diesem Fenster.");
|
||||||
|
}
|
||||||
|
return $erlaubt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine gespeicherte Kachel ausliefern, mit Unterstützung für 304. */
|
||||||
|
function liefereDatei($pfad)
|
||||||
|
{
|
||||||
|
$zeit = filemtime($pfad);
|
||||||
|
$marke = '"' . md5($pfad . $zeit) . '"';
|
||||||
|
|
||||||
|
header("Content-Type: image/png");
|
||||||
|
header("Cache-Control: public, max-age=" . BROWSER_FRIST);
|
||||||
|
header("ETag: $marke");
|
||||||
|
header("Last-Modified: " . gmdate("D, d M Y H:i:s", $zeit) . " GMT");
|
||||||
|
|
||||||
|
$bekannt = isset($_SERVER["HTTP_IF_NONE_MATCH"]) ? trim($_SERVER["HTTP_IF_NONE_MATCH"]) : "";
|
||||||
|
if ($bekannt === $marke) {
|
||||||
|
http_response_code(304);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
header("Content-Length: " . filesize($pfad));
|
||||||
|
readfile($pfad);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graue Ersatzkachel, wenn nichts zu holen war - kein Internet, Quelle
|
||||||
|
* gestört oder Bremse aktiv. Ein leeres Feld ist besser als ein kaputtes
|
||||||
|
* Bildsymbol, und es wird bewusst nicht zwischengespeichert.
|
||||||
|
*/
|
||||||
|
function liefereErsatzkachel()
|
||||||
|
{
|
||||||
|
header("Content-Type: image/png");
|
||||||
|
header("Cache-Control: no-store");
|
||||||
|
// 1x1 Punkt in Grau, von Leaflet auf Kachelgröße gezogen.
|
||||||
|
echo base64_decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
|
||||||
|
. "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
+661
@@ -0,0 +1,661 @@
|
|||||||
|
/* required styles */
|
||||||
|
|
||||||
|
.leaflet-pane,
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-tile-container,
|
||||||
|
.leaflet-pane > svg,
|
||||||
|
.leaflet-pane > canvas,
|
||||||
|
.leaflet-zoom-box,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-layer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* Prevents IE11 from highlighting tiles in blue */
|
||||||
|
.leaflet-tile::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||||
|
.leaflet-safari .leaflet-tile {
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
}
|
||||||
|
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||||
|
.leaflet-safari .leaflet-tile-container {
|
||||||
|
width: 1600px;
|
||||||
|
height: 1600px;
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||||
|
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||||
|
.leaflet-container .leaflet-overlay-pane svg {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-marker-pane img,
|
||||||
|
.leaflet-container .leaflet-shadow-pane img,
|
||||||
|
.leaflet-container .leaflet-tile-pane img,
|
||||||
|
.leaflet-container img.leaflet-image-layer,
|
||||||
|
.leaflet-container .leaflet-tile {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container img.leaflet-tile {
|
||||||
|
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||||
|
mix-blend-mode: plus-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: pan-x pan-y;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag {
|
||||||
|
-ms-touch-action: pinch-zoom;
|
||||||
|
/* Fallback for FF which doesn't support pinch-zoom */
|
||||||
|
touch-action: none;
|
||||||
|
touch-action: pinch-zoom;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tile {
|
||||||
|
filter: inherit;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile-loaded {
|
||||||
|
visibility: inherit;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 800;
|
||||||
|
}
|
||||||
|
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||||
|
.leaflet-overlay-pane svg {
|
||||||
|
-moz-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-pane { z-index: 400; }
|
||||||
|
|
||||||
|
.leaflet-tile-pane { z-index: 200; }
|
||||||
|
.leaflet-overlay-pane { z-index: 400; }
|
||||||
|
.leaflet-shadow-pane { z-index: 500; }
|
||||||
|
.leaflet-marker-pane { z-index: 600; }
|
||||||
|
.leaflet-tooltip-pane { z-index: 650; }
|
||||||
|
.leaflet-popup-pane { z-index: 700; }
|
||||||
|
|
||||||
|
.leaflet-map-pane canvas { z-index: 100; }
|
||||||
|
.leaflet-map-pane svg { z-index: 200; }
|
||||||
|
|
||||||
|
.leaflet-vml-shape {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.lvml {
|
||||||
|
behavior: url(#default#VML);
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* control positioning */
|
||||||
|
|
||||||
|
.leaflet-control {
|
||||||
|
position: relative;
|
||||||
|
z-index: 800;
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-top,
|
||||||
|
.leaflet-bottom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-top {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.leaflet-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.leaflet-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control {
|
||||||
|
float: left;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.leaflet-top .leaflet-control {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* zoom and fade animations */
|
||||||
|
|
||||||
|
.leaflet-fade-anim .leaflet-popup {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: opacity 0.2s linear;
|
||||||
|
-moz-transition: opacity 0.2s linear;
|
||||||
|
transition: opacity 0.2s linear;
|
||||||
|
}
|
||||||
|
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-animated {
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
-ms-transform-origin: 0 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
svg.leaflet-zoom-animated {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||||
|
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
}
|
||||||
|
.leaflet-zoom-anim .leaflet-tile,
|
||||||
|
.leaflet-pan-anim .leaflet-tile {
|
||||||
|
-webkit-transition: none;
|
||||||
|
-moz-transition: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* cursors */
|
||||||
|
|
||||||
|
.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.leaflet-grab {
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
cursor: -moz-grab;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.leaflet-crosshair,
|
||||||
|
.leaflet-crosshair .leaflet-interactive {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.leaflet-popup-pane,
|
||||||
|
.leaflet-control {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.leaflet-dragging .leaflet-grab,
|
||||||
|
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||||
|
.leaflet-dragging .leaflet-marker-draggable {
|
||||||
|
cursor: move;
|
||||||
|
cursor: -webkit-grabbing;
|
||||||
|
cursor: -moz-grabbing;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* marker & overlays interactivity */
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-pane > svg path,
|
||||||
|
.leaflet-tile-container {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-marker-icon.leaflet-interactive,
|
||||||
|
.leaflet-image-layer.leaflet-interactive,
|
||||||
|
.leaflet-pane > svg path.leaflet-interactive,
|
||||||
|
svg.leaflet-image-layer.leaflet-interactive path {
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* visual tweaks */
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
background: #ddd;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
color: #0078A8;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
border: 2px dotted #38f;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general typography */
|
||||||
|
.leaflet-container {
|
||||||
|
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general toolbar styles */
|
||||||
|
|
||||||
|
.leaflet-bar {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
line-height: 26px;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.leaflet-bar a,
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-position: 50% 50%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:hover,
|
||||||
|
.leaflet-bar a:focus {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.leaflet-bar a.leaflet-disabled {
|
||||||
|
cursor: default;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-bar a {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* zoom control */
|
||||||
|
|
||||||
|
.leaflet-control-zoom-in,
|
||||||
|
.leaflet-control-zoom-out {
|
||||||
|
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||||
|
text-indent: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* layers control */
|
||||||
|
|
||||||
|
.leaflet-control-layers {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers.png);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.leaflet-retina .leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers-2x.png);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers-toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers .leaflet-control-layers-list,
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded {
|
||||||
|
padding: 6px 10px 6px 6px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-scrollbar {
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-selector {
|
||||||
|
margin-top: 2px;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-separator {
|
||||||
|
height: 0;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin: 5px -10px 5px -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default icon URLs */
|
||||||
|
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||||
|
background-image: url(images/marker-icon.png);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* attribution and scale controls */
|
||||||
|
|
||||||
|
.leaflet-container .leaflet-control-attribution {
|
||||||
|
background: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution,
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
padding: 0 5px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a:hover,
|
||||||
|
.leaflet-control-attribution a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.leaflet-attribution-flag {
|
||||||
|
display: inline !important;
|
||||||
|
vertical-align: baseline !important;
|
||||||
|
width: 1em;
|
||||||
|
height: 0.6669em;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control-scale {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control-scale {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
border: 2px solid #777;
|
||||||
|
border-top: none;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 2px 5px 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
text-shadow: 1px 1px #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child) {
|
||||||
|
border-top: 2px solid #777;
|
||||||
|
border-bottom: none;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||||
|
border-bottom: 2px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-attribution,
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* popup */
|
||||||
|
|
||||||
|
.leaflet-popup {
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
padding: 1px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 13px 24px 13px 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content p {
|
||||||
|
margin: 17px 0;
|
||||||
|
margin: 1.3em 0;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip-container {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-left: -20px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
padding: 1px;
|
||||||
|
|
||||||
|
margin: -10px auto 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
-webkit-transform: rotate(45deg);
|
||||||
|
-moz-transform: rotate(45deg);
|
||||||
|
-ms-transform: rotate(45deg);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||||
|
color: #757575;
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||||
|
color: #585858;
|
||||||
|
}
|
||||||
|
.leaflet-popup-scrolled {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||||
|
-ms-zoom: 1;
|
||||||
|
}
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
width: 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||||
|
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-control-zoom,
|
||||||
|
.leaflet-oldie .leaflet-control-layers,
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
border: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* div icon */
|
||||||
|
|
||||||
|
.leaflet-div-icon {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
/* Base styles for the element that has a tooltip */
|
||||||
|
.leaflet-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
padding: 6px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tooltip.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before,
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 6px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directions */
|
||||||
|
|
||||||
|
.leaflet-tooltip-bottom {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top {
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
bottom: 0;
|
||||||
|
margin-bottom: -12px;
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before {
|
||||||
|
top: 0;
|
||||||
|
margin-top: -12px;
|
||||||
|
margin-left: -6px;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left {
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before {
|
||||||
|
right: 0;
|
||||||
|
margin-right: -12px;
|
||||||
|
border-left-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
left: 0;
|
||||||
|
margin-left: -12px;
|
||||||
|
border-right-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printing */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Prevent printers from removing background-images of controls. */
|
||||||
|
.leaflet-control {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
+531
@@ -375,3 +375,534 @@ input[type='range']::-webkit-slider-runnable-track {
|
|||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
opacity: 0.85;
|
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; }
|
||||||
|
|||||||
@@ -1,132 +1,136 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once("helper.php");
|
require_once("helper.php");
|
||||||
|
|
||||||
if (isset($_GET["addUser"])) {
|
if (isset($_GET["addUser"])) {
|
||||||
if (checkAdduser($_GET["addUser"])) {
|
if (checkAdduser($_GET["addUser"])) {
|
||||||
echo <<<ENDE
|
echo <<<ENDE
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
<title>Smarthome control</title>
|
<title>Smarthome control</title>
|
||||||
<link rel="icon" type="image/png" href="assets/img/favicon.png">
|
<link rel="icon" type="image/png" href="assets/img/favicon.png">
|
||||||
<!--end::Accessibility Meta Tags-->
|
<!--end::Accessibility Meta Tags-->
|
||||||
|
|
||||||
<!--begin::Primary Meta Tags-->
|
<!--begin::Primary Meta Tags-->
|
||||||
<meta name="title" content="Smarthome control" />
|
<meta name="title" content="Smarthome control" />
|
||||||
<meta name="author" content="ColorlibHQ" />
|
<meta name="author" content="ColorlibHQ" />
|
||||||
<meta name="description" content="Smarthome control panel by m0." />
|
<meta name="description" content="Smarthome control panel by m0." />
|
||||||
<meta name="keywords" content="smarthome dashboard, admin panel" />
|
<meta name="keywords" content="smarthome dashboard, admin panel" />
|
||||||
<!--end::Primary Meta Tags-->
|
<!--end::Primary Meta Tags-->
|
||||||
|
|
||||||
<!--begin::Fonts-->
|
<!--begin::Fonts-->
|
||||||
<link rel="stylesheet" href="assets/fonts/font_poppins.css" media="print" onload="this.media='all'" />
|
<link rel="stylesheet" href="assets/fonts/font_poppins.css" media="print" onload="this.media='all'" />
|
||||||
<!--end::Fonts-->
|
<!--end::Fonts-->
|
||||||
|
|
||||||
<!--begin::Third Party Plugin(Bootstrap Icons)-->
|
<!--begin::Third Party Plugin(Bootstrap Icons)-->
|
||||||
<link rel="stylesheet" href="css/bootstrap-icons.min.css" />
|
<link rel="stylesheet" href="css/bootstrap-icons.min.css" />
|
||||||
<!--end::Third Party Plugin(Bootstrap Icons)-->
|
<!--end::Third Party Plugin(Bootstrap Icons)-->
|
||||||
|
|
||||||
<!--begin::Required Plugin(AdminLTE)-->
|
<!--begin::Required Plugin(AdminLTE)-->
|
||||||
<link rel="stylesheet" href="./css/adminlte.min.css?v=2" />
|
<link rel="stylesheet" href="./css/adminlte.min.css?v=2" />
|
||||||
<!--end::Required Plugin(AdminLTE)-->
|
<!--end::Required Plugin(AdminLTE)-->
|
||||||
|
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body onload="createRegistration()">
|
<body onload="createRegistration()">
|
||||||
</body>
|
</body>
|
||||||
<script src="js/auth.js"></script>
|
<script src="js/auth.js"></script>
|
||||||
<script>
|
<script>
|
||||||
async function createRegistration() {
|
async function createRegistration() {
|
||||||
try {
|
try {
|
||||||
// check browser support
|
// check browser support
|
||||||
if (!window.fetch || !navigator.credentials || !navigator.credentials.create) {
|
if (!window.fetch || !navigator.credentials || !navigator.credentials.create) {
|
||||||
throw new Error('Browser not supported.');
|
throw new Error('Browser not supported.');
|
||||||
}
|
}
|
||||||
let keyName = encodeURIComponent(prompt('Bitte Hier den Namen des Schlüssels eingeben:'));
|
let keyName = encodeURIComponent(prompt('Bitte Hier den Namen des Schlüssels eingeben:'));
|
||||||
// get create args
|
// get create args
|
||||||
let rep = await window.fetch('authServer.php?fn=getCreateArgs', {method:'GET', cache:'no-cache'});
|
let rep = await window.fetch('authServer.php?fn=getCreateArgs', {method:'GET', cache:'no-cache'});
|
||||||
//alert(await rep.text());
|
//alert(await rep.text());
|
||||||
const createArgs = await rep.json();
|
const createArgs = await rep.json();
|
||||||
// error handling
|
// error handling
|
||||||
if (createArgs.success === false) {
|
if (createArgs.success === false) {
|
||||||
throw new Error(createArgs.msg || 'unknown error occured');
|
throw new Error(createArgs.msg || 'unknown error occured');
|
||||||
}
|
}
|
||||||
|
|
||||||
// replace binary base64 data with ArrayBuffer. a other way to do this
|
// replace binary base64 data with ArrayBuffer. a other way to do this
|
||||||
// is the reviver function of JSON.parse()
|
// is the reviver function of JSON.parse()
|
||||||
recursiveBase64StrToArrayBuffer(createArgs);
|
recursiveBase64StrToArrayBuffer(createArgs);
|
||||||
|
|
||||||
// create credentials
|
// create credentials
|
||||||
const cred = await navigator.credentials.create(createArgs);
|
const cred = await navigator.credentials.create(createArgs);
|
||||||
|
|
||||||
// create object
|
// create object
|
||||||
const authenticatorAttestationResponse = {
|
const authenticatorAttestationResponse = {
|
||||||
transports: cred.response.getTransports ? cred.response.getTransports() : null,
|
transports: cred.response.getTransports ? cred.response.getTransports() : null,
|
||||||
clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
|
clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
|
||||||
attestationObject: cred.response.attestationObject ? arrayBufferToBase64(cred.response.attestationObject) : null
|
attestationObject: cred.response.attestationObject ? arrayBufferToBase64(cred.response.attestationObject) : null
|
||||||
};
|
};
|
||||||
|
|
||||||
// check auth on server side
|
// check auth on server side
|
||||||
rep = await window.fetch('authServer.php?fn=processCreate&name='+keyName, {
|
rep = await window.fetch('authServer.php?fn=processCreate&name='+keyName, {
|
||||||
method : 'POST',
|
method : 'POST',
|
||||||
body : JSON.stringify(authenticatorAttestationResponse),
|
body : JSON.stringify(authenticatorAttestationResponse),
|
||||||
cache : 'no-cache'
|
cache : 'no-cache'
|
||||||
});
|
});
|
||||||
authenticatorAttestationServerResponse = await rep.json();
|
authenticatorAttestationServerResponse = await rep.json();
|
||||||
// prompt server response
|
// prompt server response
|
||||||
if (authenticatorAttestationServerResponse.success) {
|
if (authenticatorAttestationServerResponse.success) {
|
||||||
window.alert(authenticatorAttestationServerResponse.msg || 'registration success');
|
window.alert(authenticatorAttestationServerResponse.msg || 'registration success');
|
||||||
if(!authenticatorAttestationServerResponse.msg){
|
if(!authenticatorAttestationServerResponse.msg){
|
||||||
window.location.href = "https://nas.el-wa.org/smart";
|
window.location.href = "https://nas.el-wa.org/smart";
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(authenticatorAttestationServerResponse.msg);
|
throw new Error(authenticatorAttestationServerResponse.msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.alert(err.message || 'unknown error occured');
|
window.alert(err.message || 'unknown error occured');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</html>
|
</html>
|
||||||
ENDE;
|
ENDE;
|
||||||
}
|
}
|
||||||
} else if (checkLogin()) {
|
} else if (checkLogin()) {
|
||||||
/*
|
/*
|
||||||
* Seitentabelle: Vorlage, Seitenskripte und welche Bibliotheken die Seite
|
* Seitentabelle: Vorlage, Seitenskripte und welche Bibliotheken die Seite
|
||||||
* ueberhaupt braucht. Frueher wurden Chart.js, MQTT & Co. auf jeder Seite
|
* ueberhaupt braucht. Frueher wurden Chart.js, MQTT & Co. auf jeder Seite
|
||||||
* geladen - die Home-Seite etwa zeichnet gar keine Diagramme.
|
* geladen - die Home-Seite etwa zeichnet gar keine Diagramme.
|
||||||
*/
|
*/
|
||||||
$pages = [
|
$pages = [
|
||||||
"solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true,
|
"solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/solarMQTT.js"]],
|
"scripts" => ["js/solar/solarMQTT.js"]],
|
||||||
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false,
|
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
|
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
|
||||||
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false,
|
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/heatMQTT.js"]],
|
"scripts" => ["js/solar/heatMQTT.js"]],
|
||||||
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false,
|
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/historyMQTT.js"]],
|
"scripts" => ["js/solar/historyMQTT.js"]],
|
||||||
"weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true,
|
"skoda" => ["template" => "skoda.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => true,
|
||||||
"scripts" => ["js/solar/weatherMQTT.js"]],
|
"scripts" => ["js/solar/skodaMQTT.js"]],
|
||||||
];
|
"weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true, "leaflet" => false,
|
||||||
|
"scripts" => ["js/solar/weatherMQTT.js"]],
|
||||||
// Unbekannte oder fehlende Aktion faellt auf die Solar-Seite zurueck.
|
"settings" => ["template" => "settings.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||||
if (!isset($_GET["action"]) || !isset($pages[$_GET["action"]])) {
|
"scripts" => ["js/solar/settings.js"]],
|
||||||
$_GET["action"] = "solar";
|
];
|
||||||
}
|
|
||||||
$page = $pages[$_GET["action"]];
|
// Unbekannte oder fehlende Aktion faellt auf die Solar-Seite zurueck.
|
||||||
|
if (!isset($_GET["action"]) || !isset($pages[$_GET["action"]])) {
|
||||||
include "restricted/header.php";
|
$_GET["action"] = "solar";
|
||||||
include "restricted/" . $page["template"];
|
}
|
||||||
include "restricted/footer.php";
|
$page = $pages[$_GET["action"]];
|
||||||
} else {
|
|
||||||
echo <<<ENDE
|
include "restricted/header.php";
|
||||||
<html>
|
include "restricted/" . $page["template"];
|
||||||
<head><link rel="icon" type="image/png" href="assets/img/favicon.png"></head>
|
include "restricted/footer.php";
|
||||||
<body onload="checkRegistration()">
|
} else {
|
||||||
<script src="js/auth.js"></script>
|
echo <<<ENDE
|
||||||
</body>
|
<html>
|
||||||
</html>
|
<head><link rel="icon" type="image/png" href="assets/img/favicon.png"></head>
|
||||||
ENDE;
|
<body onload="checkRegistration()">
|
||||||
}
|
<script src="js/auth.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
ENDE;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,254 @@
|
|||||||
|
/*
|
||||||
|
* Einstellungsseite: Preistabellen bearbeiten.
|
||||||
|
*
|
||||||
|
* Eine Preistabelle ist eine Zeitreihe - ab einem Stichtag gilt ein Preis, bis
|
||||||
|
* der naechste Stichtag kommt. Deshalb gibt es hier nur ein Datumsfeld je
|
||||||
|
* Zeile. Wo eine Zeile endet, steht als grauer Text daneben und ergibt sich
|
||||||
|
* aus der naechsten; die juengste Zeile endet gar nicht, sie gilt weiter, bis
|
||||||
|
* ein neuer Preis eingetragen wird.
|
||||||
|
*
|
||||||
|
* Frueher stand das Ende als eigene Spalte in der Datenbank, und weil es
|
||||||
|
* eigenstaendig gepflegt wurde, konnte es dem naechsten Stichtag widersprechen:
|
||||||
|
* zwei Zeilen liefen ueber dasselbe Quartal, die Jahresstatistik zaehlte diese
|
||||||
|
* Tage doppelt. Ohne die Spalte laesst sich das nicht mehr hinschreiben.
|
||||||
|
*
|
||||||
|
* Gezeichnet wird aus dem Modell, nicht aus dem DOM: karte._modell ist die
|
||||||
|
* Wahrheit, jede Eingabe schreibt hinein und loest ein Neuzeichnen aus.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Datumskram ------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Ein Datum "JJJJ-MM-TT" um $tage verschieben. */
|
||||||
|
function tagVersatz(iso, tage) {
|
||||||
|
const d = new Date(iso + "T00:00:00Z");
|
||||||
|
d.setUTCDate(d.getUTCDate() + tage);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2025-01-01" -> "01.01.2025" */
|
||||||
|
function datumLesbar(iso) {
|
||||||
|
if (!iso) return "–";
|
||||||
|
const t = iso.split("-");
|
||||||
|
return t[2] + "." + t[1] + "." + t[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function heuteISO() {
|
||||||
|
const d = new Date();
|
||||||
|
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Modell ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Zeilen aufsteigend nach Stichtag. */
|
||||||
|
function sortiert(zeilen) {
|
||||||
|
return zeilen.slice().sort((a, b) => (a.ab < b.ab ? -1 : a.ab > b.ab ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die einzige Luecke, die es noch geben kann: vor dem aeltesten Stichtag gibt
|
||||||
|
* es keinen Preis. Messwerte, die davor liegen, fallen aus der Jahresstatistik
|
||||||
|
* heraus, weil der JOIN fuer sie keine Zeile findet. Passieren kann das, wenn
|
||||||
|
* jemand den aeltesten Zeitraum loescht oder nach hinten schiebt - deshalb
|
||||||
|
* wird die Warnung bei jedem Neuzeichnen geprueft, nicht nur beim Laden.
|
||||||
|
*/
|
||||||
|
function zeitraumWarnung(zeilen) {
|
||||||
|
const liste = sortiert(zeilen);
|
||||||
|
if (!ersteMessung || !liste.length || liste[0].ab <= ersteMessung) return [];
|
||||||
|
return ["Die Messwerte beginnen am " + datumLesbar(ersteMessung) + ", der älteste Preis"
|
||||||
|
+ " erst am " + datumLesbar(liste[0].ab) + ". Alles davor fehlt in der Statistik."];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zeichnen -------------------------------------------------------------
|
||||||
|
|
||||||
|
function preisZeile(karte, zeile, i, anzahl) {
|
||||||
|
const daten = preisDaten[karte.dataset.reihe];
|
||||||
|
const felder = daten.felder;
|
||||||
|
// Die Spaltenbreiten kommen aus preisSpalten() im PHP - Kopfzeile und
|
||||||
|
// Felder muessen dieselben benutzen, sonst stehen die Ueberschriften schief.
|
||||||
|
const breit = daten.spalten;
|
||||||
|
const letzte = (i === anzahl - 1);
|
||||||
|
const heute = heuteISO();
|
||||||
|
// Die juengste Zeile hat kein Ende; alle anderen enden am Tag vor der
|
||||||
|
// naechsten.
|
||||||
|
const bis = letzte ? null : tagVersatz(karte._modell[i + 1].ab, -1);
|
||||||
|
const gilt = zeile.ab <= heute && (bis === null || heute <= bis);
|
||||||
|
|
||||||
|
let html = "<div class='row g-2 align-items-center preis-zeile" + (gilt ? " preis-heute" : "")
|
||||||
|
+ "' data-i='" + i + "'>";
|
||||||
|
|
||||||
|
html += "<div class='col-6 col-xl-" + breit.ab + "'>"
|
||||||
|
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>Gültig ab</label>"
|
||||||
|
+ "<input type='date' class='form-control form-control-sm' data-feld='ab' value='" + zeile.ab + "'>"
|
||||||
|
+ "</div>";
|
||||||
|
|
||||||
|
// Die Marke "gilt heute" steht beim Zeitraum, nicht beim Papierkorb: sie
|
||||||
|
// beschreibt ihn, und die Aktionsspalte bleibt schmal genug, dass die
|
||||||
|
// Felder den Platz bekommen.
|
||||||
|
html += "<div class='col-6 col-xl-" + breit.bis + "'>"
|
||||||
|
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>Gültig bis</label>"
|
||||||
|
+ "<div class='form-control form-control-sm border-0 bg-transparent px-0"
|
||||||
|
+ " d-flex align-items-center gap-2'>"
|
||||||
|
+ "<span class='text-secondary'>" + (bis === null ? "offen" : datumLesbar(bis)) + "</span>"
|
||||||
|
+ (gilt ? "<span class='badge text-bg-primary'>gilt heute</span>" : "")
|
||||||
|
+ "</div></div>";
|
||||||
|
|
||||||
|
Object.keys(felder).forEach(function (spalte, nr) {
|
||||||
|
const feld = felder[spalte];
|
||||||
|
// Schrittweite aus den Nachkommastellen: der Energieinhalt je Liter will
|
||||||
|
// vier Stellen, ein Preis in Cent nur zwei.
|
||||||
|
const schritt = Math.pow(10, -feld.stellen).toFixed(feld.stellen);
|
||||||
|
html += "<div class='col-6 col-xl-" + breit.felder[nr] + "'>"
|
||||||
|
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>" + feld.titel + "</label>"
|
||||||
|
+ "<div class='input-group input-group-sm'>"
|
||||||
|
+ "<input type='number' step='" + schritt + "' min='0' max='" + feld.max + "'"
|
||||||
|
+ " class='form-control text-end' data-feld='" + spalte + "'"
|
||||||
|
+ " value='" + zeile[spalte] + "'>"
|
||||||
|
+ "<span class='input-group-text'>" + feld.einheit + "</span>"
|
||||||
|
+ "</div></div>";
|
||||||
|
});
|
||||||
|
|
||||||
|
html += "<div class='col-12 col-xl-" + breit.aktion + " d-flex align-items-center justify-content-end'>";
|
||||||
|
html += "<button type='button' class='btn btn-sm btn-outline-danger preis-weg' title='Zeitraum löschen'"
|
||||||
|
+ (anzahl > 1 ? "" : " disabled") + "><i class='bi bi-trash'></i></button>"
|
||||||
|
+ "</div></div>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeichnePreise(karte) {
|
||||||
|
karte._modell = sortiert(karte._modell);
|
||||||
|
const anzahl = karte._modell.length;
|
||||||
|
const warnung = zeitraumWarnung(karte._modell);
|
||||||
|
let html = "";
|
||||||
|
|
||||||
|
if (warnung.length) {
|
||||||
|
html += "<div class='alert alert-warning py-2 small'>"
|
||||||
|
+ "<i class='bi bi-exclamation-triangle me-1'></i>"
|
||||||
|
+ warnung.join(" ") + "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < anzahl; i++) {
|
||||||
|
html += preisZeile(karte, karte._modell[i], i, anzahl);
|
||||||
|
}
|
||||||
|
|
||||||
|
karte.querySelector(".preis-zeilen").innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bedienung ------------------------------------------------------------
|
||||||
|
|
||||||
|
function preisHinweis(karte, text, klasse) {
|
||||||
|
const feld = karte.querySelector(".preis-hinweis");
|
||||||
|
feld.className = "preis-hinweis small " + (klasse || "text-secondary");
|
||||||
|
feld.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preisNeueZeile(karte) {
|
||||||
|
const modell = karte._modell;
|
||||||
|
const vorlage = modell[modell.length - 1];
|
||||||
|
// Der neue Stichtag ist heute; ist der schon vergeben, der naechste freie
|
||||||
|
// Tag. So entsteht nie eine Zeile, die das Speichern gleich ablehnt.
|
||||||
|
let ab = heuteISO();
|
||||||
|
while (modell.some(z => z.ab === ab)) {
|
||||||
|
ab = tagVersatz(ab, 1);
|
||||||
|
}
|
||||||
|
const zeile = { ab: ab };
|
||||||
|
for (const spalte in preisDaten[karte.dataset.reihe].felder) {
|
||||||
|
zeile[spalte] = vorlage[spalte]; // Preise uebernehmen, geaendert wird meist nur einer
|
||||||
|
}
|
||||||
|
modell.push(zeile);
|
||||||
|
zeichnePreise(karte);
|
||||||
|
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
|
||||||
|
|
||||||
|
const zeilen = karte.querySelectorAll(".preis-zeile");
|
||||||
|
const feld = zeilen[zeilen.length - 1].querySelector("[data-feld='ab']");
|
||||||
|
feld.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preiseSenden(karte) {
|
||||||
|
const knopf = karte.querySelector(".preis-speichern");
|
||||||
|
knopf.disabled = true;
|
||||||
|
preisHinweis(karte, "Wird gespeichert …");
|
||||||
|
try {
|
||||||
|
const antwort = await fetch("ajax/settings.php?action=save", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ reihe: karte.dataset.reihe, zeilen: karte._modell })
|
||||||
|
});
|
||||||
|
const daten = await antwort.json();
|
||||||
|
if (!antwort.ok) {
|
||||||
|
throw new Error(daten.error || "Fehler " + antwort.status);
|
||||||
|
}
|
||||||
|
// Mit der Antwort des Servers weiterarbeiten, nicht mit dem eigenen
|
||||||
|
// Modell: sortiert und gerundet wird dort.
|
||||||
|
karte._modell = daten.zeilen;
|
||||||
|
zeichnePreise(karte);
|
||||||
|
preisHinweis(karte, "Gespeichert – " + daten.gespeichert + " Zeiträume.", "text-success");
|
||||||
|
} catch (fehler) {
|
||||||
|
preisHinweis(karte, fehler.message, "text-danger");
|
||||||
|
}
|
||||||
|
knopf.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preiseHolen(karte) {
|
||||||
|
preisHinweis(karte, "Wird geladen …");
|
||||||
|
try {
|
||||||
|
const antwort = await fetch("ajax/settings.php?action=list&reihe=" + encodeURIComponent(karte.dataset.reihe));
|
||||||
|
const daten = await antwort.json();
|
||||||
|
if (!antwort.ok) {
|
||||||
|
throw new Error(daten.error || "Fehler " + antwort.status);
|
||||||
|
}
|
||||||
|
karte._modell = daten.zeilen;
|
||||||
|
zeichnePreise(karte);
|
||||||
|
preisHinweis(karte, "");
|
||||||
|
} catch (fehler) {
|
||||||
|
preisHinweis(karte, fehler.message, "text-danger");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function preisKarteBinden(karte) {
|
||||||
|
karte.addEventListener("input", function (ereignis) {
|
||||||
|
const feld = ereignis.target.closest("[data-feld]");
|
||||||
|
if (!feld) return;
|
||||||
|
const zeile = karte._modell[parseInt(feld.closest(".preis-zeile").dataset.i, 10)];
|
||||||
|
if (feld.dataset.feld === "ab") {
|
||||||
|
if (!feld.value) return; // waehrend des Tippens ist das Feld kurz leer
|
||||||
|
zeile.ab = feld.value;
|
||||||
|
} else {
|
||||||
|
zeile[feld.dataset.feld] = isNaN(feld.valueAsNumber) ? 0 : feld.valueAsNumber;
|
||||||
|
}
|
||||||
|
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein geaenderter Stichtag verschiebt die Reihenfolge und damit die Enden
|
||||||
|
// aller Zeilen - aber erst, wenn das Feld fertig ist. Waehrend des Tippens
|
||||||
|
// neu zu zeichnen wuerde den Eingabefokus wegreissen.
|
||||||
|
karte.addEventListener("change", function (ereignis) {
|
||||||
|
if (ereignis.target.dataset.feld === "ab") {
|
||||||
|
zeichnePreise(karte);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
karte.addEventListener("click", function (ereignis) {
|
||||||
|
if (ereignis.target.closest(".preis-neu")) {
|
||||||
|
preisNeueZeile(karte);
|
||||||
|
} else if (ereignis.target.closest(".preis-weg")) {
|
||||||
|
const i = parseInt(ereignis.target.closest(".preis-zeile").dataset.i, 10);
|
||||||
|
karte._modell.splice(i, 1);
|
||||||
|
zeichnePreise(karte);
|
||||||
|
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
|
||||||
|
} else if (ereignis.target.closest(".preis-zurueck")) {
|
||||||
|
preiseHolen(karte);
|
||||||
|
} else if (ereignis.target.closest(".preis-speichern")) {
|
||||||
|
preiseSenden(karte);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
document.querySelectorAll(".preis-karte").forEach(function (karte) {
|
||||||
|
const daten = preisDaten[karte.dataset.reihe];
|
||||||
|
if (!daten) return;
|
||||||
|
karte._modell = sortiert(daten.zeilen);
|
||||||
|
zeichnePreise(karte);
|
||||||
|
preisKarteBinden(karte);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,344 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Modell-Schicht fuer die Preistabellen der Einstellungsseite.
|
||||||
|
*
|
||||||
|
* Eine Preistabelle ist eine Zeitreihe: ab einem Stichtag gelten neue Preise,
|
||||||
|
* bis der naechste Eintrag beginnt. Mehr steht dort auch nicht - insbesondere
|
||||||
|
* kein Enddatum. Das gab es frueher als eigene Spalte, und weil es eigenstaendig
|
||||||
|
* gepflegt wurde, konnte es etwas anderes behaupten als der naechste Stichtag:
|
||||||
|
* in gridCosts liefen zwei Zeilen ueber das erste Quartal 2023, der JOIN in
|
||||||
|
* ajax/getStats.php fand fuer jeden Messwert zwei Preise, und der
|
||||||
|
* Stromverbrauch 2023 stand mit 2527 statt 1674 kWh in der Statistik.
|
||||||
|
*
|
||||||
|
* Ohne die Spalte kann das nicht mehr passieren: zwei Zeitraeume koennen sich
|
||||||
|
* nicht ueberlappen, weil der eine endet, wo der andere beginnt, und eine
|
||||||
|
* Luecke kann es auch nicht geben. Die juengste Zeile hat kein Ende und gilt
|
||||||
|
* weiter, bis ein neuer Preis eingetragen wird - so verhaelt sich ein Tarif ja
|
||||||
|
* auch. Wo die Statistik das Ende braucht, holt sie es sich mit
|
||||||
|
* LEAD(active_date) beim naechsten Stichtag.
|
||||||
|
*
|
||||||
|
* Was bleibt: vor dem aeltesten Stichtag gibt es keinen Preis. Messwerte, die
|
||||||
|
* davor liegen, fallen aus der Statistik heraus - dagegen hilft nur hinsehen,
|
||||||
|
* siehe preisLuecke().
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once(__DIR__ . "/mysql.php");
|
||||||
|
|
||||||
|
/** Verbindung zur Messwert-Datenbank. */
|
||||||
|
function solarDb()
|
||||||
|
{
|
||||||
|
static $db = null;
|
||||||
|
if ($db === null) {
|
||||||
|
$db = new mysqli($GLOBALS["mysql_server"], $GLOBALS["mysql_solarUser"],
|
||||||
|
$GLOBALS["mysql_solarPass"], $GLOBALS["mysql_solarDB"]);
|
||||||
|
$db->set_charset("utf8mb4");
|
||||||
|
}
|
||||||
|
return $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die bearbeitbaren Preistabellen.
|
||||||
|
*
|
||||||
|
* Je Feld:
|
||||||
|
* faktor rechnet zwischen Speicher- und Anzeigeeinheit um. Gespeichert
|
||||||
|
* wird in Euro, angezeigt und eingegeben in Cent - so, wie es auf
|
||||||
|
* der Rechnung steht. 0,3163 EUR/kWh sind 31,63 ct/kWh; niemand
|
||||||
|
* tippt gern vier Nachkommastellen. Vorgabe 1, also unveraendert.
|
||||||
|
* stellen Nachkommastellen der Anzeige, zugleich die Schrittweite im Feld.
|
||||||
|
* max Obergrenze. Was darueber liegt, ist ein Tippfehler und keine
|
||||||
|
* Preiserhoehung.
|
||||||
|
*
|
||||||
|
* Nicht jedes Feld ist ein Preis. Der Energieinhalt des Gases stand frueher
|
||||||
|
* als 6.58 im SQL, die Benzinersparnis als Teiler 24.5 - beides gehoert neben
|
||||||
|
* den Preis, weil es am Brennstoff und am Fahrzeug haengt und nicht an der
|
||||||
|
* Physik. Beim Auto aber nicht als "kWh je Liter": diese Zahl steht auf keinem
|
||||||
|
* Datenblatt. Eingetragen werden die beiden Verbraeuche, die man kennt, und
|
||||||
|
* die Statistik rechnet daraus, wieviele Liter eine geladene kWh ersetzt.
|
||||||
|
*
|
||||||
|
* Kommt spaeter eine weitere Tabelle desselben Zuschnitts dazu, reicht hier
|
||||||
|
* ein Eintrag - Endpunkt, Maske und Speicherlogik bleiben unveraendert.
|
||||||
|
*/
|
||||||
|
function preisReihen()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
"strom" => [
|
||||||
|
"titel" => "Strompreise",
|
||||||
|
"hinweis" => "Arbeitspreis, Einspeisevergütung und Grundpreis, wie sie in die "
|
||||||
|
. "Jahresstatistik auf der Historienseite eingehen.",
|
||||||
|
"tabelle" => "gridCosts",
|
||||||
|
"symbol" => "bi-plug",
|
||||||
|
"felder" => [
|
||||||
|
"cost" => ["titel" => "Arbeitspreis", "einheit" => "ct/kWh",
|
||||||
|
"faktor" => 100, "stellen" => 2, "max" => 500],
|
||||||
|
"gain" => ["titel" => "Einspeisung", "einheit" => "ct/kWh",
|
||||||
|
"faktor" => 100, "stellen" => 2, "max" => 500],
|
||||||
|
"base" => ["titel" => "Grundpreis", "einheit" => "€/Jahr",
|
||||||
|
"faktor" => 1, "stellen" => 2, "max" => 5000],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
"gas" => [
|
||||||
|
"titel" => "Gaspreise",
|
||||||
|
"hinweis" => "Womit die Ersparnis der Heizung gerechnet wird: was das Gas kostet, "
|
||||||
|
. "das der Heizstab bei Sonnenüberschuss ersetzt.",
|
||||||
|
"tabelle" => "gasCosts",
|
||||||
|
"symbol" => "bi-fire",
|
||||||
|
"felder" => [
|
||||||
|
"cost" => ["titel" => "Gaspreis", "einheit" => "ct/l",
|
||||||
|
"faktor" => 100, "stellen" => 2, "max" => 1000],
|
||||||
|
"kwhPerLitre" => ["titel" => "Energieinhalt", "einheit" => "kWh/l",
|
||||||
|
"faktor" => 1, "stellen" => 2, "max" => 20],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
"sprit" => [
|
||||||
|
"titel" => "Benzinpreise",
|
||||||
|
"hinweis" => "Für „Benzin gespart“ und „Ersparnis Benzin“: der Preis an der Säule "
|
||||||
|
. "und was die beiden Autos verbrauchen — daraus ergibt sich, wieviele "
|
||||||
|
. "Liter eine geladene Kilowattstunde ersetzt.",
|
||||||
|
"tabelle" => "fuelCosts",
|
||||||
|
"symbol" => "bi-fuel-pump",
|
||||||
|
"felder" => [
|
||||||
|
"cost" => ["titel" => "Benzinpreis", "einheit" => "€/l",
|
||||||
|
"faktor" => 1, "stellen" => 3, "max" => 10],
|
||||||
|
"lPer100km" => ["titel" => "Verbrenner", "einheit" => "l/100 km",
|
||||||
|
"faktor" => 1, "stellen" => 1, "max" => 50],
|
||||||
|
"kwhPer100km" => ["titel" => "Elektro", "einheit" => "kWh/100 km",
|
||||||
|
"faktor" => 1, "stellen" => 1, "max" => 100],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beschreibung einer Reihe, oder null bei unbekanntem Schluessel. */
|
||||||
|
function preisReihe($key)
|
||||||
|
{
|
||||||
|
$reihen = preisReihen();
|
||||||
|
return isset($reihen[$key]) ? $reihen[$key] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spaltenbreiten einer Zeile im Bootstrap-Raster (zusammen zwoelf).
|
||||||
|
*
|
||||||
|
* Damit Kopfzeile und Felder aus derselben Quelle kommen: das PHP zeichnet die
|
||||||
|
* Ueberschriften, das JavaScript die Zeilen, und beide holen sich die Breiten
|
||||||
|
* hier.
|
||||||
|
*
|
||||||
|
* Datum, Ende und Papierkorb nehmen sich so wenig wie moeglich - das Ende ist
|
||||||
|
* nur noch Text, der Papierkorb ein Knopf. Was uebrig bleibt, teilen sich die
|
||||||
|
* Felder. Geht die Teilung nicht auf, bekommt der Rest das Feld mit der
|
||||||
|
* laengsten Einheit: hinter "kWh/100 km" bleiben von einer Sechstelspalte
|
||||||
|
* keine 50 Pixel fuer die Zahl, hinter "EUR/l" ist reichlich Platz.
|
||||||
|
*
|
||||||
|
* Ab vier Feldern wird es eng; die Zeile bricht dann um, was haesslicher, aber
|
||||||
|
* nicht kaputt ist.
|
||||||
|
*/
|
||||||
|
function preisSpalten($felder)
|
||||||
|
{
|
||||||
|
$felder = array_values($felder);
|
||||||
|
$anzahl = max(1, count($felder));
|
||||||
|
$rand = $anzahl >= 3 ? ["ab" => 2, "bis" => 2, "aktion" => 1]
|
||||||
|
: ["ab" => 3, "bis" => 3, "aktion" => 1];
|
||||||
|
$rest = 12 - $rand["ab"] - $rand["bis"] - $rand["aktion"];
|
||||||
|
|
||||||
|
$breiten = array_fill(0, $anzahl, max(1, intdiv($rest, $anzahl)));
|
||||||
|
$uebrig = $rest - array_sum($breiten);
|
||||||
|
if ($uebrig > 0) {
|
||||||
|
$laengen = array_map(function ($f) { return mb_strlen($f["einheit"]); }, $felder);
|
||||||
|
$breiten[array_search(max($laengen), $laengen)] += $uebrig;
|
||||||
|
}
|
||||||
|
return $rand + ["felder" => $breiten];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Zeitraeume einer Reihe, aeltester zuerst, Werte in Anzeigeeinheit.
|
||||||
|
*
|
||||||
|
* Geliefert wird nur der Stichtag; wo eine Zeile endet, ergibt sich aus der
|
||||||
|
* naechsten und rechnet die Maske selbst aus.
|
||||||
|
*/
|
||||||
|
function preiseLaden($key)
|
||||||
|
{
|
||||||
|
$reihe = preisReihe($key);
|
||||||
|
if (!$reihe) {
|
||||||
|
throw new InvalidArgumentException("Unbekannte Preistabelle.");
|
||||||
|
}
|
||||||
|
$spalten = array_keys($reihe["felder"]);
|
||||||
|
$res = solarDb()->query("SELECT active_date, " . implode(", ", $spalten)
|
||||||
|
. " FROM " . $reihe["tabelle"] . " ORDER BY active_date");
|
||||||
|
if (!$res) {
|
||||||
|
throw new RuntimeException(solarDb()->error);
|
||||||
|
}
|
||||||
|
$zeilen = [];
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$zeile = ["ab" => $row["active_date"]];
|
||||||
|
foreach ($spalten as $spalte) {
|
||||||
|
$feld = $reihe["felder"][$spalte];
|
||||||
|
$zeile[$spalte] = round(floatval($row[$spalte]) * $feld["faktor"], $feld["stellen"]);
|
||||||
|
}
|
||||||
|
$zeilen[] = $zeile;
|
||||||
|
}
|
||||||
|
return $zeilen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Datum in der Form JJJJ-MM-TT, sonst null. */
|
||||||
|
function preisDatum($wert)
|
||||||
|
{
|
||||||
|
if (!is_string($wert) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$datum = DateTimeImmutable::createFromFormat("!Y-m-d", $wert);
|
||||||
|
// createFromFormat schluckt den 31. Februar und macht den 3. Maerz daraus;
|
||||||
|
// der Rueckvergleich faengt das ab.
|
||||||
|
return ($datum && $datum->format("Y-m-d") === $wert) ? $datum : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tag der aeltesten Messung in EnergyFlow, als "JJJJ-MM-TT". */
|
||||||
|
function ersteMessung()
|
||||||
|
{
|
||||||
|
static $tag = null;
|
||||||
|
if ($tag === null) {
|
||||||
|
$res = solarDb()->query("SELECT MIN(datetime) FROM EnergyFlow");
|
||||||
|
$zeile = $res ? $res->fetch_row() : null;
|
||||||
|
$tag = ($zeile && $zeile[0]) ? substr($zeile[0], 0, 10) : "";
|
||||||
|
}
|
||||||
|
return $tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beginnt die Reihe zu spaet? Dann fehlt den aeltesten Messwerten der Preis.
|
||||||
|
*
|
||||||
|
* Das ist die einzige Luecke, die es noch geben kann - alle anderen hat das
|
||||||
|
* Weglassen der Spalte end_date erledigt. Auftreten kann sie, wenn jemand den
|
||||||
|
* aeltesten Zeitraum loescht.
|
||||||
|
*/
|
||||||
|
function preisLuecke($zeilen)
|
||||||
|
{
|
||||||
|
$erste = ersteMessung();
|
||||||
|
if (!$erste || !$zeilen) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
$stichtag = $zeilen[0]["ab"];
|
||||||
|
return $stichtag > $erste ? $erste : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine ganze Reihe schreiben: pruefen und ersetzen.
|
||||||
|
*
|
||||||
|
* Ersetzt statt abzugleichen. Die Tabellen haben eine Handvoll Zeilen und
|
||||||
|
* keinen kuenstlichen Schluessel - der Stichtag selbst ist der
|
||||||
|
* Primaerschluessel, und beim Verschieben eines Stichtags waere ein Abgleich
|
||||||
|
* mehr Buchhaltung als Gewinn. Alles laeuft in einer Transaktion, es gibt also
|
||||||
|
* keinen Moment, in dem die Statistik auf eine halbe Tabelle blickt.
|
||||||
|
*/
|
||||||
|
function preiseSpeichern($key, $zeilen)
|
||||||
|
{
|
||||||
|
$reihe = preisReihe($key);
|
||||||
|
if (!$reihe) {
|
||||||
|
throw new InvalidArgumentException("Unbekannte Preistabelle.");
|
||||||
|
}
|
||||||
|
if (!is_array($zeilen) || !$zeilen) {
|
||||||
|
throw new InvalidArgumentException("Es muss mindestens ein Zeitraum bleiben.");
|
||||||
|
}
|
||||||
|
$spalten = array_keys($reihe["felder"]);
|
||||||
|
|
||||||
|
// --- Pruefen --------------------------------------------------------
|
||||||
|
$geprueft = [];
|
||||||
|
foreach ($zeilen as $zeile) {
|
||||||
|
$ab = preisDatum($zeile["ab"] ?? "");
|
||||||
|
if (!$ab) {
|
||||||
|
throw new InvalidArgumentException("Ein Zeitraum hat kein gültiges Startdatum.");
|
||||||
|
}
|
||||||
|
$satz = ["ab" => $ab];
|
||||||
|
foreach ($spalten as $spalte) {
|
||||||
|
$feld = $reihe["felder"][$spalte];
|
||||||
|
$wert = $zeile[$spalte] ?? null;
|
||||||
|
if (!is_numeric($wert)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
$feld["titel"] . " ab " . $ab->format("d.m.Y") . " ist keine Zahl.");
|
||||||
|
}
|
||||||
|
$wert = floatval($wert);
|
||||||
|
if ($wert < 0 || $wert > $feld["max"]) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
$feld["titel"] . " ab " . $ab->format("d.m.Y")
|
||||||
|
. " liegt außerhalb des Erwartbaren (0 bis " . $feld["max"] . " "
|
||||||
|
. $feld["einheit"] . ").");
|
||||||
|
}
|
||||||
|
$satz[$spalte] = $wert / $feld["faktor"];
|
||||||
|
}
|
||||||
|
$geprueft[] = $satz;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach Stichtag sortieren; zwei gleiche Stichtage kann die Tabelle nicht
|
||||||
|
// halten (Primaerschluessel) und die Statistik nicht deuten.
|
||||||
|
usort($geprueft, function ($a, $b) { return $a["ab"] <=> $b["ab"]; });
|
||||||
|
for ($i = 1; $i < count($geprueft); $i++) {
|
||||||
|
if ($geprueft[$i]["ab"] == $geprueft[$i - 1]["ab"]) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Zwei Zeiträume beginnen am " . $geprueft[$i]["ab"]->format("d.m.Y") . ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Schreiben ------------------------------------------------------
|
||||||
|
$db = solarDb();
|
||||||
|
$db->begin_transaction();
|
||||||
|
try {
|
||||||
|
if (!$db->query("DELETE FROM " . $reihe["tabelle"])) {
|
||||||
|
throw new RuntimeException($db->error);
|
||||||
|
}
|
||||||
|
$platzhalter = implode(", ", array_fill(0, count($spalten) + 1, "?"));
|
||||||
|
$stmt = $db->prepare("INSERT INTO " . $reihe["tabelle"]
|
||||||
|
. " (active_date, " . implode(", ", $spalten) . ")"
|
||||||
|
. " VALUES (" . $platzhalter . ")");
|
||||||
|
if (!$stmt) {
|
||||||
|
throw new RuntimeException($db->error);
|
||||||
|
}
|
||||||
|
foreach ($geprueft as $satz) {
|
||||||
|
$werte = [$satz["ab"]->format("Y-m-d")];
|
||||||
|
foreach ($spalten as $spalte) {
|
||||||
|
$werte[] = $satz[$spalte];
|
||||||
|
}
|
||||||
|
$stmt->bind_param("s" . str_repeat("d", count($spalten)), ...$werte);
|
||||||
|
if (!$stmt->execute()) {
|
||||||
|
throw new RuntimeException($stmt->error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$db->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$db->rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
return count($geprueft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grundpreis Strom fuer ein Kalenderjahr, in Euro.
|
||||||
|
*
|
||||||
|
* Der Grundpreis ist der einzige Posten, der nicht an einem Messwert haengt -
|
||||||
|
* er faellt auch an, wenn nichts verbraucht wird. Deshalb steht er nicht im
|
||||||
|
* grossen SUM-Ausdruck der Statistik, sondern wird hier eigens gerechnet:
|
||||||
|
* jeder Zeitraum steuert seinen Anteil an den Tagen des Jahres bei. Das ist
|
||||||
|
* unabhaengig davon, wieviele Messwerte tatsaechlich aufgezeichnet wurden -
|
||||||
|
* 2022 hat merklich Luecken, und ein fehlender Nachmittag darf den Grundpreis
|
||||||
|
* nicht senken.
|
||||||
|
*
|
||||||
|
* LEAD() holt den naechsten Stichtag; einen Tag davor endet der Zeitraum. Die
|
||||||
|
* juengste Zeile hat keinen Nachfolger und laeuft bis zum Jahresende weiter.
|
||||||
|
*/
|
||||||
|
function grundpreisImJahr($jahr)
|
||||||
|
{
|
||||||
|
$jahr = intval($jahr);
|
||||||
|
$von = $jahr . "-01-01";
|
||||||
|
$bis = $jahr . "-12-31";
|
||||||
|
$tage = intval((new DateTimeImmutable($bis))->format("z")) + 1; // 365 oder 366
|
||||||
|
|
||||||
|
$stmt = solarDb()->prepare(
|
||||||
|
"WITH p AS (SELECT active_date, base,
|
||||||
|
LEAD(active_date) OVER (ORDER BY active_date) AS folgt
|
||||||
|
FROM gridCosts)
|
||||||
|
SELECT SUM(base * (DATEDIFF(LEAST(IFNULL(DATE_SUB(folgt, INTERVAL 1 DAY), ?), ?),
|
||||||
|
GREATEST(active_date, ?)) + 1) / ?)
|
||||||
|
FROM p
|
||||||
|
WHERE active_date <= ? AND (folgt IS NULL OR folgt > ?)");
|
||||||
|
$stmt->bind_param("sssiss", $bis, $bis, $von, $tage, $bis, $von);
|
||||||
|
$stmt->execute();
|
||||||
|
$wert = $stmt->get_result()->fetch_row()[0];
|
||||||
|
return $wert === null ? 0.0 : floatval($wert);
|
||||||
|
}
|
||||||
@@ -62,6 +62,11 @@
|
|||||||
<?php if ($page["charts"]): ?>
|
<?php if ($page["charts"]): ?>
|
||||||
<?php include __DIR__ . "/charts.html"; ?>
|
<?php include __DIR__ . "/charts.html"; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if ($page["leaflet"]): ?>
|
||||||
|
<!-- Leaflet 1.9.4, BSD 2-Clause. Liegt wie alle anderen Bibliotheken lokal;
|
||||||
|
die Kacheln kommen ueber ajax/tile.php aus dem eigenen Zwischenspeicher. -->
|
||||||
|
<script src="js/leaflet.js"></script>
|
||||||
|
<?php endif; ?>
|
||||||
<script src="js/solar/common.js?v=<?= filemtime("js/solar/common.js") ?>"></script>
|
<script src="js/solar/common.js?v=<?= filemtime("js/solar/common.js") ?>"></script>
|
||||||
<?php foreach ($page["scripts"] as $script): ?>
|
<?php foreach ($page["scripts"] as $script): ?>
|
||||||
<script src="<?= $script ?>?v=<?= filemtime($script) ?>"></script>
|
<script src="<?= $script ?>?v=<?= filemtime($script) ?>"></script>
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ if (!isset($_GET["floor"]))
|
|||||||
<link rel="stylesheet" href="./css/adminlte.min.css?v=2" />
|
<link rel="stylesheet" href="./css/adminlte.min.css?v=2" />
|
||||||
<!--end::Required Plugin(AdminLTE)-->
|
<!--end::Required Plugin(AdminLTE)-->
|
||||||
|
|
||||||
|
<!--begin::Karte (Leaflet) - nur auf Seiten mit Karte-->
|
||||||
|
<?php if ($page["leaflet"]): ?>
|
||||||
|
<link rel="stylesheet" href="css/leaflet.css" />
|
||||||
|
<?php endif; ?>
|
||||||
|
<!--end::Karte-->
|
||||||
|
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<!--end::Head-->
|
<!--end::Head-->
|
||||||
@@ -142,6 +148,12 @@ if (!isset($_GET["floor"]))
|
|||||||
<p>Wetter</p>
|
<p>Wetter</p>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="?action=skoda" class="nav-link <?php if($_GET["action"]=="skoda"){echo " active";} ?>">
|
||||||
|
<i class="nav-icon bi bi-ev-front"></i>
|
||||||
|
<p>Škoda</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item menu-open">
|
<li class="nav-item menu-open">
|
||||||
@@ -170,6 +182,14 @@ if (!isset($_GET["floor"]))
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
<!-- Einstellungen stehen fuer sich: kein Dashboard,
|
||||||
|
sondern die Parameter dahinter. -->
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="?action=settings" class="nav-link<?php if($_GET["action"]=="settings"){echo " active";} ?>">
|
||||||
|
<i class="nav-icon bi bi-gear"></i>
|
||||||
|
<p>Einstellungen</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<!--end::Sidebar Menu-->
|
<!--end::Sidebar Menu-->
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ $statistics = [
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-cols-1 row-cols-md-1 g-4 p-2 pb-0" id="<?= $id ?>">
|
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="<?= $id ?>">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- /.card -->
|
<!-- /.card -->
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Einstellungsseite: Parameter, die von Hand gepflegt werden.
|
||||||
|
*
|
||||||
|
* Bisher sind das die Preistabellen aus restricted/costs.php. Die Seite ist
|
||||||
|
* als Sammlung von Karten angelegt - eine Karte je Thema. Kommt spaeter ein
|
||||||
|
* weiterer Parameter dazu, kommt er als eigene Karte hinzu, ohne dass die
|
||||||
|
* vorhandenen etwas davon merken.
|
||||||
|
*
|
||||||
|
* Die Zeilen zeichnet js/solar/settings.js aus dem Modell, das hier
|
||||||
|
* eingebettet wird - dieselbe Arbeitsteilung wie im Automatik-Editor. Sonst
|
||||||
|
* gaebe es die erste Zeile als PHP und jede weitere als JavaScript, und die
|
||||||
|
* beiden Fassungen liefen frueher oder spaeter auseinander. Aus demselben
|
||||||
|
* Grund kommen die Spaltenbreiten aus preisSpalten(): die Kopfzeile hier und
|
||||||
|
* die Felder dort muessen dieselben sein.
|
||||||
|
*/
|
||||||
|
require_once(__DIR__ . "/costs.php");
|
||||||
|
|
||||||
|
$reihen = preisReihen();
|
||||||
|
$daten = [];
|
||||||
|
$fehler = "";
|
||||||
|
try {
|
||||||
|
foreach ($reihen as $key => $reihe) {
|
||||||
|
$daten[$key] = [
|
||||||
|
"felder" => $reihe["felder"],
|
||||||
|
"spalten" => preisSpalten($reihe["felder"]),
|
||||||
|
"zeilen" => preiseLaden($key),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$fehler = $e->getMessage();
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!--begin::App Main-->
|
||||||
|
<main class="app-main">
|
||||||
|
<!--begin::App Content Header-->
|
||||||
|
<div class="app-content-header">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<h3 class="mb-0">Einstellungen</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!--end::App Content Header-->
|
||||||
|
<!--begin::App Content-->
|
||||||
|
<div class="app-content">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<?php if ($fehler): ?>
|
||||||
|
<div class="alert alert-danger">Die Einstellungen sind nicht erreichbar: <?= htmlspecialchars($fehler) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach ($reihen as $key => $reihe): ?>
|
||||||
|
<?php $breiten = preisSpalten($reihe["felder"]); ?>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-xxl-9">
|
||||||
|
<div class="card mb-4 preis-karte" data-reihe="<?= htmlspecialchars($key) ?>">
|
||||||
|
<div class="card-header pb-2 pt-2">
|
||||||
|
<h3 class="card-title"><i class="bi <?= $reihe["symbol"] ?> me-2"></i><?= htmlspecialchars($reihe["titel"]) ?></h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<button type="button" class="btn btn-tool preis-neu" title="Zeitraum hinzufügen">
|
||||||
|
<i class="bi bi-plus-lg"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-tool preis-zurueck" title="Änderungen verwerfen">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body pb-2">
|
||||||
|
<p class="text-secondary small mb-3"><?= htmlspecialchars($reihe["hinweis"]) ?></p>
|
||||||
|
|
||||||
|
<!-- Spaltenköpfe nur auf breiten Schirmen; auf dem Handy trägt
|
||||||
|
jedes Feld seine eigene Beschriftung. -->
|
||||||
|
<div class="row g-2 d-none d-xl-flex preis-kopf text-secondary small fw-semibold">
|
||||||
|
<div class="col-xl-<?= $breiten["ab"] ?>">Gültig ab</div>
|
||||||
|
<div class="col-xl-<?= $breiten["bis"] ?>">Gültig bis</div>
|
||||||
|
<?php foreach (array_values($reihe["felder"]) as $i => $feld): ?>
|
||||||
|
<div class="col-xl-<?= $breiten["felder"][$i] ?>"><?= htmlspecialchars($feld["titel"]) ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<div class="col-xl-<?= $breiten["aktion"] ?>"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preis-zeilen"></div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary mt-3 preis-neu">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Zeitraum hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex align-items-center gap-3">
|
||||||
|
<button type="button" class="btn btn-primary preis-speichern">Speichern</button>
|
||||||
|
<span class="preis-hinweis small"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!--end::App Content-->
|
||||||
|
</main>
|
||||||
|
<!--end::App Main-->
|
||||||
|
<script>
|
||||||
|
// Ausgangsstand der Preistabellen. Werte in der Anzeigeeinheit (ct statt
|
||||||
|
// Euro); umgerechnet wird in restricted/costs.php - an genau einer Stelle.
|
||||||
|
const preisDaten = <?= json_encode($daten, JSON_UNESCAPED_UNICODE) ?>;
|
||||||
|
// Aelteste Messung in EnergyFlow. Beginnt eine Preisreihe spaeter, fehlt
|
||||||
|
// den Werten davor der Preis - dann warnt die Maske.
|
||||||
|
const ersteMessung = <?= json_encode(ersteMessung()) ?>;
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Škoda-Dashboard.
|
||||||
|
*
|
||||||
|
* Zwei Datenwege, bewusst getrennt:
|
||||||
|
*
|
||||||
|
* MQTT Ladestand, Verriegelung, Reichweite, Ladezustand und
|
||||||
|
* Wallbox-Leistung kommen laufend über solarManager/# und
|
||||||
|
* aktualisieren die Anzeige sofort.
|
||||||
|
*
|
||||||
|
* ajax/skoda.php Alles, was nur in der Tabelle steht - Türen, Fenster,
|
||||||
|
* Licht, Kilometerstand, Position, Klima - sowie sämtliche
|
||||||
|
* Auswertungen. Wird beim Laden und danach minütlich geholt.
|
||||||
|
*
|
||||||
|
* Solange kein Fahrzeug angebunden ist, bleibt die Tabelle leer; jeder
|
||||||
|
* Abschnitt zeigt dann seinen Leerzustand statt eines Fehlers.
|
||||||
|
*
|
||||||
|
* Die Fahrzeuggrafik ist eine maßstäbliche Draufsicht des Enyaq:
|
||||||
|
* 4648 x 1877 mm, Radstand 2765 mm, daraus das Seitenverhältnis 2,48:1 und
|
||||||
|
* die Lage der Achsen. Kein Foto, sondern ein Schema - die Teile sollen
|
||||||
|
* ihren Zustand zeigen, nicht das Auto porträtieren.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<!--begin::App Main-->
|
||||||
|
<main class="app-main">
|
||||||
|
<div class="app-content-header">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<h3 class="mb-0">Škoda</h3>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4 text-sm-end">
|
||||||
|
<span class="skoda-alter text-secondary" id="skodaAlter"> </span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-content">
|
||||||
|
<div class="container-fluid">
|
||||||
|
|
||||||
|
<!-- ================================================ Zustand ===== -->
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<!-- Das Fahrzeug als Anzeigeelement. Jedes Teil trägt seinen Zustand
|
||||||
|
selbst: offene Türen und Klappen leuchten bernstein, Licht gelb,
|
||||||
|
der Ladeanschluss pulst beim Laden. Die Zeile darunter benennt
|
||||||
|
dasselbe in Worten und hebt beim Überfahren das zugehörige Teil
|
||||||
|
hervor - Farbe allein soll die Aussage nicht tragen. -->
|
||||||
|
<div class="col-xxl-4 col-lg-5 d-flex align-items-stretch">
|
||||||
|
<div class="card mb-4 p-0 w-100">
|
||||||
|
<div class="card-header pb-0 pt-1">
|
||||||
|
<h3 class="card-title">Fahrzeug</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<span class="badge text-bg-secondary" id="skodaSchloss">–</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body d-flex flex-column align-items-center pt-2">
|
||||||
|
|
||||||
|
<svg id="skodaAuto" class="skoda-auto" viewBox="0 0 224 500"
|
||||||
|
role="img" aria-label="Draufsicht des Fahrzeugs mit dem Zustand von Türen, Fenstern, Klappen und Licht">
|
||||||
|
|
||||||
|
<!-- Räder. Vorderachse bei 860 mm, Hinterachse bei 3625 mm
|
||||||
|
ab Bugspitze - der Radstand von 2765 mm maßstäblich. -->
|
||||||
|
<g class="rad">
|
||||||
|
<rect x="5" y="68" width="21" height="58" rx="9"/>
|
||||||
|
<rect x="198" y="68" width="21" height="58" rx="9"/>
|
||||||
|
<rect x="5" y="362" width="21" height="58" rx="9"/>
|
||||||
|
<rect x="198" y="362" width="21" height="58" rx="9"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Karosserie -->
|
||||||
|
<path class="karosserie" d="M112 6
|
||||||
|
C150 6, 198 16, 204 50 C209 90, 212 160, 212 250
|
||||||
|
C212 340, 209 410, 204 450 C198 484, 150 494, 112 494
|
||||||
|
C74 494, 26 484, 20 450 C15 410, 12 340, 12 250
|
||||||
|
C12 160, 15 90, 20 50 C26 16, 74 6, 112 6 Z"/>
|
||||||
|
|
||||||
|
<!-- Tech-Deck Face: die glänzend schwarze Blende über die
|
||||||
|
volle Bugbreite, die den Enyaq seit der Überarbeitung
|
||||||
|
kennzeichnet. Von oben sieht man ihre Oberkante. -->
|
||||||
|
<path class="techdeck" d="M112 7
|
||||||
|
C150 7, 197 17, 203 49 L194 53 C187 27, 148 18, 112 18
|
||||||
|
C76 18, 37 27, 30 53 L21 49 C27 17, 74 7, 112 7 Z"/>
|
||||||
|
|
||||||
|
<!-- Scheinwerfer, vier Elemente je Seite wie beim Original,
|
||||||
|
dazu die Rückleuchten als durchgehendes Band. -->
|
||||||
|
<g id="teil-lights" class="teil teil-licht" data-teil="lights">
|
||||||
|
<rect x="36" y="30" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="48" y="27" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="60" y="25" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="72" y="24" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="143" y="24" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="155" y="25" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="167" y="27" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="179" y="30" width="9" height="13" rx="4"/>
|
||||||
|
<rect class="rueck" x="40" y="462" width="52" height="13" rx="6"/>
|
||||||
|
<rect class="rueck" x="132" y="462" width="52" height="13" rx="6"/>
|
||||||
|
<title>Beleuchtung</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Motorhaube mit angedeuteter Mittelsicke -->
|
||||||
|
<g id="teil-bonnet" class="teil" tabindex="0" data-teil="bonnet">
|
||||||
|
<path d="M31 152 L193 152 L189 62 C169 40, 55 40, 35 62 Z"/>
|
||||||
|
<title>Motorhaube</title>
|
||||||
|
</g>
|
||||||
|
<path class="sicke" d="M112 60 L112 150"/>
|
||||||
|
|
||||||
|
<!-- Windschutzscheibe, Dach, Heckscheibe -->
|
||||||
|
<path class="scheibe" d="M34 156 L190 156 L178 200 L46 200 Z"/>
|
||||||
|
<rect class="dach" x="46" y="200" width="132" height="136" rx="10"/>
|
||||||
|
|
||||||
|
<!-- Panoramaglasdach: beim Enyaq nahezu die ganze Dachfläche -->
|
||||||
|
<g id="teil-sunroof" class="teil" tabindex="0" data-teil="sunroof">
|
||||||
|
<rect x="62" y="206" width="100" height="120" rx="8"/>
|
||||||
|
<title>Panoramadach</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Dachreling -->
|
||||||
|
<g class="reling">
|
||||||
|
<rect x="48" y="204" width="9" height="134" rx="4"/>
|
||||||
|
<rect x="167" y="204" width="9" height="134" rx="4"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<path class="scheibe" d="M46 336 L178 336 L190 382 L34 382 Z"/>
|
||||||
|
<rect class="spoiler" x="52" y="330" width="120" height="9" rx="4"/>
|
||||||
|
|
||||||
|
<!-- Heckklappe -->
|
||||||
|
<g id="teil-trunk" class="teil" tabindex="0" data-teil="trunk">
|
||||||
|
<path d="M34 384 L190 384 L186 458 C168 478, 56 478, 38 458 Z"/>
|
||||||
|
<title>Heckklappe</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Türen, jeweils mit Seitenfenster darin -->
|
||||||
|
<g id="teil-doors" class="teil" data-teil="doors">
|
||||||
|
<g tabindex="0"><rect x="12" y="202" width="23" height="74" rx="6"/><title>Tür vorn links</title></g>
|
||||||
|
<g tabindex="0"><rect x="12" y="278" width="23" height="66" rx="6"/><title>Tür hinten links</title></g>
|
||||||
|
<g tabindex="0"><rect x="189" y="202" width="23" height="74" rx="6"/><title>Tür vorn rechts</title></g>
|
||||||
|
<g tabindex="0"><rect x="189" y="278" width="23" height="66" rx="6"/><title>Tür hinten rechts</title></g>
|
||||||
|
</g>
|
||||||
|
<g id="teil-windows" class="teil teil-fenster" data-teil="windows">
|
||||||
|
<rect x="17" y="210" width="13" height="58" rx="4"/>
|
||||||
|
<rect x="17" y="286" width="13" height="50" rx="4"/>
|
||||||
|
<rect x="194" y="210" width="13" height="58" rx="4"/>
|
||||||
|
<rect x="194" y="286" width="13" height="50" rx="4"/>
|
||||||
|
<title>Seitenfenster</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Außenspiegel -->
|
||||||
|
<g class="spiegel">
|
||||||
|
<rect x="1" y="194" width="16" height="10" rx="5"/>
|
||||||
|
<rect x="207" y="194" width="16" height="10" rx="5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
Steuerung: die beiden anklickbaren Stellen.
|
||||||
|
|
||||||
|
Bedient wird das, worum es geht - der Ladeanschluss für
|
||||||
|
das Laden, der Innenraum für Klima und Lüftung. Beide
|
||||||
|
tragen einen Ring als Hinweis darauf, dass sie mehr sind
|
||||||
|
als Anzeige, und öffnen ein Modal mit den Befehlen.
|
||||||
|
|
||||||
|
Sie stehen bewusst NICHT in der Zustandszeile darunter:
|
||||||
|
die zeigt, was ist. Diese beiden ändern etwas.
|
||||||
|
============================================================ -->
|
||||||
|
|
||||||
|
<!-- Ladeanschluss. Beim Enyaq sitzt die Klappe am hinteren
|
||||||
|
rechten Kotflügel; sie pulst, solange geladen wird. -->
|
||||||
|
<g id="teil-plug" class="teil teil-stecker steuer" tabindex="0"
|
||||||
|
role="button" data-steuer="laden" aria-label="Laden steuern">
|
||||||
|
<circle class="ring" cx="203" cy="408" r="17"/>
|
||||||
|
<circle cx="203" cy="408" r="11"/>
|
||||||
|
<path d="M198 402 l6 5 -4 0 4 6"/>
|
||||||
|
<title>Laden steuern</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Innenraum, auf der Windschutzscheibe: Klima und Lüftung. -->
|
||||||
|
<g id="steuer-klima" class="steuer" tabindex="0"
|
||||||
|
role="button" data-steuer="klima" aria-label="Klima und Lüftung steuern">
|
||||||
|
<circle class="ring" cx="112" cy="178" r="19"/>
|
||||||
|
<circle class="flaeche" cx="112" cy="178" r="13"/>
|
||||||
|
<path class="glyphe" d="M112 170 v10 M108 174 h8 M105 185 q7 -4 14 0"/>
|
||||||
|
<title>Klima und Lüftung steuern</title>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="skoda-teile" id="skodaTeile"></div>
|
||||||
|
<p class="skoda-hinweis">
|
||||||
|
Ladeanschluss und Innenraum antippen, um zu steuern
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Batterie, Reichweite, Ladung -->
|
||||||
|
<div class="col-xxl-8 col-lg-7 d-flex align-items-stretch">
|
||||||
|
<div class="card mb-4 p-0 w-100">
|
||||||
|
<div class="card-header pb-0 pt-1">
|
||||||
|
<h3 class="card-title">Batterie und Ladung</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<span class="badge text-bg-secondary" id="skodaLadeZustand">–</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row align-items-center g-3">
|
||||||
|
|
||||||
|
<!-- Ladestandsring. Der Strich auf dem Ring ist das
|
||||||
|
eingestellte Ladeziel - eine Ladung, die dort endet, ist
|
||||||
|
kein Fehler, sondern Absicht. -->
|
||||||
|
<div class="col-md-5 text-center">
|
||||||
|
<svg class="skoda-ring" viewBox="0 0 200 200" role="img" aria-labelledby="skodaSocText">
|
||||||
|
<circle class="ring-bahn" cx="100" cy="100" r="82"/>
|
||||||
|
<circle class="ring-wert" id="skodaRing" cx="100" cy="100" r="82"/>
|
||||||
|
<line class="ring-ziel" id="skodaZiel" x1="100" y1="8" x2="100" y2="26"/>
|
||||||
|
<text id="skodaSocText" class="ring-zahl" x="100" y="98">–</text>
|
||||||
|
<text class="ring-einheit" x="100" y="126">Prozent</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="skoda-werte">
|
||||||
|
<div class="skoda-wert"><span class="k">Reichweite</span><span class="v" id="skodaReichweite">–</span></div>
|
||||||
|
<div class="skoda-wert"><span class="k">Ladeleistung</span><span class="v" id="skodaLeistung">–</span></div>
|
||||||
|
<div class="skoda-wert"><span class="k">Restzeit</span><span class="v" id="skodaRestzeit">–</span></div>
|
||||||
|
<div class="skoda-wert"><span class="k">Ladeziel</span><span class="v" id="skodaZielText">–</span></div>
|
||||||
|
<div class="skoda-wert"><span class="k">Kilometerstand</span><span class="v" id="skodaKm">–</span></div>
|
||||||
|
<div class="skoda-wert"><span class="k">Klima</span><span class="v" id="skodaKlima">–</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Woher der Strom gerade kommt: der Balken teilt die
|
||||||
|
Ladeleistung in den Anteil, den die PV deckt, und den
|
||||||
|
Rest aus dem Netz. -->
|
||||||
|
<div class="skoda-quelle mt-3" id="skodaQuelleBox">
|
||||||
|
<div class="d-flex justify-content-between small text-secondary mb-1">
|
||||||
|
<span>Ladestrom</span><span id="skodaQuelleText">–</span>
|
||||||
|
</div>
|
||||||
|
<div class="skoda-balken">
|
||||||
|
<div class="teil-pv" id="skodaAnteilPV"></div>
|
||||||
|
<div class="teil-netz" id="skodaAnteilNetz"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================================================= Ladungen ==== -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-8 d-flex align-items-stretch">
|
||||||
|
<div class="card mb-4 p-0 w-100">
|
||||||
|
<div class="card-header pb-0 pt-1">
|
||||||
|
<h3 class="card-title">Verlauf der letzten Ladung</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<span class="text-secondary small" id="skodaKurveZeit"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body pb-1">
|
||||||
|
<div class="chart-container"><canvas id="skodaKurve"></canvas></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-4 d-flex align-items-stretch">
|
||||||
|
<div class="card mb-4 p-0 w-100">
|
||||||
|
<div class="card-header pb-0 pt-1">
|
||||||
|
<h3 class="card-title">Ladungen</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="skoda-liste" id="skodaLadungen"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================== Akkugesundheit == -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-8 d-flex align-items-stretch">
|
||||||
|
<div class="card mb-4 p-0 w-100">
|
||||||
|
<div class="card-header pb-0 pt-1">
|
||||||
|
<h3 class="card-title">Akkugesundheit</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<span class="text-secondary small">kWh je 100 Ladestandsprozent</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body pb-1">
|
||||||
|
<div class="chart-container"><canvas id="skodaGesundheit"></canvas></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-4 d-flex align-items-stretch">
|
||||||
|
<div class="card mb-4 p-0 w-100">
|
||||||
|
<div class="card-header pb-0 pt-1">
|
||||||
|
<h3 class="card-title">Befund</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="skoda-befund" id="skodaBefund">
|
||||||
|
<p class="text-secondary mb-0">Noch keine ausgewerteten Ladungen.</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-secondary small mb-0 mt-3">
|
||||||
|
Gemessen wird am Wallbox-Zähler, die Ladeverluste sind also
|
||||||
|
enthalten. Absolut ist der Wert eine Näherung — aussagekräftig
|
||||||
|
ist sein Verlauf, denn ein alternder Akku bräuchte <em>weniger</em>
|
||||||
|
Kilowattstunden für dieselben hundert Prozentpunkte.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================ Standort ========== -->
|
||||||
|
<!-- Karte auf Leaflet. Die Kacheln kommen nicht unmittelbar von
|
||||||
|
OpenStreetMap, sondern über ajax/tile.php aus dem eigenen
|
||||||
|
Zwischenspeicher auf dem NAS - siehe dort, warum.
|
||||||
|
|
||||||
|
Die Fahrtlinien sind gestrichelt, und zwar mit Absicht: die API
|
||||||
|
liefert nur die letzte bekannte PARKposition, also Start und Ziel.
|
||||||
|
Die tatsächlich gefahrene Strecke kennt niemand, und eine
|
||||||
|
durchgezogene Linie auf einer Straßenkarte würde genau das
|
||||||
|
behaupten. -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-7 d-flex align-items-stretch">
|
||||||
|
<div class="card mb-4 p-0 w-100">
|
||||||
|
<div class="card-header pb-0 pt-1">
|
||||||
|
<h3 class="card-title">Standort und Fahrten</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<button type="button" id="skodaKarteHeim" class="btn btn-tool"
|
||||||
|
title="Auf den aktuellen Standort zentrieren">
|
||||||
|
<i class="bi bi-geo-alt"></i>
|
||||||
|
</button>
|
||||||
|
<a href="#" id="skodaOsmLink" class="btn btn-tool" target="_blank"
|
||||||
|
rel="noopener noreferrer" title="Auf OpenStreetMap öffnen">
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div id="skodaKarte" class="skoda-karte"></div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center px-2 pt-2 pb-1">
|
||||||
|
<span class="text-secondary small" id="skodaStandort">–</span>
|
||||||
|
<span class="skoda-kartenlegende">
|
||||||
|
<span><i class="pkt jetzt"></i>jetzt</span>
|
||||||
|
<span><i class="pkt halt"></i>Halt</span>
|
||||||
|
<span><i class="lin"></i>Luftlinie</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-5 d-flex align-items-stretch">
|
||||||
|
<!-- Die Klasse zeigt-kwh / zeigt-prozent auf dieser Kachel
|
||||||
|
entscheidet, welche Einheit die Zeilen anzeigen. Beide Werte
|
||||||
|
stehen im Markup, umgeschaltet wird allein per CSS - so muss
|
||||||
|
beim Wechsel nichts neu gebaut werden, und die Auswahl
|
||||||
|
übersteht auch die minütliche Auffrischung der Liste. -->
|
||||||
|
<div class="card mb-4 p-0 w-100 zeigt-kwh" id="skodaFahrtenKachel">
|
||||||
|
<div class="card-header pb-0 pt-1">
|
||||||
|
<h3 class="card-title">Fahrten</h3>
|
||||||
|
<div class="card-tools d-flex align-items-center gap-2">
|
||||||
|
<!-- Text kommt aus zeigeVerbrauchsbasis(): der Verbrauch in
|
||||||
|
Kilowattstunden ist nicht gemessen, sondern aus dem
|
||||||
|
Ladestand und der eigenen Ladehistorie gerechnet. Womit
|
||||||
|
genau, steht im Titel dieses Elements. -->
|
||||||
|
<span class="text-secondary small" id="skodaVerbrauchBasis"></span>
|
||||||
|
<span class="skoda-umschalter" id="skodaEinheit" role="group"
|
||||||
|
aria-label="Einheit des Verbrauchs">
|
||||||
|
<button type="button" data-einheit="kwh" aria-pressed="true">kWh</button>
|
||||||
|
<button type="button" data-einheit="prozent" aria-pressed="false">%</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="skoda-liste" id="skodaFahrten"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<!--end::App Main-->
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-cols-1 row-cols-md-1 g-4 p-2 pb-0" id="Stats-Year">
|
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="Stats-Year">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- Preistabellen der Statistik: feste Zahlen rein, Redundanz raus.
|
||||||
|
--
|
||||||
|
-- 1) gasCosts und fuelCosts lagen unbenutzt herum; ajax/getStats.php rechnete
|
||||||
|
-- stattdessen mit drei fest eingetragenen Zahlen:
|
||||||
|
--
|
||||||
|
-- 140 Grundpreis Strom pro Jahr -> gridCosts.base
|
||||||
|
-- 0.063 Gaspreis je kWh -> gasCosts.cost / gasCosts.kwhPerLitre
|
||||||
|
-- 24.5 12 * kWh je Liter Benzin -> fuelCosts.lPer100km / kwhPer100km
|
||||||
|
--
|
||||||
|
-- Beim Gas stand der Energieinhalt bisher nur als Kommentar an der Spalte
|
||||||
|
-- ("6.58kWh/l"), beim Benzin steckte er unsichtbar im Teiler 24.5. Beides
|
||||||
|
-- ist keine Physik, die sich nie aendert, sondern haengt am Brennstoff und
|
||||||
|
-- am Ersatzfahrzeug - und gehoert deshalb neben den Preis.
|
||||||
|
--
|
||||||
|
-- Beim Auto nicht als "kWh je Liter": diese Zahl steht auf keinem Datenblatt
|
||||||
|
-- und niemand hat sie im Kopf. Eingetragen werden die beiden Verbraeuche,
|
||||||
|
-- die man kennt - Liter je 100 km beim Verbrenner, kWh je 100 km beim
|
||||||
|
-- Stromer. Wieviele Liter eine geladene kWh ersetzt, rechnet die Abfrage
|
||||||
|
-- daraus aus.
|
||||||
|
--
|
||||||
|
-- 2) end_date faellt weg. Die Spalte war reine Redundanz: sie sagte nie etwas
|
||||||
|
-- anderes als "Tag vor dem naechsten Stichtag" - konnte aber, weil sie
|
||||||
|
-- eigenstaendig gepflegt wurde, etwas anderes sagen. Genau das war
|
||||||
|
-- passiert: gridCosts liess zwei Zeilen ueber das erste Quartal 2023
|
||||||
|
-- laufen, der JOIN in getStats.php fand fuer jeden Messwert zwei Preise,
|
||||||
|
-- und der Stromverbrauch 2023 stand mit 2527 statt 1674 kWh in der
|
||||||
|
-- Statistik - 51 % zu hoch.
|
||||||
|
--
|
||||||
|
-- Ohne die Spalte laesst sich eine Ueberlappung nicht mehr hinschreiben,
|
||||||
|
-- und eine Luecke auch nicht. Das Ende einer Zeile holt sich die Abfrage
|
||||||
|
-- mit LEAD(active_date) beim naechsten Stichtag; die juengste Zeile hat
|
||||||
|
-- kein Ende und gilt weiter, bis ein neuer Preis eingetragen wird. So
|
||||||
|
-- verhaelt sich ein Tarif ja auch.
|
||||||
|
--
|
||||||
|
-- Nebenbei ist die Statistik dadurch rund viermal so schnell (2,9 s -> 0,7 s
|
||||||
|
-- je Jahr): der Optimierer schlaegt die drei kleinen Preistabellen einmal
|
||||||
|
-- auf, statt fuer jeden der 100.000 Messwerte einen Bereich zu durchsuchen.
|
||||||
|
--
|
||||||
|
-- Anwenden: mysql -h <host> -P 3310 -u solarLog -p solarLog < solarLog_costs.sql
|
||||||
|
|
||||||
|
ALTER TABLE `gridCosts`
|
||||||
|
ADD COLUMN `base` float NOT NULL DEFAULT 0
|
||||||
|
COMMENT 'Grundpreis pro Jahr in Euro' AFTER `gain`,
|
||||||
|
DROP KEY `end_date`,
|
||||||
|
DROP COLUMN `end_date`;
|
||||||
|
|
||||||
|
-- Bis hierher stand die 140 fest im SQL - fuer alle Jahre gleich.
|
||||||
|
UPDATE `gridCosts` SET `base` = 140;
|
||||||
|
|
||||||
|
-- Die Zeile ab 2022-12-13 lief bis 2023-03-31 und ueberlappte damit die
|
||||||
|
-- naechste. Ohne end_date endet sie automatisch am 31.12.2022.
|
||||||
|
|
||||||
|
ALTER TABLE `gasCosts`
|
||||||
|
ADD COLUMN `kwhPerLitre` float NOT NULL DEFAULT 6.58
|
||||||
|
COMMENT 'Energieinhalt: kWh je Liter';
|
||||||
|
|
||||||
|
ALTER TABLE `fuelCosts`
|
||||||
|
ADD COLUMN `lPer100km` float NOT NULL DEFAULT 9.8
|
||||||
|
COMMENT 'Verbrauch des Verbrenners in Liter je 100 km',
|
||||||
|
ADD COLUMN `kwhPer100km` float NOT NULL DEFAULT 20
|
||||||
|
COMMENT 'Verbrauch des Elektroautos in kWh je 100 km',
|
||||||
|
DROP KEY `end_date`,
|
||||||
|
DROP COLUMN `end_date`;
|
||||||
Reference in New Issue
Block a user