Compare commits
3
Commits
79a55aeafd
...
d285ec27a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d285ec27a0 | ||
|
|
c9a9e16b21 | ||
|
|
cbddd664e3 |
@@ -48,3 +48,7 @@ Thumbs.db
|
||||
*.tmp
|
||||
*.bak
|
||||
*.backup
|
||||
|
||||
# Kartenkacheln des Zwischenspeichers (ajax/tile.php).
|
||||
# Wachsen mit dem, was angesehen wurde - gehoeren nicht ins Repository.
|
||||
tiles/
|
||||
|
||||
+82
-15
@@ -4,30 +4,94 @@
|
||||
* Ersetzt getConsData_{month,year,decade}.php und getProdData_{month,year,decade}.php.
|
||||
*
|
||||
* Aufruf: energyHistory.php?series=prod|cons&range=month|year|decade
|
||||
*
|
||||
* Zwei Quellen, je nach Zeitraum:
|
||||
*
|
||||
* month EnergyFlow, also die Rohdaten. Nach Tagen gruppiert, und der
|
||||
* heutige Balken soll bis zur letzten Messung reichen. Das
|
||||
* Stundenarchiv wird nur einmal nachts fortgeschrieben und wäre
|
||||
* für den laufenden Tag noch leer.
|
||||
*
|
||||
* year EnergyFlow_hourly, das Stundenarchiv. Die Rohdaten werden nach
|
||||
* decade zwölf Monaten ausgedünnt (solarlog-rollup.sh thin), diese
|
||||
* Diagramme fänden dort also nichts mehr. Bei Gruppierung nach
|
||||
* Monat bzw. Jahr fällt der fehlende laufende Tag nicht auf.
|
||||
*
|
||||
* Beide Quellen liefern Wattstunden, weil die Diagramme auf der y-Achse durch
|
||||
* 1.000.000 teilen und MWh beschriften. Die Rohdaten sind Momentanleistungen
|
||||
* alle fünf Minuten, daher /12; im Archiv stehen bereits kWh, daher *1000.
|
||||
* Gegengerechnet über einen kompletten Monat stimmen beide auf 0,003 % überein
|
||||
* - der Rest ist die /12-Näherung gegenüber der echten Zeitgewichtung.
|
||||
*
|
||||
* pv_fehlbetrag_kwh wird zur erfassten Erzeugung addiert. Wenn Wechselrichter
|
||||
* die Verbindung zur OpenDTU verlieren, fehlt ihr Beitrag in pvP bzw. pv_kwh -
|
||||
* die Einspeisung, die Batterieladung und der Heizstab sind aber vollständig
|
||||
* gemessen. Ohne die Korrektur wird der Direktverbrauch als Differenz negativ
|
||||
* und der Balken bleibt leer, etwa am 03.08.2026 mit -0,5 statt +15,9 kWh.
|
||||
* Im Rohdaten-Zweig kommt der Betrag über eine nach Tagen vorverdichtete
|
||||
* abgeleitete Tabelle dazu, sonst zählte ihn jede der zwölf Zeilen je Stunde
|
||||
* erneut; für den laufenden Tag gibt es ihn noch nicht, dann bleibt er 0.
|
||||
*
|
||||
* Die Einspeisung kommt in beiden Fällen aus gridPfeed, dem Zählerregister,
|
||||
* nicht aus dem Saldo gridP. Innerhalb eines Fensters kommen Bezug und
|
||||
* Einspeisung gleichzeitig vor; der Saldo löscht kurze Bezüge weg und passt
|
||||
* dann nicht mehr zu dem, was abgerechnet wird.
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
require_once("./chartData.php");
|
||||
|
||||
// Zeitraum -> [Format der Beschriftung, Filter auf datetime]
|
||||
// Zeitraum -> [Format der Beschriftung, Quelle]
|
||||
$ranges = [
|
||||
"month" => ["'%d.%b.'", "DATE_FORMAT(datetime,'%Y%m%d') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 1 MONTH),'%Y%m%d')"],
|
||||
"year" => ["'%b. %y'", "DATE_FORMAT(datetime,'%Y%m') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 13 MONTH),'%Y%m')"],
|
||||
"decade" => ["'%Y'", "DATE_FORMAT(datetime,'%Y') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 10 YEAR),'%Y')"],
|
||||
"month" => ["'%d.%b.'", "roh"],
|
||||
"year" => ["'%b. %y'", "archiv"],
|
||||
"decade" => ["'%Y'", "archiv"],
|
||||
];
|
||||
|
||||
// Filter auf die Zeitspalte. ZEIT wird durch den Spaltennamen der Quelle ersetzt.
|
||||
$filters = [
|
||||
"month" => "DATE_FORMAT(ZEIT,'%Y%m%d') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 1 MONTH),'%Y%m%d')",
|
||||
"year" => "DATE_FORMAT(ZEIT,'%Y%m') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 13 MONTH),'%Y%m')",
|
||||
"decade" => "DATE_FORMAT(ZEIT,'%Y') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 10 YEAR),'%Y')",
|
||||
];
|
||||
|
||||
// Datenreihen je Diagramm. Beide teilen sich Solarertrag und Heizstab und
|
||||
// unterscheiden sich nur darin, wohin die Energie fließt (prod) bzw. woher
|
||||
// sie kommt (cons).
|
||||
$series = [
|
||||
"prod" => "SUM(pvP/12 - IF(gridP < 0, -gridP/12,0) - IF(battP < 0, -battP/12,0) - heaterPwr/12) AS Direktverbrauch,
|
||||
$quellen = [
|
||||
"roh" => [
|
||||
"tabelle" => "EnergyFlow",
|
||||
"zeit" => "datetime",
|
||||
"join" => "LEFT JOIN (SELECT DATE(stunde) AS tag, SUM(pv_fehlbetrag_kwh) AS fehlt
|
||||
FROM EnergyFlow_hourly GROUP BY 1) f ON f.tag = DATE(datetime)",
|
||||
"reihen" => [
|
||||
"prod" => "SUM(pvP/12 - gridPfeed/12 - IF(battP < 0, -battP/12,0) - heaterPwr/12)
|
||||
+ IFNULL(MAX(f.fehlt),0)*1000 AS Direktverbrauch,
|
||||
SUM(heaterPwr/12) AS Heizstab,
|
||||
SUM(IF(battP < 0, -battP/12,0)) AS Batterieladung,
|
||||
SUM(gridPfeed/12) AS Einspeisung",
|
||||
"cons" => "SUM(pvP/12 - IF(gridP < 0, -gridP/12,0) - IF(battP < 0, -battP/12,0) - heaterPwr/12) AS Solarverbrauch,
|
||||
"cons" => "SUM(pvP/12 - gridPfeed/12 - IF(battP < 0, -battP/12,0) - heaterPwr/12)
|
||||
+ IFNULL(MAX(f.fehlt),0)*1000 AS Solarverbrauch,
|
||||
SUM(heaterPwr/12) AS Heizstab,
|
||||
SUM(IF(battP > 0, battP/12,0)) AS Batteriebezug,
|
||||
SUM(gridPcons/12) AS Netzbezug",
|
||||
],
|
||||
],
|
||||
"archiv" => [
|
||||
"tabelle" => "EnergyFlow_hourly",
|
||||
"zeit" => "stunde",
|
||||
"join" => "",
|
||||
"reihen" => [
|
||||
"prod" => "SUM((pv_kwh + IFNULL(pv_fehlbetrag_kwh,0) - netz_einsp_kwh - batt_laden_kwh - heizstab_kwh)*1000) AS Direktverbrauch,
|
||||
SUM(heizstab_kwh*1000) AS Heizstab,
|
||||
SUM(batt_laden_kwh*1000) AS Batterieladung,
|
||||
SUM(netz_einsp_kwh*1000) AS Einspeisung",
|
||||
"cons" => "SUM((pv_kwh + IFNULL(pv_fehlbetrag_kwh,0) - netz_einsp_kwh - batt_laden_kwh - heizstab_kwh)*1000) AS Solarverbrauch,
|
||||
SUM(heizstab_kwh*1000) AS Heizstab,
|
||||
SUM(batt_entladen_kwh*1000) AS Batteriebezug,
|
||||
SUM(netz_bezug_kwh*1000) AS Netzbezug",
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$linecolors["Direktverbrauch"] = "#FFFF00";
|
||||
@@ -46,14 +110,17 @@ $linecolors["Vorhersage"] = "#2222FF";
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$rangeKey = isset($_GET["range"]) ? $_GET["range"] : "";
|
||||
$rangeKey = isset($_GET["range"]) ? $_GET["range"] : "";
|
||||
$seriesKey = isset($_GET["series"]) ? $_GET["series"] : "";
|
||||
if (!isset($ranges[$rangeKey]) || !isset($series[$seriesKey])) {
|
||||
if (!isset($ranges[$rangeKey]) || !in_array($seriesKey, ["prod", "cons"], true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Unbekannter Zeitraum oder Datenreihe."]);
|
||||
exit;
|
||||
}
|
||||
list($labelFormat, $filter) = $ranges[$rangeKey];
|
||||
list($labelFormat, $quelleKey) = $ranges[$rangeKey];
|
||||
$quelle = $quellen[$quelleKey];
|
||||
$zeit = $quelle["zeit"];
|
||||
$filter = str_replace("ZEIT", $zeit, $filters[$rangeKey]);
|
||||
|
||||
$obj = (object)[];
|
||||
$obj->labels = [];
|
||||
@@ -62,12 +129,12 @@ $obj->datasets = [];
|
||||
if (checkLogin()) {
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$query = "SELECT
|
||||
DATE_FORMAT(datetime, $labelFormat) AS 'time',
|
||||
" . $series[$seriesKey] . "
|
||||
FROM EnergyFlow
|
||||
DATE_FORMAT($zeit, $labelFormat) AS 'time',
|
||||
" . $quelle["reihen"][$seriesKey] . "
|
||||
FROM " . $quelle["tabelle"] . " " . $quelle["join"] . "
|
||||
WHERE $filter
|
||||
GROUP BY DATE_FORMAT(datetime, $labelFormat)
|
||||
ORDER BY datetime ; ";
|
||||
GROUP BY DATE_FORMAT($zeit, $labelFormat)
|
||||
ORDER BY MIN($zeit) ; ";
|
||||
$obj = buildChartData($mysql, $query, $linecolors);
|
||||
}
|
||||
|
||||
|
||||
+97
-41
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
require_once("../restricted/costs.php");
|
||||
if(!isset($_GET["year"])){
|
||||
$_GET["year"] = date("Y");
|
||||
}else{
|
||||
@@ -17,47 +18,98 @@ if(isset($_GET["type"])){
|
||||
}
|
||||
}
|
||||
|
||||
$Query = "SELECT
|
||||
SUM(gridPcons/12000) as 'Stromverbrauch',
|
||||
SUM((gridPcons*cost)/12000)+140 as 'Stromkosten (140€ fix)',
|
||||
SUM(pvP - IF(gridP < 0, -gridP,0) - IF(battP < 0, -battP,0)- heaterPwr + IF(battP > 0, battP,0))*cost/12000 as 'Verbrauchsersparnis',
|
||||
SUM((gridPfeed*gain)/12000) as 'Einspeisevergütung',
|
||||
SUM(IF(gridP < 500, heaterPwr, 0)*0.063/12000) as 'Ersparnis Heizung',
|
||||
SUM(IF(battP > 0, battP, 0)*cost/12000) as 'Ersparnis Batterie',
|
||||
AVG(autonomy) AS 'Ø Autarkie',
|
||||
(SUM(PL1_EV+PL2_EV+PL3_EV))/12 AS 'Autoladung ges. EG',
|
||||
(((SUM(IF(gridP>100,0,PL1_EV+PL2_EV+PL3_EV)))/(SUM(PL1_EV+PL2_EV+PL3_EV))))*100 AS 'Autoladung Solar EG',
|
||||
(SUM(IF(gridP>100,0,(PL1_EV+PL2_EV+PL3_EV)*cost))/12) AS 'Ersparnis Solarladung EG',
|
||||
(SUM(PL1_EV+PL2_EV+PL3_EV))/24.5 AS 'Benzin gespart EG',
|
||||
(SUM(PL1_EVog+PL2_EVog+PL3_EVog))/12 AS 'Autoladung ges. OG',
|
||||
(((SUM(IF(gridP>100,0,PL1_EVog+PL2_EVog+PL3_EVog)))/(SUM(PL1_EVog+PL2_EVog+PL3_EVog))))*100 AS 'Autoladung Solar OG',
|
||||
(SUM(IF(gridP>100,0,(PL1_EVog+PL2_EVog+PL3_EVog)*cost))/12) AS 'Ersparnis Solarladung OG',
|
||||
(SUM(PL1_EVog+PL2_EVog+PL3_EVog))/24.5 AS 'Benzin gespart OG'
|
||||
FROM EnergyFlow JOIN gridCosts ON DATE(datetime) >= DATE(gridCosts.active_date) AND DATE(datetime) <= DATE(gridCosts.end_date)
|
||||
WHERE year(datetime) = ".$_GET["year"].";";
|
||||
/**
|
||||
* Die Jahresstatistik als eine Abfrage - jetzt auf stats_daily.
|
||||
*
|
||||
* Gerechnet wird nicht mehr ueber die Rohdaten aus EnergyFlow, sondern ueber
|
||||
* die Tageswerte. Zwei Gruende:
|
||||
*
|
||||
* 1. Die Rohdaten werden nach zwoelf Monaten ausgeduennt bzw. ins
|
||||
* Stundenarchiv verschoben (solarlog-rollup.sh). Eine Kachel, die auf
|
||||
* EnergyFlow rechnet, zeigt fuer alte Jahre danach zu wenig an.
|
||||
* 2. Ein Jahr sind 365 Zeilen statt rund 105.000.
|
||||
*
|
||||
* Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt
|
||||
* LEAD() beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie
|
||||
* zwei Tarife haben - die Verdichtung auf Tage verliert hier also nichts.
|
||||
*
|
||||
* Was in stats_daily vorberechnet sein MUSS, weil es eine Bedingung je
|
||||
* Messwert auswertet und sich aus Tagessummen nicht rekonstruieren laesst:
|
||||
*
|
||||
* eigenverbrauch_kwh pv - Einspeisung - Batterieladung
|
||||
* - Heizstab + Batterieentladung. Dazu addiert
|
||||
* wird pv_fehlbetrag_kwh: bei Ausfall einzelner
|
||||
* Wechselrichter fehlt deren Beitrag in pv, alle
|
||||
* Senken sind dagegen gemessen - die Differenz
|
||||
* faellt sonst zu klein oder negativ aus.
|
||||
* heizstab_solar_kwh Heizstab, anteilig nach autonomy
|
||||
* auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom
|
||||
* Geraet in feiner Aufloesung gerechneten
|
||||
* Nicht-Netz-Anteil der Zufuhr
|
||||
*
|
||||
* Einheiten sind in stats_daily bereits bereinigt: PL*_EV und PL*_EVog
|
||||
* liefern Kilowatt, alle uebrigen Leistungsspalten Watt. In der alten
|
||||
* Abfrage stand das als /12 gegen /12000 nebeneinander.
|
||||
*
|
||||
* Die Solaranteile teilen jetzt durch NULLIF(...): ohne Ladung im Jahr kam
|
||||
* vorher 0/0 heraus.
|
||||
*
|
||||
* "Ersparnis Batterie" rechnet den Nutzen netto: was die entladene Energie
|
||||
* aus dem Netz gekostet haette, abzueglich der Verguetung, die fuer den zum
|
||||
* Laden verwendeten Strom entgangen ist. Brutto waere die Kachel zu
|
||||
* freundlich - der Speicher ist nicht umsonst zu fuellen.
|
||||
*
|
||||
* Achtung beim Lesen: "Verbrauchsersparnis" ist der GESAMTE Eigenverbrauch
|
||||
* zum Strompreis. "Ersparnis Batterie" und "Ersparnis Solarladung" sind
|
||||
* Teilmengen davon, nach Herkunft bzw. Verwendung herausgegriffen. Sie
|
||||
* duerfen nicht addiert werden.
|
||||
*
|
||||
* Der Grundpreis fehlt weiterhin bewusst - er haengt an den Tagen des
|
||||
* Jahres, nicht an einem Messwert, und kommt aus grundpreisImJahr() dazu.
|
||||
*/
|
||||
function statistikAbfrage($jahr)
|
||||
{
|
||||
return "WITH
|
||||
preis AS (SELECT active_date, cost, gain,
|
||||
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gridCosts),
|
||||
gas AS (SELECT active_date, cost, kwhPerLitre,
|
||||
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gasCosts),
|
||||
sprit AS (SELECT active_date, cost, lPer100km, kwhPer100km,
|
||||
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM fuelCosts)
|
||||
SELECT
|
||||
SUM(d.netz_bezug_kwh) AS 'Stromverbrauch',
|
||||
SUM(d.netz_bezug_kwh * preis.cost) AS 'Stromkosten',
|
||||
SUM((d.eigenverbrauch_kwh + IFNULL(d.pv_fehlbetrag_kwh,0)) * preis.cost) AS 'Verbrauchsersparnis',
|
||||
SUM(d.netz_einsp_kwh * preis.gain) AS 'Einspeisevergütung',
|
||||
SUM(d.heizstab_solar_kwh * gas.cost / NULLIF(gas.kwhPerLitre,0)) AS 'Ersparnis Heizung',
|
||||
SUM(d.batt_entladen_kwh * preis.cost - d.batt_laden_kwh * preis.gain) AS 'Ersparnis Batterie',
|
||||
SUM(d.autarkie_avg * d.stunden_erfasst) / NULLIF(SUM(d.stunden_erfasst),0) AS 'Ø Autarkie',
|
||||
SUM(d.auto_eg_kwh) AS 'Autoladung ges. EG',
|
||||
SUM(d.auto_eg_solar_kwh) / NULLIF(SUM(d.auto_eg_kwh),0) * 100 AS 'Autoladung Solar EG',
|
||||
SUM(d.auto_eg_solar_kwh * preis.cost) AS 'Ersparnis Solarladung EG',
|
||||
SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart EG',
|
||||
SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin EG',
|
||||
SUM(d.auto_og_kwh) AS 'Autoladung ges. OG',
|
||||
SUM(d.auto_og_solar_kwh) / NULLIF(SUM(d.auto_og_kwh),0) * 100 AS 'Autoladung Solar OG',
|
||||
SUM(d.auto_og_solar_kwh * preis.cost) AS 'Ersparnis Solarladung OG',
|
||||
SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart OG',
|
||||
SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin OG'
|
||||
FROM stats_daily d
|
||||
JOIN preis ON d.tag >= preis.active_date AND (preis.folgt IS NULL OR d.tag < preis.folgt)
|
||||
LEFT JOIN gas ON d.tag >= gas.active_date AND (gas.folgt IS NULL OR d.tag < gas.folgt)
|
||||
LEFT JOIN sprit ON d.tag >= sprit.active_date AND (sprit.folgt IS NULL OR d.tag < sprit.folgt)
|
||||
WHERE YEAR(d.tag) = " . intval($jahr) . ";";
|
||||
}
|
||||
|
||||
$PrevQuery = "SELECT
|
||||
SUM(gridPcons/12000) as 'Stromverbrauch',
|
||||
SUM((gridPcons*cost)/12000)+140 as 'Stromkosten (140€ fix)',
|
||||
SUM(pvP - IF(gridP < 0, -gridP,0) - IF(battP < 0, -battP,0)- heaterPwr + IF(battP > 0, battP,0))*cost/12000 as 'Verbrauchsersparnis',
|
||||
SUM((gridPfeed*gain)/12000) as 'Einspeisevergütung',
|
||||
SUM(IF(gridP < 500, heaterPwr, 0)*0.063/12000) as 'Ersparnis Heizung',
|
||||
SUM(IF(battP > 0, battP, 0)*cost/12000) as 'Ersparnis Batterie',
|
||||
AVG(autonomy) AS 'Ø Autarkie',
|
||||
(SUM(PL1_EV+PL2_EV+PL3_EV))/12 AS 'Autoladung ges. EG',
|
||||
(((SUM(IF(gridP>100,0,PL1_EV+PL2_EV+PL3_EV)))/(SUM(PL1_EV+PL2_EV+PL3_EV))))*100 AS 'Autoladung Solar EG',
|
||||
(SUM(IF(gridP>100,0,(PL1_EV+PL2_EV+PL3_EV)*cost))/12) AS 'Ersparnis Solarladung EG',
|
||||
(SUM(PL1_EV+PL2_EV+PL3_EV))/24.5 AS 'Benzin gespart EG',
|
||||
(SUM(PL1_EVog+PL2_EVog+PL3_EVog))/12 AS 'Autoladung ges. OG',
|
||||
(((SUM(IF(gridP>100,0,PL1_EVog+PL2_EVog+PL3_EVog)))/(SUM(PL1_EVog+PL2_EVog+PL3_EVog))))*100 AS 'Autoladung Solar OG',
|
||||
(SUM(IF(gridP>100,0,(PL1_EVog+PL2_EVog+PL3_EVog)*cost))/12) AS 'Ersparnis Solarladung OG',
|
||||
(SUM(PL1_EVog+PL2_EVog+PL3_EVog))/24.5 AS 'Benzin gespart OG'
|
||||
FROM EnergyFlow JOIN gridCosts ON DATE(datetime) >= DATE(gridCosts.active_date) AND DATE(datetime) <= DATE(gridCosts.end_date)
|
||||
WHERE year(datetime) = ".($_GET["year"]-1).";";
|
||||
$Query = statistikAbfrage($_GET["year"]);
|
||||
$PrevQuery = statistikAbfrage($_GET["year"] - 1);
|
||||
|
||||
|
||||
$units = Array("kWh","€","€","€","€","€","€","%","kWh","%","€","L","kWh","%","€","L");
|
||||
$LessIsBetter = Array(true,true,false,false,false,false,false,false,false,false,false,false,false,false,false,false);
|
||||
// Reihenfolge wie in der Abfrage, mit "Verg.+Einsp. Strom" an Stelle 4.
|
||||
$units = Array("kWh","€","€","€","€","€","€","%",
|
||||
"kWh","%","€","L","€",
|
||||
"kWh","%","€","L","€");
|
||||
// Nur bei Verbrauch und Kosten ist weniger besser.
|
||||
$LessIsBetter = array_merge([true, true], array_fill(0, count($units) - 2, false));
|
||||
|
||||
function array_insert($array,$values,$offset) {
|
||||
return array_slice($array, 0, $offset, true) + $values + array_slice($array, $offset, NULL, true);
|
||||
@@ -67,7 +119,7 @@ function array_insert($array,$values,$offset) {
|
||||
if (checkLogin()) {
|
||||
|
||||
$html = "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$mysql = solarDb();
|
||||
$Res = mysqli_query($mysql,$Query);
|
||||
$ResPrev = mysqli_query($mysql,$PrevQuery);
|
||||
if(!$Res || !$ResPrev){
|
||||
@@ -77,13 +129,17 @@ if (checkLogin()) {
|
||||
$i = 0;
|
||||
$row = $Res->fetch_assoc();
|
||||
$rowPrev = $ResPrev->fetch_assoc();
|
||||
// Der Grundpreis faellt unabhaengig vom Verbrauch an und laesst sich
|
||||
// deshalb nicht aus den Messwerten summieren.
|
||||
$row["Stromkosten"] += grundpreisImJahr($_GET["year"]);
|
||||
$rowPrev["Stromkosten"] += grundpreisImJahr($_GET["year"] - 1);
|
||||
//$row["Vergütung+Einsparung Strom"] = $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"];
|
||||
$row = array_insert($row,["Verg.+Einsp. Strom" => $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"]],4);
|
||||
$rowPrev = array_insert($rowPrev,["Verg.+Einsp. Strom" => $rowPrev["Einspeisevergütung"]+$rowPrev["Verbrauchsersparnis"]],4);
|
||||
$i = 0;
|
||||
foreach ($row as $name => $value) {
|
||||
if(str_starts_with($name,"Autoladung ges.")){
|
||||
$html .= "</div><hr class='mt-2 mb-3 border-light' />";
|
||||
$html .= "</div><hr class='mt-3 mb-3 border-light' />";
|
||||
$html .= "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
|
||||
}
|
||||
/*$html .= "<div class='col-12 col-sm-4 col-md-2 col-xl-1'>
|
||||
|
||||
@@ -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;
|
||||
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; }
|
||||
|
||||
@@ -124,7 +124,7 @@ CREATE TABLE IF NOT EXISTS `automation_log` (
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Ferien und Feiertage. Nur besondere Tage stehen drin, ein fehlendes Datum
|
||||
-- ist ein gewoehnlicher Tag. Gefuellt von
|
||||
-- restricted/autoActions/fetch_calendar.py, einmal jaehrlich per Cron.
|
||||
-- SolarManager/autoActions/fetch_calendar.py, einmal jaehrlich per Cron.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `calendar_days` (
|
||||
`date` date NOT NULL,
|
||||
|
||||
@@ -1,132 +1,136 @@
|
||||
<?php
|
||||
require_once("helper.php");
|
||||
|
||||
if (isset($_GET["addUser"])) {
|
||||
if (checkAdduser($_GET["addUser"])) {
|
||||
echo <<<ENDE
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<title>Smarthome control</title>
|
||||
<link rel="icon" type="image/png" href="assets/img/favicon.png">
|
||||
<!--end::Accessibility Meta Tags-->
|
||||
|
||||
<!--begin::Primary Meta Tags-->
|
||||
<meta name="title" content="Smarthome control" />
|
||||
<meta name="author" content="ColorlibHQ" />
|
||||
<meta name="description" content="Smarthome control panel by m0." />
|
||||
<meta name="keywords" content="smarthome dashboard, admin panel" />
|
||||
<!--end::Primary Meta Tags-->
|
||||
|
||||
<!--begin::Fonts-->
|
||||
<link rel="stylesheet" href="assets/fonts/font_poppins.css" media="print" onload="this.media='all'" />
|
||||
<!--end::Fonts-->
|
||||
|
||||
<!--begin::Third Party Plugin(Bootstrap Icons)-->
|
||||
<link rel="stylesheet" href="css/bootstrap-icons.min.css" />
|
||||
<!--end::Third Party Plugin(Bootstrap Icons)-->
|
||||
|
||||
<!--begin::Required Plugin(AdminLTE)-->
|
||||
<link rel="stylesheet" href="./css/adminlte.min.css?v=2" />
|
||||
<!--end::Required Plugin(AdminLTE)-->
|
||||
|
||||
|
||||
</head>
|
||||
<body onload="createRegistration()">
|
||||
</body>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>
|
||||
async function createRegistration() {
|
||||
try {
|
||||
// check browser support
|
||||
if (!window.fetch || !navigator.credentials || !navigator.credentials.create) {
|
||||
throw new Error('Browser not supported.');
|
||||
}
|
||||
let keyName = encodeURIComponent(prompt('Bitte Hier den Namen des Schlüssels eingeben:'));
|
||||
// get create args
|
||||
let rep = await window.fetch('authServer.php?fn=getCreateArgs', {method:'GET', cache:'no-cache'});
|
||||
//alert(await rep.text());
|
||||
const createArgs = await rep.json();
|
||||
// error handling
|
||||
if (createArgs.success === false) {
|
||||
throw new Error(createArgs.msg || 'unknown error occured');
|
||||
}
|
||||
|
||||
// replace binary base64 data with ArrayBuffer. a other way to do this
|
||||
// is the reviver function of JSON.parse()
|
||||
recursiveBase64StrToArrayBuffer(createArgs);
|
||||
|
||||
// create credentials
|
||||
const cred = await navigator.credentials.create(createArgs);
|
||||
|
||||
// create object
|
||||
const authenticatorAttestationResponse = {
|
||||
transports: cred.response.getTransports ? cred.response.getTransports() : null,
|
||||
clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
|
||||
attestationObject: cred.response.attestationObject ? arrayBufferToBase64(cred.response.attestationObject) : null
|
||||
};
|
||||
|
||||
// check auth on server side
|
||||
rep = await window.fetch('authServer.php?fn=processCreate&name='+keyName, {
|
||||
method : 'POST',
|
||||
body : JSON.stringify(authenticatorAttestationResponse),
|
||||
cache : 'no-cache'
|
||||
});
|
||||
authenticatorAttestationServerResponse = await rep.json();
|
||||
// prompt server response
|
||||
if (authenticatorAttestationServerResponse.success) {
|
||||
window.alert(authenticatorAttestationServerResponse.msg || 'registration success');
|
||||
if(!authenticatorAttestationServerResponse.msg){
|
||||
window.location.href = "https://nas.el-wa.org/smart";
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error(authenticatorAttestationServerResponse.msg);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
window.alert(err.message || 'unknown error occured');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</html>
|
||||
ENDE;
|
||||
}
|
||||
} else if (checkLogin()) {
|
||||
/*
|
||||
* Seitentabelle: Vorlage, Seitenskripte und welche Bibliotheken die Seite
|
||||
* ueberhaupt braucht. Frueher wurden Chart.js, MQTT & Co. auf jeder Seite
|
||||
* geladen - die Home-Seite etwa zeichnet gar keine Diagramme.
|
||||
*/
|
||||
$pages = [
|
||||
"solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true,
|
||||
"scripts" => ["js/solar/solarMQTT.js"]],
|
||||
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false,
|
||||
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
|
||||
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false,
|
||||
"scripts" => ["js/solar/heatMQTT.js"]],
|
||||
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false,
|
||||
"scripts" => ["js/solar/historyMQTT.js"]],
|
||||
"weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true,
|
||||
"scripts" => ["js/solar/weatherMQTT.js"]],
|
||||
];
|
||||
|
||||
// Unbekannte oder fehlende Aktion faellt auf die Solar-Seite zurueck.
|
||||
if (!isset($_GET["action"]) || !isset($pages[$_GET["action"]])) {
|
||||
$_GET["action"] = "solar";
|
||||
}
|
||||
$page = $pages[$_GET["action"]];
|
||||
|
||||
include "restricted/header.php";
|
||||
include "restricted/" . $page["template"];
|
||||
include "restricted/footer.php";
|
||||
} else {
|
||||
echo <<<ENDE
|
||||
<html>
|
||||
<head><link rel="icon" type="image/png" href="assets/img/favicon.png"></head>
|
||||
<body onload="checkRegistration()">
|
||||
<script src="js/auth.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
ENDE;
|
||||
}
|
||||
<?php
|
||||
require_once("helper.php");
|
||||
|
||||
if (isset($_GET["addUser"])) {
|
||||
if (checkAdduser($_GET["addUser"])) {
|
||||
echo <<<ENDE
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<title>Smarthome control</title>
|
||||
<link rel="icon" type="image/png" href="assets/img/favicon.png">
|
||||
<!--end::Accessibility Meta Tags-->
|
||||
|
||||
<!--begin::Primary Meta Tags-->
|
||||
<meta name="title" content="Smarthome control" />
|
||||
<meta name="author" content="ColorlibHQ" />
|
||||
<meta name="description" content="Smarthome control panel by m0." />
|
||||
<meta name="keywords" content="smarthome dashboard, admin panel" />
|
||||
<!--end::Primary Meta Tags-->
|
||||
|
||||
<!--begin::Fonts-->
|
||||
<link rel="stylesheet" href="assets/fonts/font_poppins.css" media="print" onload="this.media='all'" />
|
||||
<!--end::Fonts-->
|
||||
|
||||
<!--begin::Third Party Plugin(Bootstrap Icons)-->
|
||||
<link rel="stylesheet" href="css/bootstrap-icons.min.css" />
|
||||
<!--end::Third Party Plugin(Bootstrap Icons)-->
|
||||
|
||||
<!--begin::Required Plugin(AdminLTE)-->
|
||||
<link rel="stylesheet" href="./css/adminlte.min.css?v=2" />
|
||||
<!--end::Required Plugin(AdminLTE)-->
|
||||
|
||||
|
||||
</head>
|
||||
<body onload="createRegistration()">
|
||||
</body>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>
|
||||
async function createRegistration() {
|
||||
try {
|
||||
// check browser support
|
||||
if (!window.fetch || !navigator.credentials || !navigator.credentials.create) {
|
||||
throw new Error('Browser not supported.');
|
||||
}
|
||||
let keyName = encodeURIComponent(prompt('Bitte Hier den Namen des Schlüssels eingeben:'));
|
||||
// get create args
|
||||
let rep = await window.fetch('authServer.php?fn=getCreateArgs', {method:'GET', cache:'no-cache'});
|
||||
//alert(await rep.text());
|
||||
const createArgs = await rep.json();
|
||||
// error handling
|
||||
if (createArgs.success === false) {
|
||||
throw new Error(createArgs.msg || 'unknown error occured');
|
||||
}
|
||||
|
||||
// replace binary base64 data with ArrayBuffer. a other way to do this
|
||||
// is the reviver function of JSON.parse()
|
||||
recursiveBase64StrToArrayBuffer(createArgs);
|
||||
|
||||
// create credentials
|
||||
const cred = await navigator.credentials.create(createArgs);
|
||||
|
||||
// create object
|
||||
const authenticatorAttestationResponse = {
|
||||
transports: cred.response.getTransports ? cred.response.getTransports() : null,
|
||||
clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
|
||||
attestationObject: cred.response.attestationObject ? arrayBufferToBase64(cred.response.attestationObject) : null
|
||||
};
|
||||
|
||||
// check auth on server side
|
||||
rep = await window.fetch('authServer.php?fn=processCreate&name='+keyName, {
|
||||
method : 'POST',
|
||||
body : JSON.stringify(authenticatorAttestationResponse),
|
||||
cache : 'no-cache'
|
||||
});
|
||||
authenticatorAttestationServerResponse = await rep.json();
|
||||
// prompt server response
|
||||
if (authenticatorAttestationServerResponse.success) {
|
||||
window.alert(authenticatorAttestationServerResponse.msg || 'registration success');
|
||||
if(!authenticatorAttestationServerResponse.msg){
|
||||
window.location.href = "https://nas.el-wa.org/smart";
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error(authenticatorAttestationServerResponse.msg);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
window.alert(err.message || 'unknown error occured');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</html>
|
||||
ENDE;
|
||||
}
|
||||
} else if (checkLogin()) {
|
||||
/*
|
||||
* Seitentabelle: Vorlage, Seitenskripte und welche Bibliotheken die Seite
|
||||
* ueberhaupt braucht. Frueher wurden Chart.js, MQTT & Co. auf jeder Seite
|
||||
* geladen - die Home-Seite etwa zeichnet gar keine Diagramme.
|
||||
*/
|
||||
$pages = [
|
||||
"solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false,
|
||||
"scripts" => ["js/solar/solarMQTT.js"]],
|
||||
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
|
||||
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false,
|
||||
"scripts" => ["js/solar/heatMQTT.js"]],
|
||||
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false,
|
||||
"scripts" => ["js/solar/historyMQTT.js"]],
|
||||
"skoda" => ["template" => "skoda.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => true,
|
||||
"scripts" => ["js/solar/skodaMQTT.js"]],
|
||||
"weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true, "leaflet" => false,
|
||||
"scripts" => ["js/solar/weatherMQTT.js"]],
|
||||
"settings" => ["template" => "settings.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||
"scripts" => ["js/solar/settings.js"]],
|
||||
];
|
||||
|
||||
// Unbekannte oder fehlende Aktion faellt auf die Solar-Seite zurueck.
|
||||
if (!isset($_GET["action"]) || !isset($pages[$_GET["action"]])) {
|
||||
$_GET["action"] = "solar";
|
||||
}
|
||||
$page = $pages[$_GET["action"]];
|
||||
|
||||
include "restricted/header.php";
|
||||
include "restricted/" . $page["template"];
|
||||
include "restricted/footer.php";
|
||||
} else {
|
||||
echo <<<ENDE
|
||||
<html>
|
||||
<head><link rel="icon" type="image/png" href="assets/img/favicon.png"></head>
|
||||
<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
@@ -1,215 +0,0 @@
|
||||
# AutoActions
|
||||
|
||||
Automatiken, die im Web-UI unter „Automatismen" angelegt werden und hier
|
||||
ausgeführt werden: *wenn Bedingung, dann Kommando*.
|
||||
|
||||
```
|
||||
Browser Datenbank homeMesh Runner
|
||||
────────────────────── ───────────────────────── ────────────────────
|
||||
Karte „Automatismen" ──▶ automations ──▶ autoaction_runner.py
|
||||
ajax/AutoAction.php automation_conditions MQTT / HTTP / Tahoma
|
||||
restricted/automations.php automation_actions ──▶ Geräte
|
||||
js/solar/autoActionFuncs.js automation_action_params
|
||||
automation_log
|
||||
calendar_days ◀── fetch_calendar.py
|
||||
```
|
||||
|
||||
## Wie eine Automatik aufgebaut ist
|
||||
|
||||
Eine Automatik hat **Auslöser**, **Rahmenbedingungen** und **Aktionen**.
|
||||
|
||||
Ein Auslöser vergleicht einen Messwert (`actor_states`) mit einer Schwelle.
|
||||
Mehrere Auslöser werden über `group_no` verknüpft: gleiche Nummer heißt UND,
|
||||
verschiedene Nummern heißen ODER — ausgewertet wird `any(all(gruppe))`. Im
|
||||
Editor ist eine Gruppe ein gerahmter Block mit eigenem „+ Bedingung", zwischen
|
||||
den Blöcken steht ein ODER. Die Klammerung ist damit gezeichnet und nicht bloß
|
||||
vereinbart, und darunter steht derselbe Ausdruck noch einmal als Satz.
|
||||
|
||||
Eine Aktion ist ein Kommando (`actor_commands`) mit einem Wert je Parameter
|
||||
(`command_parameters`) — nicht vier feste Spalten „Wert 1" bis „Wert 4",
|
||||
sondern so viele Zeilen, wie das Gerät Parameter hat.
|
||||
|
||||
Die Rahmenbedingungen (Wochentage, Zeitfenster, Ferien, Feiertage) sagen, wann
|
||||
die Automatik überhaupt hinsehen darf.
|
||||
|
||||
## Warum ein Dauerläufer und kein Cronjob
|
||||
|
||||
Zwei Gründe:
|
||||
|
||||
* Schwellwert-Auslöser sollen greifen, wenn die MQTT-Nachricht hereinkommt,
|
||||
nicht erst im nächsten Minutenraster.
|
||||
* `actor_states.current_value` wird sonst von niemandem fortgeschrieben — es
|
||||
wird beim Geräte-Discovery einmal gesetzt und danach nie wieder. Ein
|
||||
zustandsloser Cronjob hätte gar nichts, womit er vergleichen könnte. Der
|
||||
Runner pflegt den Wert nebenbei mit (gedrosselt auf einmal je Minute), wovon
|
||||
auch der Editor profitiert: er zeigt neben jedem Messwert den aktuellen Stand.
|
||||
|
||||
## Nur die steigende Flanke
|
||||
|
||||
`automations.cond_met` hält fest, ob die Bedingung beim letzten Durchlauf schon
|
||||
erfüllt war. Ohne das würde „Temperatur über 22 Grad" bei jedem Takt erneut
|
||||
feuern. Verlässt die Automatik ihr Zeitfenster, wird die Flanke
|
||||
zurückgesetzt, damit sie im nächsten Fenster wieder steigen kann.
|
||||
|
||||
Zeit-Auslöser gibt es in drei Formen:
|
||||
|
||||
| | wahr, wenn |
|
||||
|---|---|
|
||||
| `um 16:30` | genau in dieser Minute |
|
||||
| `ab 16:30` | von da an bis Mitternacht |
|
||||
| `vor 16:30` | bis dahin |
|
||||
|
||||
Ausgelöst wird in allen drei Fällen nur einmal, eben wegen der Flanke. `ab`
|
||||
ist trotzdem das robustere: fällt der Runner in genau der Minute aus, auf die
|
||||
`um` zeigt, ist die Automatik für den Tag verloren — bei `ab` holt der nächste
|
||||
Takt es nach. Wer `um` braucht und den Ausfall nicht riskieren will, hakt
|
||||
zusätzlich `force_once` an. Die breiten Zeitfenster in `auto_watering.py`
|
||||
folgen derselben Überlegung.
|
||||
|
||||
Beim Sonnenauf- und -untergang ist der Wert ein Versatz, und der kann davor
|
||||
oder danach liegen — deshalb dieselben drei Fälle mal zwei: `+ 00:30` eine
|
||||
halbe Stunde nach Sonnenaufgang, `ab - 00:30` ab einer halben Stunde davor,
|
||||
`vor + 00:30` bis eine halbe Stunde danach.
|
||||
|
||||
Über den Tagesrand wird gerechnet, nicht abgeschnitten: „sechs Stunden vor
|
||||
Sonnenaufgang" landet am Vorabend, und das ist so gewollt — abgeschnitten
|
||||
wären solche Angaben gar nicht mehr formulierbar. Verglichen wird die Uhrzeit
|
||||
innerhalb des Tages; ein Ziel jenseits von Mitternacht gilt als diese Uhrzeit
|
||||
am selben Tag. Bei `+` und `-` ist das genau der gemeinte Zeitpunkt, bei `ab`
|
||||
und `vor` verschiebt sich der wahre Bereich entsprechend mit.
|
||||
|
||||
`force_once` („am Ende des Zeitraums auf jeden Fall ausführen") greift, wenn
|
||||
das Fenster zugeht und in diesem Fenster noch nichts passiert ist.
|
||||
|
||||
## Sperrzeit
|
||||
|
||||
Die Flanke allein schützt nicht gegen einen Messwert, der um die Schwelle
|
||||
**pendelt**: „Temperatur > 22" bei 22,1 / 21,9 / 22,1 °C ist jedes Mal eine
|
||||
echte steigende Flanke, und über MQTT können die Werte im Sekundentakt
|
||||
hereinkommen. `automations.lockout_secs` sagt, wie lange nach einer Auslösung
|
||||
nicht wieder geschaltet wird. Der Editor bietet drei Stufen an:
|
||||
|
||||
| | | gedacht für |
|
||||
|---|---|---|
|
||||
| Ohne | 0 s | volle Geschwindigkeit, jede Flanke schaltet |
|
||||
| Kurz | 60 s | Licht, Farbe, Dimmwert |
|
||||
| Lang | 900 s | Rollläden, Ventile, alles mit Motor |
|
||||
|
||||
Gespeichert werden Sekunden, angeboten werden nur die drei Stufen — eine
|
||||
vierte ist damit eine Zeile in `lockoutChoices()` und keine Wanderung durch
|
||||
die Datenbank.
|
||||
|
||||
Eine Flanke innerhalb der Sperrzeit wird **verworfen, nicht aufgehoben**. Ein
|
||||
Rollladen, der eine Viertelstunde später doch noch losfährt, weil vor langer
|
||||
Zeit einmal eine Schwelle gestreift wurde, wäre unangenehmer als einer, der
|
||||
gar nicht fährt — und der nächste echte Anlass nach Ablauf der Sperre kommt
|
||||
ohnehin durch. Verworfene Flanken stehen im Log auf `DEBUG`, nicht in
|
||||
`automation_log`; bei einem zappelnden Sensor wäre die Tabelle sonst voll
|
||||
davon.
|
||||
|
||||
`force_once` ist von der Sperre nicht betroffen: es greift nur, wenn im
|
||||
Fenster gar nichts gelaufen ist — dann ist auch keine Sperre aktiv.
|
||||
|
||||
## Transporte
|
||||
|
||||
Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`:
|
||||
|
||||
| URL | Messwert (`actor_states.url`) | Kommando (`actor_commands.command_url`) |
|
||||
|---|---|---|
|
||||
| `mqtt://…` | vollständiges Topic, abonniert; bei mehreren Messwerten je Topic zusätzlich `value_path` | Nutzlast auf das Parameter-Topic |
|
||||
| `http://…` | Feldname in der JSON-Antwort, gepollt | Abfrageargumente an die Geräte-URL (`turn=on`) |
|
||||
| `wled://…` | Pfad in `/json/state` (`seg[0].col[0]`), gepollt | JSON-Vorlage mit Platzhaltern, als Ganzes gesendet |
|
||||
| Tahoma | Statusname (`core:ClosureState`), gepollt | `exec/apply` an die Box |
|
||||
| `Logic` | gerechnet: Uhrzeit, Datum, Sonne | – |
|
||||
|
||||
Alle fünf stehen in `transports.py`. Eine sechste Geräteart kommt als weitere
|
||||
Klasse dazu; sie braucht `passt()`, `zustaende_lesen()` und `senden()`.
|
||||
|
||||
Tahoma ist der einzige, der nicht am URL-Schema erkannt wird, sondern an der
|
||||
**Box-Kennung** in der URL. Das Schema beschreibt dort die Funkart, und
|
||||
dieselbe Box liefert `io://` für die Jalousien, `rts://` für die Dachfenster
|
||||
und `internal://` für die Alarmanlage. Ohne `pin` in der `config.ini` ist
|
||||
niemand zuständig — dann meldet der Runner beim Auslösen „kein Transport",
|
||||
statt still nichts zu tun.
|
||||
|
||||
Mehrere Messwerte teilen sich oft **ein Topic**: der go-eCharger schickt
|
||||
sechzehn Zahlen als JSON-Feld auf `…/nrg`, und erst das `value_template` der
|
||||
Home-Assistant-Discovery sagt, dass „Strom L1" das fünfte Element ist. Diese
|
||||
Angabe steht in `actor_states.value_path` — in derselben Schreibweise, die
|
||||
auch WLED benutzt: `[4]`, `ssid`, `seg[0].col[0]`. Ohne Pfad gilt die ganze
|
||||
Nutzlast.
|
||||
|
||||
Gelesen wird nur der einfache Fall aus dem Template: ein Zugriff auf
|
||||
`value_json` und was danach an Punkten und Klammern folgt.
|
||||
|
||||
### Werttabellen
|
||||
|
||||
Manche Geräte schicken eine Zahl und meinen einen Zustand:
|
||||
|
||||
```
|
||||
{{ ['Unknown','Idle','Charging','WaitCar','Complete','Error'][value_json|int] }}
|
||||
{{ ['Default','Eco','NextTrip'][value_json|int-3] }}
|
||||
```
|
||||
|
||||
Das ist kein Pfad, sondern eine Übersetzung von Zahl nach Text. Sie landet in
|
||||
`possible_values` — in der Schreibweise, die WLED für seine Effektliste schon
|
||||
benutzt: eine Liste aus `{Wert: Bezeichnung}`. Ein Versatz im Ausdruck wandert
|
||||
dabei in die Schlüssel, aus `[value_json|int-3]` wird also `{"3":"Default"}`.
|
||||
|
||||
Der Runner übersetzt beim Lesen: aus der gesendeten `2` wird `Charging`. Eine
|
||||
Bedingung vergleicht damit genau den Klartext, den der Editor zur Auswahl
|
||||
stellt. Steht die Zahl nicht in der Tabelle, bleibt sie stehen — ein
|
||||
erfundener Name wäre schlimmer als ein roher Wert.
|
||||
|
||||
Auf beiden Seiten des Editors steckt dieselbe Tabelle, aber der gespeicherte
|
||||
Wert ist ein anderer:
|
||||
|
||||
| | angezeigt | gespeichert |
|
||||
|---|---|---|
|
||||
| **Messwert** (Bedingung) | `Charging` | `Charging` — der Runner hat schon übersetzt |
|
||||
| **Parameter** (Aktion) | `Blink` | `1` — das Gerät will die Zahl |
|
||||
|
||||
Bei WLED trägt die Kommando-Vorlage alles: `{"seg":[{"col":[[%red%,%green%,%blue%]]}]}`
|
||||
wird mit den Parameterwerten gefüllt und am Stück geschickt. Deshalb haben die
|
||||
Parameter dort keine eigene URL — ihr Name *ist* der Platzhalter.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
* Python 3 mit `pymysql`, `requests`, `paho-mqtt`
|
||||
* `homeMesh_automations.sql` einmal eingespielt
|
||||
* In `../deviceDiscovery/config.ini` muss **`clear_tables = false`** stehen.
|
||||
Discovery schreibt mit `ON DUPLICATE KEY UPDATE` auf den URLs, das Leeren ist
|
||||
unnötig — ein `TRUNCATE` würde dagegen die Geräte-IDs neu vergeben, und die
|
||||
Automatiken zeigen per Fremdschlüssel genau auf diese IDs.
|
||||
|
||||
## Einrichten
|
||||
|
||||
```bash
|
||||
cp config.ini.example config.ini # ausfüllen: Datenbank, MQTT, Tahoma
|
||||
python3 fetch_calendar.py # Feiertage und Ferien holen
|
||||
python3 autoaction_runner.py --once --dry-run --verbose # Probelauf
|
||||
```
|
||||
|
||||
`--dry-run` schaltet nichts, protokolliert aber jedes Kommando, das geschickt
|
||||
würde. `--once` macht einen einzigen Durchlauf.
|
||||
|
||||
Im Dauerbetrieb wird `autoaction_runner.py` beim Booten gestartet (auf der
|
||||
Synology über den Aufgabenplaner, Ereignis „Hochfahren", als root). Er
|
||||
verbindet sich selbst neu, wenn MQTT wegbricht, und lädt das Regelwerk nach,
|
||||
sobald im Browser etwas gespeichert wurde — ein Neustart nach jeder Änderung
|
||||
ist nicht nötig.
|
||||
|
||||
`fetch_calendar.py` gehört einmal jährlich in den Cron:
|
||||
|
||||
```
|
||||
0 4 1 1 * /usr/bin/python3 /volume1/web/smart/restricted/autoActions/fetch_calendar.py
|
||||
```
|
||||
|
||||
Ein zusätzlicher Lauf im Herbst schadet nicht — die Ferientermine des
|
||||
übernächsten Schuljahres stehen erst später fest.
|
||||
|
||||
## Nachsehen, was passiert ist
|
||||
|
||||
`automation_log` hält je Auslösung fest, ob sie durchlief (`fired`), wegen
|
||||
`force_once` nachgeholt wurde (`forced`) oder scheiterte (`error`, mit Grund in
|
||||
`detail`). Einträge älter als 30 Tage räumt der Runner selbst weg.
|
||||
@@ -1,848 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AutoAction-Runner - fuehrt die im Web-UI angelegten Automatiken aus.
|
||||
|
||||
Laeuft als Dauerprozess, nicht als Cronjob. Zwei Gruende:
|
||||
|
||||
* Schwellwert-Ausloeser ("Temperatur ueber 22 Grad") sollen sofort greifen,
|
||||
wenn die Nachricht hereinkommt, und nicht bis zum naechsten Minutenraster
|
||||
warten.
|
||||
* `actor_states.current_value` wird sonst von niemandem fortgeschrieben -
|
||||
beim Geraete-Discovery einmal gesetzt und danach nie wieder. Ein
|
||||
zustandsloser Cronjob haette also gar nichts, womit er vergleichen
|
||||
koennte. Der Runner pflegt den Wert nebenbei mit, wodurch auch der Editor
|
||||
im Browser aktuelle Zahlen anzeigt.
|
||||
|
||||
Ablauf:
|
||||
|
||||
Start Regelwerk und Geraetemodell laden, MQTT-Topics abonnieren
|
||||
Ereignis MQTT-Nachricht -> Wert merken -> im naechsten Takt auswerten
|
||||
Takt alle tick_seconds: Uhrzeit/Datum/Sonnenzeiten neu rechnen,
|
||||
HTTP- und Tahoma-Geraete pollen, Regelwerk auf Aenderung pruefen
|
||||
Pruefen aktiv? (enabled, Wochentag, Ferien/Feiertag, Zeitfenster)
|
||||
-> Bedingungen auswerten -> steigende Flanke -> Aktionen
|
||||
|
||||
Nur die steigende Flanke loest aus: `automations.cond_met` haelt fest, ob die
|
||||
Bedingung beim letzten Durchlauf schon erfuellt war. Ohne das wuerde
|
||||
"Temperatur ueber 22 Grad" bei jedem Takt erneut feuern.
|
||||
|
||||
Zeit-Ausloeser gibt es in drei Formen: "um 16:30" ist genau in dieser Minute
|
||||
wahr, "ab 16:30" von da an bis Mitternacht, "vor 16:30" bis dahin.
|
||||
Ausgeloest wird in allen drei Faellen nur einmal, eben wegen der Flanke -
|
||||
"ab" ist aber das robustere: faellt der Runner in der einen Minute aus, auf
|
||||
die "um" zeigt, ist die Automatik fuer den Tag verloren; bei "ab" holt der
|
||||
naechste Takt es nach. Wer "um" braucht und den Ausfall nicht riskieren
|
||||
will, hakt zusaetzlich force_once an. Dieselbe Ueberlegung steht hinter den
|
||||
breiten Zeitfenstern in auto_watering.py.
|
||||
|
||||
Beim Sonnenauf- und -untergang traegt der Operator zusaetzlich das
|
||||
Vorzeichen des Versatzes: "+ 00:30" eine halbe Stunde danach, ">=- 00:30"
|
||||
ab einer halben Stunde davor, "<+ 00:30" bis eine halbe Stunde danach.
|
||||
|
||||
Tabellen siehe homeMesh_automations.sql, Konfiguration siehe config.ini.example.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import pymysql
|
||||
import requests
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from transports import (HTTPTransport, LogicTransport, MQTTTransport,
|
||||
TahomaTransport, WLEDTransport)
|
||||
|
||||
logger = logging.getLogger("autoaction")
|
||||
|
||||
# Wie lange ein Messwert in der Datenbank stehen bleiben darf, bevor er
|
||||
# aufgefrischt wird. Der Wert dient nur der Anzeige im Editor; jede Nachricht
|
||||
# sofort zu schreiben waere bei einem gespraechigen Sensor sinnlose Last.
|
||||
SCHREIB_ABSTAND = timedelta(seconds=60)
|
||||
|
||||
# Aelteres im Protokoll interessiert niemanden mehr.
|
||||
LOG_AUFBEWAHRUNG_TAGE = 30
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Konfiguration
|
||||
# ===========================================================================
|
||||
|
||||
class Config:
|
||||
def __init__(self, dateiname="config.ini"):
|
||||
pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), dateiname)
|
||||
if not os.path.exists(pfad):
|
||||
raise FileNotFoundError(
|
||||
"%s fehlt - config.ini.example kopieren und ausfuellen." % pfad)
|
||||
self.cfg = configparser.ConfigParser()
|
||||
self.cfg.read(pfad, encoding="utf-8")
|
||||
|
||||
def text(self, sektion, schluessel, vorgabe=""):
|
||||
return self.cfg.get(sektion, schluessel, fallback=vorgabe).strip()
|
||||
|
||||
def zahl(self, sektion, schluessel, vorgabe=0):
|
||||
try:
|
||||
return self.cfg.getint(sektion, schluessel, fallback=vorgabe)
|
||||
except ValueError:
|
||||
return vorgabe
|
||||
|
||||
def ja(self, sektion, schluessel, vorgabe=False):
|
||||
return self.text(sektion, schluessel, str(vorgabe)).lower() in ("true", "1", "yes", "on")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Datenbank
|
||||
# ===========================================================================
|
||||
|
||||
def verbinden(config, sektion="database"):
|
||||
return pymysql.connect(
|
||||
host=config.text("database", "host", "localhost"),
|
||||
port=config.zahl("database", "port", 3306),
|
||||
user=config.text(sektion, "user") or config.text("database", "user"),
|
||||
password=config.text(sektion, "password") or config.text("database", "password"),
|
||||
database=config.text(sektion, "database"),
|
||||
charset="utf8mb4",
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
autocommit=True)
|
||||
|
||||
|
||||
class Regelwerk:
|
||||
"""
|
||||
Das geladene Abbild der Datenbank: Automatiken mit ihren Bedingungen und
|
||||
Aktionen, dazu die Messwerte und Kommandos, die sie benutzen.
|
||||
"""
|
||||
|
||||
def __init__(self, automatiken, states, kommandos, signatur):
|
||||
self.automatiken = automatiken
|
||||
self.states = states # state_id -> Beschreibung
|
||||
self.kommandos = kommandos # command_id -> Beschreibung
|
||||
self.signatur = signatur
|
||||
|
||||
@staticmethod
|
||||
def signatur_lesen(db):
|
||||
"""
|
||||
Woran der Runner merkt, dass er neu laden muss. `changed` allein
|
||||
genuegt nicht: eine geloeschte Automatik veraendert den groessten
|
||||
Zeitstempel nicht. Deshalb zaehlen die Zeilen mit - auch die der
|
||||
Geraetetabellen, damit ein Discovery-Lauf ebenfalls durchschlaegt.
|
||||
"""
|
||||
with db.cursor() as c:
|
||||
c.execute("""SELECT (SELECT COUNT(*) FROM automations) AS a,
|
||||
(SELECT UNIX_TIMESTAMP(MAX(changed)) FROM automations) AS t,
|
||||
(SELECT COUNT(*) FROM automation_conditions) AS b,
|
||||
(SELECT COUNT(*) FROM automation_actions) AS c,
|
||||
(SELECT COUNT(*) FROM actor_states) AS d,
|
||||
(SELECT COUNT(*) FROM actor_commands) AS e""")
|
||||
return tuple(sorted(c.fetchone().items()))
|
||||
|
||||
@classmethod
|
||||
def laden(cls, db):
|
||||
signatur = cls.signatur_lesen(db)
|
||||
|
||||
states = {}
|
||||
with db.cursor() as c:
|
||||
c.execute("""SELECT s.id, s.state_name, s.url AS state_url, s.value_path,
|
||||
s.current_value, s.possible_values,
|
||||
a.url AS actor_url, a.name AS actor_name, t.type
|
||||
FROM actor_states s
|
||||
JOIN actors a ON a.id = s.actor_id
|
||||
LEFT JOIN state_types t ON s.state_type = t.id""")
|
||||
for row in c.fetchall():
|
||||
row["type"] = row["type"] or "string"
|
||||
# Werttabelle als flaches {gesendeter Wert: Bezeichnung}. In
|
||||
# der Datenbank steht sie als Liste aus Ein-Schluessel-
|
||||
# Objekten, weil der Editor sie so schon versteht.
|
||||
row["wertetabelle"] = {}
|
||||
try:
|
||||
for eintrag in json.loads(row["possible_values"] or "[]"):
|
||||
if isinstance(eintrag, dict):
|
||||
for wert, name in eintrag.items():
|
||||
row["wertetabelle"][str(wert)] = name
|
||||
except ValueError:
|
||||
pass
|
||||
states[row["id"]] = row
|
||||
|
||||
kommandos = {}
|
||||
with db.cursor() as c:
|
||||
c.execute("""SELECT k.id, k.command_name, k.command_url,
|
||||
a.url AS actor_url, a.name AS actor_name
|
||||
FROM actor_commands k JOIN actors a ON a.id = k.actor_id""")
|
||||
for row in c.fetchall():
|
||||
row["params"] = []
|
||||
kommandos[row["id"]] = row
|
||||
with db.cursor() as c:
|
||||
c.execute("""SELECT id, command_id, parameter_name, url FROM command_parameters
|
||||
ORDER BY command_id, id""")
|
||||
for row in c.fetchall():
|
||||
if row["command_id"] in kommandos:
|
||||
kommandos[row["command_id"]]["params"].append(row)
|
||||
|
||||
automatiken = {}
|
||||
with db.cursor() as c:
|
||||
c.execute("SELECT * FROM automations WHERE enabled = 1")
|
||||
for row in c.fetchall():
|
||||
row["gruppen"] = {}
|
||||
row["aktionen"] = []
|
||||
automatiken[row["id"]] = row
|
||||
with db.cursor() as c:
|
||||
c.execute("""SELECT * FROM automation_conditions
|
||||
ORDER BY automation_id, group_no, position, id""")
|
||||
for row in c.fetchall():
|
||||
auto = automatiken.get(row["automation_id"])
|
||||
if auto is None:
|
||||
continue
|
||||
if row["state_id"] not in states:
|
||||
logger.warning("Automatik %s: Messwert %s gibt es nicht mehr",
|
||||
auto["name"], row["state_id"])
|
||||
continue
|
||||
auto["gruppen"].setdefault(row["group_no"], []).append(row)
|
||||
with db.cursor() as c:
|
||||
c.execute("""SELECT a.id, a.automation_id, a.command_id, p.parameter_id, p.value
|
||||
FROM automation_actions a
|
||||
LEFT JOIN automation_action_params p ON p.action_id = a.id
|
||||
ORDER BY a.automation_id, a.position, a.id""")
|
||||
gesammelt = {}
|
||||
for row in c.fetchall():
|
||||
auto = automatiken.get(row["automation_id"])
|
||||
if auto is None:
|
||||
continue
|
||||
aktion = gesammelt.get(row["id"])
|
||||
if aktion is None:
|
||||
aktion = {"command_id": row["command_id"], "werte": {}}
|
||||
gesammelt[row["id"]] = aktion
|
||||
auto["aktionen"].append(aktion)
|
||||
if row["parameter_id"] is not None:
|
||||
aktion["werte"][row["parameter_id"]] = row["value"]
|
||||
|
||||
logger.info("Regelwerk geladen: %d Automatiken, %d Messwerte, %d Kommandos",
|
||||
len(automatiken), len(states), len(kommandos))
|
||||
return cls(automatiken, states, kommandos, signatur)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Auswertung
|
||||
# ===========================================================================
|
||||
|
||||
def minuten(text):
|
||||
""""16:30" oder "16:30:00" als Minuten seit Mitternacht."""
|
||||
teile = str(text).strip().split(":")
|
||||
return int(teile[0]) * 60 + int(teile[1])
|
||||
|
||||
|
||||
def als_datum(text):
|
||||
for form in ("%d.%m.%Y", "%Y-%m-%d", "%d.%m.%y"):
|
||||
try:
|
||||
return datetime.strptime(str(text).strip(), form).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def als_zeitpunkt(text):
|
||||
"""Datum mit Uhrzeit. Das Web-Feld liefert "2026-08-30T16:30"."""
|
||||
roh = str(text).strip().replace("T", " ")
|
||||
for form in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M",
|
||||
"%d.%m.%Y %H:%M:%S", "%d.%m.%Y %H:%M"):
|
||||
try:
|
||||
return datetime.strptime(roh, form)
|
||||
except ValueError:
|
||||
continue
|
||||
tag = als_datum(roh.split(" ")[0])
|
||||
return datetime.combine(tag, datetime.min.time()) if tag else None
|
||||
|
||||
|
||||
WAHR = {"true", "1", "on", "ja", "yes", "an"}
|
||||
|
||||
|
||||
def bedingung_erfuellt(bedingung, state, wert, jetzt):
|
||||
"""
|
||||
Ein einzelner Vergleich. `wert` ist der aktuelle Messwert als Text, so wie
|
||||
er vom Geraet kam; `bedingung["value"]` die eingestellte Schwelle.
|
||||
Unbekannter Wert heisst nicht erfuellt - lieber nicht schalten als auf
|
||||
Verdacht schalten.
|
||||
"""
|
||||
if wert is None or wert == "":
|
||||
return False
|
||||
typ = state["type"]
|
||||
op = bedingung["operator"]
|
||||
soll = bedingung["value"]
|
||||
|
||||
try:
|
||||
if typ in ("integer", "float"):
|
||||
ist_z, soll_z = float(str(wert).replace(",", ".")), float(str(soll).replace(",", "."))
|
||||
if op == "=": return ist_z == soll_z
|
||||
if op == "!=": return ist_z != soll_z
|
||||
if op == ">": return ist_z > soll_z
|
||||
if op == "<": return ist_z < soll_z
|
||||
if op == ">=": return ist_z >= soll_z
|
||||
if op == "<=": return ist_z <= soll_z
|
||||
return False
|
||||
|
||||
if typ == "time":
|
||||
ist_m, soll_m = minuten(wert), minuten(soll)
|
||||
if op == "=": return ist_m == soll_m
|
||||
if op == "<": return ist_m < soll_m
|
||||
return ist_m >= soll_m
|
||||
|
||||
if typ == "deltatime":
|
||||
# Der Messwert ist der Sonnenauf- bzw. -untergang, die Schwelle
|
||||
# ein Versatz. Das Vorzeichen steckt im Operator, der Vergleich
|
||||
# davor: ">=-" heisst "ab einer halben Stunde davor".
|
||||
versatz = minuten(soll)
|
||||
ziel = minuten(wert) + (-versatz if op.endswith("-") else versatz)
|
||||
# Ueber den Tagesrand wird gerechnet, nicht abgeschnitten: "sechs
|
||||
# Stunden vor Sonnenaufgang" ist eine gewollte Angabe und landet
|
||||
# dann eben am Vorabend. Abgeschnitten waeren solche Faelle gar
|
||||
# nicht mehr formulierbar.
|
||||
#
|
||||
# Verglichen wird die Uhrzeit innerhalb des Tages. Ein Ziel
|
||||
# jenseits von Mitternacht gilt also als diese Uhrzeit am selben
|
||||
# Tag - bei "um" ist das genau der gemeinte Zeitpunkt, bei "ab"
|
||||
# und "vor" verschiebt sich der wahre Bereich entsprechend.
|
||||
ziel %= 1440
|
||||
jetzt_m = jetzt.hour * 60 + jetzt.minute
|
||||
if op.startswith(">="): return jetzt_m >= ziel
|
||||
if op.startswith("<"): return jetzt_m < ziel
|
||||
return jetzt_m == ziel
|
||||
|
||||
if typ in ("date", "datetime"):
|
||||
wandeln = als_datum if typ == "date" else als_zeitpunkt
|
||||
ist_d, soll_d = wandeln(wert), wandeln(soll)
|
||||
if ist_d is None or soll_d is None:
|
||||
return False
|
||||
if op == "=": return ist_d == soll_d
|
||||
if op == "<": return ist_d < soll_d
|
||||
return ist_d >= soll_d
|
||||
|
||||
if typ == "bool":
|
||||
ist_b = str(wert).strip().lower() in WAHR
|
||||
soll_b = str(soll).strip().lower() in WAHR
|
||||
return ist_b == soll_b if op == "=" else ist_b != soll_b
|
||||
|
||||
# string und alles Uebrige
|
||||
if op == "=":
|
||||
return str(wert).strip() == str(soll).strip()
|
||||
return str(wert).strip() != str(soll).strip()
|
||||
|
||||
except (ValueError, IndexError) as fehler:
|
||||
logger.debug("Vergleich %s %s %s nicht moeglich: %s", wert, op, soll, fehler)
|
||||
return False
|
||||
|
||||
|
||||
def gruppen_erfuellt(automatik, regelwerk, werte, jetzt):
|
||||
"""
|
||||
Gleiche group_no = UND, verschiedene = ODER. Eine Automatik ohne
|
||||
Bedingungen loest nie aus - sonst wuerde sie nach einem Discovery-Lauf,
|
||||
der ihren Messwert entfernt hat, ploetzlich dauernd feuern.
|
||||
"""
|
||||
if not automatik["gruppen"]:
|
||||
return False
|
||||
for bedingungen in automatik["gruppen"].values():
|
||||
if all(bedingung_erfuellt(b, regelwerk.states[b["state_id"]],
|
||||
werte.get(b["state_id"]), jetzt)
|
||||
for b in bedingungen):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def im_zeitfenster(jetzt, von, bis):
|
||||
"""von > bis heisst: das Fenster reicht ueber Mitternacht."""
|
||||
m = jetzt.hour * 60 + jetzt.minute
|
||||
a, b = minuten(von), minuten(bis)
|
||||
return a <= m <= b if a <= b else (m >= a or m <= b)
|
||||
|
||||
|
||||
def tag_passt(automatik, jetzt, kalender):
|
||||
if not automatik["weekdays"] & (1 << jetzt.weekday()):
|
||||
return False
|
||||
if kalender["feiertag"] and not automatik["on_holiday"]:
|
||||
return False
|
||||
if kalender["ferien"] and not automatik["on_vacation"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Versand
|
||||
# ===========================================================================
|
||||
|
||||
class Versand:
|
||||
"""
|
||||
Schickt Kommandos im Hintergrund - je Geraet der Reihe nach, ueber
|
||||
Geraete hinweg nebeneinander.
|
||||
|
||||
Ohne das blockiert ein einziges Kommando die ganze Auswertung: eine
|
||||
Jalousie zuzufahren dauert ueber eine Minute, weil zwischen den beiden
|
||||
Schwenkbefehlen auf das Ende der Fahrt gewartet werden muss (siehe
|
||||
TahomaTransport). So lange kaeme kein Zeit-Ausloeser mehr durch, kein
|
||||
Messwert wuerde zurueckgeschrieben, und mehrere Rollladen in einer
|
||||
Automatik wuerden sich aufaddieren.
|
||||
|
||||
Je Geraet eine Warteschlange mit einem eigenen Faden: zwei Kommandos an
|
||||
dieselbe Jalousie duerfen sich nicht ueberholen - "Neigung 0" und
|
||||
"Neigung 100" sind sonst wirkungslos oder vertauscht -, zwei Kommandos an
|
||||
verschiedene Jalousien duerfen ruhig gleichzeitig laufen.
|
||||
|
||||
Die Datenbank bleibt aussen vor: die Faeden melden ihr Ergebnis nur
|
||||
zurueck, geschrieben wird im Hauptfaden. Eine pymysql-Verbindung ist
|
||||
nicht fuer mehrere Faeden gedacht.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.warteschlangen = {} # actor_url -> Queue
|
||||
self.ergebnisse = queue.Queue()
|
||||
self.laeuft = True
|
||||
|
||||
def einreihen(self, automation_id, beschreibung, transport, auftrag):
|
||||
schlange = self.warteschlangen.get(auftrag["actor_url"])
|
||||
if schlange is None:
|
||||
schlange = queue.Queue()
|
||||
self.warteschlangen[auftrag["actor_url"]] = schlange
|
||||
faden = threading.Thread(target=self._arbeiten, args=(schlange,),
|
||||
name="versand", daemon=True)
|
||||
faden.start()
|
||||
schlange.put((automation_id, beschreibung, transport, auftrag))
|
||||
|
||||
def _arbeiten(self, schlange):
|
||||
while self.laeuft:
|
||||
posten = schlange.get()
|
||||
if posten is None:
|
||||
return
|
||||
automation_id, beschreibung, transport, auftrag = posten
|
||||
try:
|
||||
transport.senden(auftrag)
|
||||
self.ergebnisse.put((automation_id, beschreibung, None))
|
||||
except Exception as fehler:
|
||||
self.ergebnisse.put((automation_id, beschreibung, str(fehler)))
|
||||
finally:
|
||||
schlange.task_done()
|
||||
|
||||
def abholen(self):
|
||||
"""Alles, was seit dem letzten Mal fertig geworden ist."""
|
||||
fertig = []
|
||||
while True:
|
||||
try:
|
||||
fertig.append(self.ergebnisse.get_nowait())
|
||||
except queue.Empty:
|
||||
return fertig
|
||||
|
||||
def offen(self):
|
||||
return sum(s.unfinished_tasks for s in self.warteschlangen.values())
|
||||
|
||||
def beenden(self):
|
||||
self.laeuft = False
|
||||
for schlange in self.warteschlangen.values():
|
||||
schlange.put(None)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Der Runner
|
||||
# ===========================================================================
|
||||
|
||||
class Runner:
|
||||
|
||||
def __init__(self, config, dry_run=False):
|
||||
self.config = config
|
||||
self.dry_run = dry_run or config.ja("runner", "dry_run")
|
||||
self.db = verbinden(config)
|
||||
self.solar = verbinden(config, "solar")
|
||||
|
||||
self.werte = {} # state_id -> letzter bekannter Wert
|
||||
self.geschrieben = {} # state_id -> wann zuletzt in die DB
|
||||
self.war_aktiv = {} # automation_id -> war im Zeitfenster
|
||||
self.lief_im_fenster = {} # automation_id -> hat im Fenster ausgeloest
|
||||
self._sonne = (None, "00:00", "00:00") # (datum, aufgang, untergang)
|
||||
self._kalender = (None, {"feiertag": False, "ferien": False})
|
||||
self._letzte_saeuberung = None
|
||||
|
||||
self.versand = Versand()
|
||||
self.mqtt = self._mqtt_verbinden()
|
||||
self.transporte = [
|
||||
MQTTTransport(self.mqtt, self.dry_run),
|
||||
WLEDTransport(requests, dry_run=self.dry_run),
|
||||
HTTPTransport(requests, dry_run=self.dry_run),
|
||||
TahomaTransport(requests, config.text("tahoma", "pin"),
|
||||
config.text("tahoma", "token"),
|
||||
config.zahl("tahoma", "timeout", 10), self.dry_run),
|
||||
LogicTransport(self.sonnenzeiten),
|
||||
]
|
||||
self.regelwerk = None
|
||||
|
||||
# --- Aufbau ----------------------------------------------------------
|
||||
|
||||
def _mqtt_verbinden(self):
|
||||
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=self.config.text("mqtt", "client_id", "autoaction_runner"))
|
||||
benutzer = self.config.text("mqtt", "username")
|
||||
if benutzer:
|
||||
client.username_pw_set(benutzer, self.config.text("mqtt", "password"))
|
||||
client.on_message = self._mqtt_nachricht
|
||||
client.connect(self.config.text("mqtt", "broker", "localhost"),
|
||||
self.config.zahl("mqtt", "port", 1883), 60)
|
||||
client.loop_start()
|
||||
return client
|
||||
|
||||
def _mqtt_nachricht(self, client, userdata, nachricht):
|
||||
# Der Client laeuft schon, waehrend __init__ noch die Transporte baut.
|
||||
for transport in getattr(self, "transporte", []):
|
||||
if isinstance(transport, MQTTTransport):
|
||||
transport.nachricht(nachricht.topic, nachricht.payload)
|
||||
|
||||
def transport_fuer(self, actor_url):
|
||||
for transport in self.transporte:
|
||||
if transport.passt(actor_url):
|
||||
return transport
|
||||
return None
|
||||
|
||||
def regelwerk_laden(self):
|
||||
self.regelwerk = Regelwerk.laden(self.db)
|
||||
|
||||
# Welche Geraete Position und Neigung zusammen koennen. Nur die haben
|
||||
# die Kugelschreiber-Mechanik, und nur bei ihnen wird ein "Zu" zum
|
||||
# zweifachen Schwenken - siehe TahomaTransport.
|
||||
kombi = {k["actor_url"] for k in self.regelwerk.kommandos.values()
|
||||
if k["command_url"] == "setClosureAndOrientation"}
|
||||
for transport in self.transporte:
|
||||
if isinstance(transport, TahomaTransport):
|
||||
transport.kombigeraete_setzen(kombi)
|
||||
|
||||
# Jeder Transport bekommt die Messwerte, fuer die er zustaendig ist.
|
||||
for transport in self.transporte:
|
||||
passende = [{"id": s["id"], "actor_url": s["actor_url"],
|
||||
"state_url": s["state_url"], "value_path": s["value_path"],
|
||||
"wertetabelle": s["wertetabelle"]}
|
||||
for s in self.regelwerk.states.values()
|
||||
if transport.passt(s["actor_url"])]
|
||||
transport.zustaende_anmelden(passende)
|
||||
# Der zuletzt bekannte Wert aus der Datenbank ist besser als gar
|
||||
# keiner: nach einem Neustart steht sonst jede Bedingung auf "unklar",
|
||||
# bis das Geraet zufaellig etwas schickt.
|
||||
for s in self.regelwerk.states.values():
|
||||
if s["id"] not in self.werte and s["current_value"] is not None:
|
||||
self.werte[s["id"]] = s["current_value"]
|
||||
|
||||
# --- Umgebung --------------------------------------------------------
|
||||
|
||||
def sonnenzeiten(self):
|
||||
"""Aus solarLog.daylight, einmal je Tag geholt."""
|
||||
heute = date.today()
|
||||
if self._sonne[0] == heute:
|
||||
return self._sonne[1], self._sonne[2]
|
||||
auf, unter = "00:00", "00:00"
|
||||
try:
|
||||
with self.solar.cursor() as c:
|
||||
c.execute("SELECT sunrise, sunset FROM daylight WHERE date = %s", (heute,))
|
||||
zeile = c.fetchone()
|
||||
if zeile:
|
||||
auf = self._als_uhrzeit(zeile["sunrise"])
|
||||
unter = self._als_uhrzeit(zeile["sunset"])
|
||||
else:
|
||||
logger.warning("Kein Eintrag in daylight fuer %s", heute)
|
||||
except Exception as fehler:
|
||||
logger.warning("Sonnenzeiten nicht lesbar: %s", fehler)
|
||||
self._sonne = (heute, auf, unter)
|
||||
return auf, unter
|
||||
|
||||
@staticmethod
|
||||
def _als_uhrzeit(wert):
|
||||
"""TIME-Spalten liefert pymysql als timedelta, nicht als Text."""
|
||||
if isinstance(wert, timedelta):
|
||||
minute = int(wert.total_seconds()) // 60
|
||||
return "%02d:%02d" % (minute // 60 % 24, minute % 60)
|
||||
return str(wert)[:5]
|
||||
|
||||
def kalender(self):
|
||||
"""Ferien und Feiertage von heute, einmal je Tag geholt."""
|
||||
heute = date.today()
|
||||
if self._kalender[0] == heute:
|
||||
return self._kalender[1]
|
||||
stand = {"feiertag": False, "ferien": False}
|
||||
try:
|
||||
with self.db.cursor() as c:
|
||||
c.execute("SELECT holiday, vacation FROM calendar_days WHERE date = %s", (heute,))
|
||||
zeile = c.fetchone()
|
||||
if zeile:
|
||||
stand = {"feiertag": bool(zeile["holiday"]), "ferien": bool(zeile["vacation"])}
|
||||
except Exception as fehler:
|
||||
logger.warning("Kalender nicht lesbar: %s", fehler)
|
||||
self._kalender = (heute, stand)
|
||||
return stand
|
||||
|
||||
# --- Werte -----------------------------------------------------------
|
||||
|
||||
def werte_einsammeln(self, mit_pollen):
|
||||
neu = {}
|
||||
for transport in self.transporte:
|
||||
if isinstance(transport, MQTTTransport) or mit_pollen:
|
||||
neu.update(transport.zustaende_lesen())
|
||||
if neu:
|
||||
self.werte.update(neu)
|
||||
self.werte_zurueckschreiben(neu)
|
||||
return neu
|
||||
|
||||
def werte_zurueckschreiben(self, neu):
|
||||
"""
|
||||
current_value nachfuehren, damit der Editor im Browser aktuelle Zahlen
|
||||
zeigt ("Temperatur (= 21,4 °C)"). Gedrosselt, sonst schreibt ein
|
||||
gespraechiger Sensor die Tabelle im Sekundentakt voll.
|
||||
"""
|
||||
jetzt = datetime.now()
|
||||
faellig = [(w, i) for i, w in neu.items()
|
||||
if self.geschrieben.get(i, datetime.min) + SCHREIB_ABSTAND <= jetzt]
|
||||
if not faellig:
|
||||
return
|
||||
try:
|
||||
with self.db.cursor() as c:
|
||||
c.executemany("UPDATE actor_states SET current_value = %s WHERE id = %s", faellig)
|
||||
for _, state_id in faellig:
|
||||
self.geschrieben[state_id] = jetzt
|
||||
except Exception as fehler:
|
||||
logger.warning("current_value nicht schreibbar: %s", fehler)
|
||||
|
||||
# --- Ausfuehren ------------------------------------------------------
|
||||
|
||||
def ausloesen(self, automatik, anlass):
|
||||
"""
|
||||
Die Aktionen einer Automatik in den Versand geben.
|
||||
|
||||
Geschickt wird im Hintergrund - eine Jalousie zuzufahren dauert ueber
|
||||
eine Minute, und so lange darf die Auswertung nicht stehen. Was dabei
|
||||
schiefgeht, kommt spaeter ueber ergebnisse_verbuchen() ins Protokoll.
|
||||
"""
|
||||
fehlerText = []
|
||||
eingereiht = 0
|
||||
for aktion in automatik["aktionen"]:
|
||||
kommando = self.regelwerk.kommandos.get(aktion["command_id"])
|
||||
if kommando is None:
|
||||
fehlerText.append("Kommando %s gibt es nicht mehr" % aktion["command_id"])
|
||||
logger.error("%s: Kommando %s gibt es nicht mehr",
|
||||
automatik["name"], aktion["command_id"])
|
||||
continue
|
||||
transport = self.transport_fuer(kommando["actor_url"])
|
||||
if transport is None:
|
||||
fehlerText.append("Kein Transport fuer %s" % kommando["actor_url"])
|
||||
logger.error("%s: fuer %s (%s) gibt es keinen Transport",
|
||||
automatik["name"], kommando["actor_name"], kommando["actor_url"])
|
||||
continue
|
||||
auftrag = {
|
||||
"actor_url": kommando["actor_url"],
|
||||
"command_url": kommando["command_url"],
|
||||
"params": [{"url": p["url"], "name": p["parameter_name"],
|
||||
"wert": aktion["werte"].get(p["id"], "")}
|
||||
for p in kommando["params"]],
|
||||
}
|
||||
beschreibung = "%s: %s" % (kommando["actor_name"], kommando["command_name"])
|
||||
logger.info("%s: %s (unterwegs)", automatik["name"], beschreibung)
|
||||
self.versand.einreihen(automatik["id"], beschreibung, transport, auftrag)
|
||||
eingereiht += 1
|
||||
|
||||
ergebnis = "error" if fehlerText else anlass
|
||||
detail = "; ".join(fehlerText)[:255] if fehlerText \
|
||||
else ("%d Kommando(s) unterwegs" % eingereiht)
|
||||
try:
|
||||
with self.db.cursor() as c:
|
||||
c.execute("""UPDATE automations SET last_run = NOW(), changed = changed
|
||||
WHERE id = %s""", (automatik["id"],))
|
||||
c.execute("""INSERT INTO automation_log (automation_id, result, detail)
|
||||
VALUES (%s, %s, %s)""", (automatik["id"], ergebnis, detail))
|
||||
except Exception as fehler:
|
||||
logger.warning("Protokoll nicht schreibbar: %s", fehler)
|
||||
automatik["last_run"] = datetime.now()
|
||||
self.lief_im_fenster[automatik["id"]] = True
|
||||
|
||||
def ergebnisse_verbuchen(self):
|
||||
"""
|
||||
Was der Versand inzwischen erledigt hat ins Protokoll schreiben.
|
||||
|
||||
Nur Fehlschlaege bekommen eine eigene Zeile - der Lauf selbst steht
|
||||
schon drin, und ein Protokoll, das jedes gelungene Kommando einzeln
|
||||
auffuehrt, findet niemand mehr etwas darin.
|
||||
"""
|
||||
for automation_id, beschreibung, fehler in self.versand.abholen():
|
||||
if fehler is None:
|
||||
logger.debug("erledigt: %s", beschreibung)
|
||||
continue
|
||||
logger.error("%s konnte nicht geschickt werden: %s", beschreibung, fehler)
|
||||
try:
|
||||
with self.db.cursor() as c:
|
||||
c.execute("""INSERT INTO automation_log (automation_id, result, detail)
|
||||
VALUES (%s, 'error', %s)""",
|
||||
(automation_id, ("%s: %s" % (beschreibung, fehler))[:255]))
|
||||
except Exception as schreibfehler:
|
||||
logger.warning("Protokoll nicht schreibbar: %s", schreibfehler)
|
||||
|
||||
def flanke_merken(self, automatik, erfuellt):
|
||||
if bool(automatik["cond_met"]) == bool(erfuellt):
|
||||
return
|
||||
automatik["cond_met"] = 1 if erfuellt else 0
|
||||
try:
|
||||
with self.db.cursor() as c:
|
||||
c.execute("""UPDATE automations SET cond_met = %s, changed = changed
|
||||
WHERE id = %s""", (automatik["cond_met"], automatik["id"]))
|
||||
except Exception as fehler:
|
||||
logger.warning("cond_met nicht schreibbar: %s", fehler)
|
||||
|
||||
def durchlauf(self):
|
||||
jetzt = datetime.now()
|
||||
kalender = self.kalender()
|
||||
|
||||
for automatik in self.regelwerk.automatiken.values():
|
||||
aktiv = (tag_passt(automatik, jetzt, kalender)
|
||||
and im_zeitfenster(jetzt, automatik["window_from"], automatik["window_to"]))
|
||||
vorher_aktiv = self.war_aktiv.get(automatik["id"], aktiv)
|
||||
|
||||
if aktiv:
|
||||
if not vorher_aktiv:
|
||||
self.lief_im_fenster[automatik["id"]] = False
|
||||
erfuellt = gruppen_erfuellt(automatik, self.regelwerk, self.werte, jetzt)
|
||||
if erfuellt and not automatik["cond_met"]:
|
||||
if self.gesperrt(automatik, jetzt):
|
||||
logger.debug("%s: Flanke faellt in die Sperrzeit, uebersprungen",
|
||||
automatik["name"])
|
||||
else:
|
||||
self.ausloesen(automatik, "fired")
|
||||
self.flanke_merken(automatik, erfuellt)
|
||||
else:
|
||||
# Das Fenster ist gerade zugegangen. Wer "auf jeden Fall"
|
||||
# angehakt hat, bekommt jetzt seinen Lauf - aber nur, wenn in
|
||||
# diesem Fenster noch keiner stattgefunden hat. Nach einem
|
||||
# Neustart mitten im Fenster weiss der Runner das nicht mehr
|
||||
# aus dem Speicher, deshalb zaehlt zusaetzlich last_run.
|
||||
if vorher_aktiv and automatik["force_once"] and not self.lief_im_fenster.get(automatik["id"]):
|
||||
if not self.lief_heute(automatik, jetzt):
|
||||
logger.info("%s: Zeitfenster vorbei, wird trotzdem ausgefuehrt",
|
||||
automatik["name"])
|
||||
self.ausloesen(automatik, "forced")
|
||||
# Ausserhalb des Fensters die Flanke zuruecksetzen, sonst
|
||||
# koennte sie im naechsten Fenster nicht mehr steigen.
|
||||
self.flanke_merken(automatik, False)
|
||||
self.lief_im_fenster[automatik["id"]] = False
|
||||
|
||||
self.war_aktiv[automatik["id"]] = aktiv
|
||||
|
||||
@staticmethod
|
||||
def gesperrt(automatik, jetzt):
|
||||
"""
|
||||
Liegt die letzte Ausloesung noch innerhalb der Sperrzeit?
|
||||
|
||||
Gegen Messwerte, die um die Schwelle pendeln: "Temperatur > 22" bei
|
||||
22,1 / 21,9 / 22,1 Grad ist jedes Mal eine echte steigende Flanke, und
|
||||
ueber MQTT koennen die Werte im Sekundentakt hereinkommen.
|
||||
|
||||
Die Flanke wird dabei verworfen und nicht aufgehoben. Ein Rollladen,
|
||||
der eine Viertelstunde spaeter doch noch losfaehrt, weil vor langer
|
||||
Zeit einmal eine Schwelle gestreift wurde, waere unangenehmer als
|
||||
einer, der gar nicht faehrt. Der naechste echte Anlass nach Ablauf
|
||||
der Sperre kommt ohnehin durch.
|
||||
|
||||
force_once ist davon nicht betroffen: es greift nur, wenn im Fenster
|
||||
gar nichts gelaufen ist - dann ist auch keine Sperre aktiv.
|
||||
"""
|
||||
sperre = int(automatik.get("lockout_secs") or 0)
|
||||
letzter = automatik.get("last_run")
|
||||
if not sperre or not letzter:
|
||||
return False
|
||||
return (jetzt - letzter).total_seconds() < sperre
|
||||
|
||||
@staticmethod
|
||||
def lief_heute(automatik, jetzt):
|
||||
letzter = automatik.get("last_run")
|
||||
return bool(letzter) and letzter.date() == jetzt.date()
|
||||
|
||||
def protokoll_saeubern(self):
|
||||
heute = date.today()
|
||||
if self._letzte_saeuberung == heute:
|
||||
return
|
||||
self._letzte_saeuberung = heute
|
||||
try:
|
||||
with self.db.cursor() as c:
|
||||
c.execute("DELETE FROM automation_log WHERE ts < NOW() - INTERVAL %s DAY",
|
||||
(LOG_AUFBEWAHRUNG_TAGE,))
|
||||
except Exception as fehler:
|
||||
logger.warning("Protokoll nicht aufraeumbar: %s", fehler)
|
||||
|
||||
# --- Hauptschleife ---------------------------------------------------
|
||||
|
||||
def laufen(self, nur_einmal=False):
|
||||
self.regelwerk_laden()
|
||||
takt = self.config.zahl("runner", "tick_seconds", 30)
|
||||
poll = self.config.zahl("runner", "poll_seconds", 60)
|
||||
neuladen = self.config.zahl("runner", "reload_seconds", 30)
|
||||
letztes_pollen = 0.0
|
||||
letztes_pruefen = 0.0
|
||||
|
||||
while True:
|
||||
jetzt = time.monotonic()
|
||||
mit_pollen = jetzt - letztes_pollen >= poll
|
||||
if mit_pollen:
|
||||
letztes_pollen = jetzt
|
||||
|
||||
self.werte_einsammeln(mit_pollen)
|
||||
self.durchlauf()
|
||||
self.ergebnisse_verbuchen()
|
||||
self.protokoll_saeubern()
|
||||
|
||||
if jetzt - letztes_pruefen >= neuladen:
|
||||
letztes_pruefen = jetzt
|
||||
try:
|
||||
if Regelwerk.signatur_lesen(self.db) != self.regelwerk.signatur:
|
||||
logger.info("Regelwerk hat sich geaendert, wird neu geladen")
|
||||
self.regelwerk_laden()
|
||||
except Exception as fehler:
|
||||
logger.warning("Regelwerk nicht pruefbar: %s", fehler)
|
||||
|
||||
if nur_einmal:
|
||||
return
|
||||
time.sleep(takt)
|
||||
|
||||
def beenden(self):
|
||||
offen = self.versand.offen()
|
||||
if offen:
|
||||
logger.info("%d Kommando(s) noch unterwegs - wird nicht abgewartet", offen)
|
||||
self.versand.beenden()
|
||||
try:
|
||||
self.mqtt.loop_stop()
|
||||
self.mqtt.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fuehrt die Automatiken aus dem Web-UI aus.")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="nichts wirklich schalten, nur protokollieren")
|
||||
parser.add_argument("--once", action="store_true",
|
||||
help="einen einzigen Durchlauf, dann beenden")
|
||||
parser.add_argument("--verbose", action="store_true", help="DEBUG-Ausgaben")
|
||||
parser.add_argument("--config", default="config.ini")
|
||||
args = parser.parse_args()
|
||||
|
||||
config = Config(args.config)
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else getattr(
|
||||
logging, config.text("runner", "log_level", "INFO").upper(), logging.INFO),
|
||||
format="%(asctime)s - %(levelname)s - %(message)s")
|
||||
|
||||
runner = Runner(config, args.dry_run)
|
||||
if runner.dry_run:
|
||||
logger.info("Probelauf: es wird nichts geschaltet.")
|
||||
try:
|
||||
runner.laufen(args.once)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Abbruch durch Benutzer")
|
||||
finally:
|
||||
runner.beenden()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,75 +0,0 @@
|
||||
# ============================================================================
|
||||
# AutoAction-Runner - Konfiguration
|
||||
# ============================================================================
|
||||
# Kopieren nach config.ini und ausfuellen. config.ini ist nicht versioniert
|
||||
# (siehe .gitignore), weil hier Zugangsdaten stehen.
|
||||
|
||||
# ============================================================================
|
||||
# DATENBANK - Geraete und Automatiken (homeMesh)
|
||||
# ============================================================================
|
||||
[database]
|
||||
host = nas.fritz.box
|
||||
port = 3310
|
||||
database = homeMesh
|
||||
user = homeMesh
|
||||
password =
|
||||
|
||||
# ============================================================================
|
||||
# DATENBANK - Messwerte (solarLog)
|
||||
# ============================================================================
|
||||
# Nur fuer die Tabelle `daylight`, aus der Sonnenauf- und -untergang kommen.
|
||||
# Leer lassen, wenn dieselben Zugangsdaten wie oben gelten.
|
||||
[solar]
|
||||
database = solarLog
|
||||
user =
|
||||
password =
|
||||
|
||||
# ============================================================================
|
||||
# MQTT
|
||||
# ============================================================================
|
||||
[mqtt]
|
||||
broker = nas.fritz.box
|
||||
port = 1883
|
||||
username =
|
||||
password =
|
||||
client_id = autoaction_runner
|
||||
|
||||
# ============================================================================
|
||||
# TAHOMA
|
||||
# ============================================================================
|
||||
# Fuer Aktoren, deren URL mit io:// beginnt. Ohne Token bleiben sie stumm -
|
||||
# der Runner protokolliert das dann als Fehler, statt still nichts zu tun.
|
||||
[tahoma]
|
||||
pin =
|
||||
token =
|
||||
timeout = 10
|
||||
|
||||
# ============================================================================
|
||||
# KALENDER
|
||||
# ============================================================================
|
||||
# Fuer fetch_calendar.py, das Feiertage und Schulferien in calendar_days
|
||||
# eintraegt. Regionscodes siehe openholidaysapi.org, Bayern ist DE-BY.
|
||||
[calendar]
|
||||
country = DE
|
||||
subdivision = DE-BY
|
||||
|
||||
# ============================================================================
|
||||
# LAUFZEIT
|
||||
# ============================================================================
|
||||
[runner]
|
||||
# Abstand der Auswertung in Sekunden. MQTT-Nachrichten werden sofort
|
||||
# verarbeitet; der Takt ist fuer die Zeit-Ausloeser und fuer das Pollen.
|
||||
tick_seconds = 30
|
||||
|
||||
# Wie oft Geraete abgefragt werden, die nichts von sich aus melden
|
||||
# (Shelly ueber HTTP, Tahoma). In Sekunden.
|
||||
poll_seconds = 60
|
||||
|
||||
# Wie oft der Runner nachsieht, ob sich das Regelwerk geaendert hat.
|
||||
reload_seconds = 30
|
||||
|
||||
# Nichts wirklich schalten, nur protokollieren. Zum Ausprobieren neuer Regeln.
|
||||
dry_run = false
|
||||
|
||||
# DEBUG, INFO, WARNING, ERROR
|
||||
log_level = INFO
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fuellt die Tabelle `calendar_days` mit Feiertagen und Schulferien.
|
||||
|
||||
Die Automatiken koennen mit "an Feiertagen ausfuehren" und "in den Ferien
|
||||
ausfuehren" auf besondere Tage reagieren; woher das Wissen kommt, steht hier.
|
||||
Nur besondere Tage werden eingetragen - ein fehlendes Datum ist ein
|
||||
gewoehnlicher Tag.
|
||||
|
||||
Quelle ist openholidaysapi.org: ein offenes Verzeichnis der EU-Kommission, das
|
||||
gesetzliche Feiertage und Schulferien fuer alle deutschen Bundeslaender
|
||||
liefert. Ein Aufruf je Jahr genuegt, deshalb ist das ein Cronjob und kein
|
||||
Dauerlaeufer:
|
||||
|
||||
0 4 1 1 * /usr/bin/python3 /pfad/zu/fetch_calendar.py
|
||||
|
||||
Ein zusaetzlicher Lauf im Herbst schadet nicht - die Ferientermine des
|
||||
uebernaechsten Schuljahres stehen erst spaeter fest.
|
||||
|
||||
Bereits eingetragene Tage werden ueberschrieben, nie doppelt angelegt.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pymysql
|
||||
import requests
|
||||
|
||||
from autoaction_runner import Config, verbinden
|
||||
|
||||
logger = logging.getLogger("kalender")
|
||||
|
||||
BASIS = "https://openholidaysapi.org"
|
||||
|
||||
|
||||
def zeitraum(api, land, region, von, bis):
|
||||
"""Eintraege einer Art (PublicHolidays oder SchoolHolidays) abholen."""
|
||||
antwort = requests.get(
|
||||
BASIS + "/" + api,
|
||||
params={"countryIsoCode": land, "subdivisionCode": region,
|
||||
"languageIsoCode": "DE", "validFrom": von.isoformat(),
|
||||
"validTo": bis.isoformat()},
|
||||
headers={"Accept": "application/json"}, timeout=20)
|
||||
antwort.raise_for_status()
|
||||
return antwort.json()
|
||||
|
||||
|
||||
def name(eintrag):
|
||||
for teil in eintrag.get("name", []):
|
||||
if teil.get("text"):
|
||||
return teil["text"][:80]
|
||||
return "?"
|
||||
|
||||
|
||||
def tage(eintrag):
|
||||
"""Ferien erstrecken sich ueber Wochen; hier wird daraus Tag fuer Tag."""
|
||||
start = date.fromisoformat(eintrag["startDate"])
|
||||
ende = date.fromisoformat(eintrag["endDate"])
|
||||
while start <= ende:
|
||||
yield start
|
||||
start += timedelta(days=1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Feiertage und Ferien in calendar_days schreiben.")
|
||||
parser.add_argument("--jahr", type=int, default=date.today().year)
|
||||
parser.add_argument("--jahre", type=int, default=2, help="wie viele Jahre ab --jahr")
|
||||
parser.add_argument("--config", default="config.ini")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
||||
config = Config(args.config)
|
||||
land = config.text("calendar", "country", "DE")
|
||||
region = config.text("calendar", "subdivision", "DE-BY")
|
||||
|
||||
von = date(args.jahr, 1, 1)
|
||||
bis = date(args.jahr + args.jahre - 1, 12, 31)
|
||||
logger.info("Hole %s bis %s fuer %s", von, bis, region)
|
||||
|
||||
gesammelt = {}
|
||||
for eintrag in zeitraum("PublicHolidays", land, region, von, bis):
|
||||
for tag in tage(eintrag):
|
||||
gesammelt.setdefault(tag, {})["holiday"] = name(eintrag)
|
||||
for eintrag in zeitraum("SchoolHolidays", land, region, von, bis):
|
||||
for tag in tage(eintrag):
|
||||
gesammelt.setdefault(tag, {})["vacation"] = name(eintrag)
|
||||
|
||||
db = verbinden(config)
|
||||
with db.cursor() as c:
|
||||
c.executemany(
|
||||
"""INSERT INTO calendar_days (date, holiday, vacation) VALUES (%s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE holiday = VALUES(holiday), vacation = VALUES(vacation)""",
|
||||
[(tag, eintrag.get("holiday"), eintrag.get("vacation"))
|
||||
for tag, eintrag in sorted(gesammelt.items())])
|
||||
logger.info("%d Tage eingetragen", len(gesammelt))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,626 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Transporte fuer den AutoAction-Runner.
|
||||
|
||||
Ein Transport weiss, wie man mit einer Sorte Geraet redet - er liest deren
|
||||
Messwerte und schickt deren Kommandos. Welcher zustaendig ist, entscheidet die
|
||||
URL des Aktors in der Tabelle `actors`:
|
||||
|
||||
mqtt://... MQTT-Geraete (Home-Assistant-Discovery)
|
||||
http://... Shelly und andere HTTP-Geraete
|
||||
wled://... WLED-Lampen
|
||||
io://, rts://, internal://, ogp:// Tahoma - das Schema haengt an der
|
||||
Funkart des Geraets, deshalb wird dort nicht danach
|
||||
entschieden, sondern an der Box-Kennung in der URL
|
||||
Logic das gerechnete Geraet "Zeitpunkt" (Uhrzeit, Datum, Sonne)
|
||||
|
||||
Alle liegen in einer Datei statt in einem Paket wie bei deviceDiscovery: es
|
||||
sind fuenf kurze Klassen, und wer eine sechste Geraeteart anschliesst, sieht
|
||||
hier auf einen Blick, was dafuer zu tun ist.
|
||||
|
||||
Jeder Transport hat zwei Haelften:
|
||||
|
||||
zustaende_anmelden(states) einmalig beim Start
|
||||
zustaende_lesen() liefert {state_id: wert} - nur was neu ist
|
||||
senden(aktion) fuehrt ein Kommando aus
|
||||
|
||||
`states` ist eine Liste von Dicts mit actor_url, state_url, value_path und
|
||||
id, `aktion` ein Dict mit actor_url, command_url und params (Liste aus
|
||||
{url, name, wert}).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import urllib3
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
|
||||
logger = logging.getLogger("autoaction.transport")
|
||||
|
||||
# Die Tahoma-Box hat ein selbst ausgestelltes Zertifikat auf einen Namen, den
|
||||
# nur das Heimnetz kennt. Die Pruefung ist dort bewusst aus (wie in
|
||||
# ajax/tahoma.php); ohne diese Zeile warnt urllib3 bei jeder einzelnen
|
||||
# Abfrage und uebertoent das eigentliche Protokoll.
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
|
||||
def wert_aus_pfad(daten, pfad):
|
||||
"""
|
||||
Einen Teilwert aus einer Nutzlast holen: "[3]", "ssid", "seg[0].col[0]".
|
||||
Ein leerer Pfad heisst: die Nutzlast selbst.
|
||||
|
||||
Gebraucht wird das an zwei Stellen. MQTT-Geraete legen mehrere Messwerte
|
||||
auf ein Topic - der go-eCharger schickt sechzehn Zahlen als JSON-Feld -,
|
||||
und WLED liefert seinen gesamten Zustand als ein Dokument.
|
||||
"""
|
||||
if not pfad:
|
||||
return daten
|
||||
for teil in pfad.split("."):
|
||||
treffer = re.match(r"^([^\[]*)((?:\[\d+\])*)$", teil)
|
||||
if not treffer:
|
||||
raise KeyError(pfad)
|
||||
if treffer.group(1):
|
||||
daten = daten[treffer.group(1)]
|
||||
for index in re.findall(r"\[(\d+)\]", treffer.group(2)):
|
||||
daten = daten[int(index)]
|
||||
return daten
|
||||
|
||||
|
||||
def uebersetze(wert, tabelle):
|
||||
"""
|
||||
Aus der gesendeten Zahl den Zustandsnamen machen: aus "2" wird "Charging".
|
||||
|
||||
Manche Geraete schicken einen Zahlencode und meinen einen Zustand. Welche
|
||||
Zahl welchen Namen hat, steht in possible_values - derselben Spalte, aus
|
||||
der auch der Editor seine Auswahlliste baut. Eine Bedingung vergleicht
|
||||
damit genau den Klartext, den man dort ausgewaehlt hat.
|
||||
|
||||
Steht die Zahl nicht in der Tabelle, bleibt sie stehen: ein erfundener
|
||||
Name waere schlimmer als ein roher Wert.
|
||||
"""
|
||||
if not tabelle:
|
||||
return wert
|
||||
if wert in tabelle:
|
||||
return tabelle[wert]
|
||||
try: # "2.0" und "2" meinen dieselbe Stufe
|
||||
ganz = str(int(float(wert)))
|
||||
except (TypeError, ValueError):
|
||||
return wert
|
||||
return tabelle.get(ganz, wert)
|
||||
|
||||
|
||||
class Transport:
|
||||
"""Gemeinsame Form. Wer nichts zu lesen hat, erbt die leeren Methoden."""
|
||||
|
||||
schema = ""
|
||||
|
||||
def passt(self, actor_url):
|
||||
return actor_url.startswith(self.schema)
|
||||
|
||||
def zustaende_anmelden(self, states):
|
||||
pass
|
||||
|
||||
def zustaende_lesen(self):
|
||||
return {}
|
||||
|
||||
def senden(self, aktion):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MQTT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MQTTTransport(Transport):
|
||||
"""
|
||||
Die state_url ist hier das vollstaendige Topic, die parameter_url das
|
||||
Kommando-Topic. Werte kommen von selbst herein und landen in einem
|
||||
Zwischenspeicher, den der Runner im Takt abholt.
|
||||
"""
|
||||
|
||||
schema = "mqtt://"
|
||||
|
||||
def __init__(self, client, dry_run=False):
|
||||
self.client = client
|
||||
self.dry_run = dry_run
|
||||
self.topics = {} # topic -> [(state_id, value_path, wertetabelle), ...]
|
||||
self.neu = {} # state_id -> wert
|
||||
|
||||
def zustaende_anmelden(self, states):
|
||||
self.topics = {}
|
||||
for s in states:
|
||||
if not s["state_url"]:
|
||||
continue
|
||||
self.topics.setdefault(s["state_url"], []).append(
|
||||
(s["id"], s.get("value_path"), s.get("wertetabelle") or {}))
|
||||
for topic in self.topics:
|
||||
self.client.subscribe(topic)
|
||||
mehrfach = sum(1 for e in self.topics.values() if len(e) > 1)
|
||||
logger.info("MQTT: %d Topics abonniert, %d davon mit mehreren Messwerten",
|
||||
len(self.topics), mehrfach)
|
||||
|
||||
def nachricht(self, topic, payload):
|
||||
"""Wird vom Runner aus dem on_message-Rueckruf gerufen."""
|
||||
eintraege = self.topics.get(topic, [])
|
||||
if not eintraege:
|
||||
return
|
||||
text = payload.decode("utf-8", "replace").strip() if isinstance(payload, bytes) else str(payload)
|
||||
try:
|
||||
daten = json.loads(text)
|
||||
except ValueError:
|
||||
daten = None # kein JSON - dann gilt der Rohtext
|
||||
for state_id, pfad, tabelle in eintraege:
|
||||
wert = self._wert(text, daten, pfad, tabelle)
|
||||
if wert is not None:
|
||||
self.neu[state_id] = wert
|
||||
|
||||
@staticmethod
|
||||
def _wert(text, daten, pfad, tabelle=None):
|
||||
"""
|
||||
Aus der Nutzlast den Wert eines einzelnen Messwerts machen.
|
||||
|
||||
Mehrere Messwerte teilen sich oft ein Topic; welcher Teil gemeint ist,
|
||||
steht in value_path - gelesen aus dem value_template der
|
||||
Home-Assistant-Discovery. Ohne Pfad gilt die ganze Nutzlast, und
|
||||
JSON-Skalare werden ausgepackt: manche Geraete schicken 21.4 mit
|
||||
Anfuehrungszeichen, andere true statt ON.
|
||||
|
||||
Steht eine Werttabelle dabei, wird aus der gesendeten Zahl der
|
||||
Zustandsname: aus 2 wird "Charging".
|
||||
"""
|
||||
if daten is None:
|
||||
return uebersetze(text, tabelle)
|
||||
try:
|
||||
wert = wert_aus_pfad(daten, pfad)
|
||||
except (KeyError, IndexError, TypeError):
|
||||
logger.debug("Pfad %s nicht in der Nutzlast: %s", pfad, text[:80])
|
||||
return None
|
||||
if isinstance(wert, bool):
|
||||
wert = "true" if wert else "false"
|
||||
elif isinstance(wert, (int, float, str)):
|
||||
wert = str(wert)
|
||||
else:
|
||||
return json.dumps(wert, ensure_ascii=False)
|
||||
return uebersetze(wert, tabelle)
|
||||
|
||||
def zustaende_lesen(self):
|
||||
werte, self.neu = self.neu, {}
|
||||
return werte
|
||||
|
||||
def senden(self, aktion):
|
||||
if not aktion["params"]:
|
||||
# Kommando ohne Parameter: das Kommando selbst ist die Nutzlast.
|
||||
self._publish(aktion["command_url"], "")
|
||||
return
|
||||
for p in aktion["params"]:
|
||||
self._publish(p["url"] or aktion["command_url"], p["wert"])
|
||||
|
||||
def _publish(self, topic, nutzlast):
|
||||
if not topic:
|
||||
raise ValueError("Kommando ohne Topic")
|
||||
if self.dry_run:
|
||||
logger.info("[dry-run] MQTT %s <- %s", topic, nutzlast)
|
||||
return
|
||||
ergebnis = self.client.publish(topic, nutzlast, qos=1, retain=False)
|
||||
ergebnis.wait_for_publish(timeout=5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP (Shelly und Verwandte)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class HTTPTransport(Transport):
|
||||
"""
|
||||
Die actor_url ist der Endpunkt, die state_url ein Feldname in dessen
|
||||
JSON-Antwort ("tC", "a_voltage"). Solche Geraete melden sich nicht von
|
||||
selbst, sie werden im Poll-Takt gefragt.
|
||||
|
||||
Beim Senden wird die command_url als Abfrageargument an die actor_url
|
||||
gehaengt ("turn=on") und die Parameter mit ihrem eigenen Namen dazu.
|
||||
Frueher stand hier eine Uebersetzungstabelle, weil die Shelly-Kommandos
|
||||
ohne URL in der Datenbank landeten - das ist im Discovery behoben, die
|
||||
Zuordnung gehoert dorthin und nicht in den Runner.
|
||||
"""
|
||||
|
||||
schema = "http"
|
||||
|
||||
def __init__(self, requests_modul, timeout=5, dry_run=False):
|
||||
self.requests = requests_modul
|
||||
self.timeout = timeout
|
||||
self.dry_run = dry_run
|
||||
self.states = []
|
||||
|
||||
def zustaende_anmelden(self, states):
|
||||
self.states = [s for s in states if s["state_url"]]
|
||||
logger.info("HTTP: %d Messwerte an %d Endpunkten",
|
||||
len(self.states), len({s["actor_url"] for s in self.states}))
|
||||
|
||||
def zustaende_lesen(self):
|
||||
werte = {}
|
||||
# Je Endpunkt eine Anfrage, auch wenn mehrere Messwerte daran haengen.
|
||||
nach_url = {}
|
||||
for s in self.states:
|
||||
nach_url.setdefault(s["actor_url"], []).append(s)
|
||||
for url, states in nach_url.items():
|
||||
try:
|
||||
antwort = self.requests.get(url, timeout=self.timeout)
|
||||
daten = antwort.json()
|
||||
except Exception as fehler:
|
||||
logger.debug("HTTP %s nicht erreichbar: %s", url, fehler)
|
||||
continue
|
||||
for s in states:
|
||||
if isinstance(daten, dict) and s["state_url"] in daten:
|
||||
werte[s["id"]] = str(daten[s["state_url"]])
|
||||
return werte
|
||||
|
||||
def senden(self, aktion):
|
||||
argumente = {}
|
||||
for teil in (aktion["command_url"] or "").split("&"):
|
||||
if "=" in teil:
|
||||
schluessel, wert = teil.split("=", 1)
|
||||
argumente[schluessel] = wert
|
||||
for p in aktion["params"]:
|
||||
if p["url"]:
|
||||
argumente[p["url"]] = p["wert"]
|
||||
if not argumente:
|
||||
raise RuntimeError("Kommando ohne URL und ohne Parameter - im "
|
||||
"Geraetemodell fehlt die Angabe, was zu schicken ist")
|
||||
if self.dry_run:
|
||||
logger.info("[dry-run] HTTP %s %s", aktion["actor_url"], argumente)
|
||||
return
|
||||
antwort = self.requests.get(aktion["actor_url"], params=argumente, timeout=self.timeout)
|
||||
if antwort.status_code >= 400:
|
||||
raise RuntimeError("HTTP %d von %s" % (antwort.status_code, aktion["actor_url"]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WLED
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class WLEDTransport(Transport):
|
||||
"""
|
||||
WLED-Lampen sprechen ueber eine einzige JSON-Schnittstelle:
|
||||
GET http://IP/json/state liefert den Zustand, POST dorthin setzt ihn.
|
||||
|
||||
Das Geraetemodell nutzt das elegant aus - die command_url ist eine
|
||||
JSON-Vorlage mit Platzhaltern:
|
||||
|
||||
{"bri":%brightness%}
|
||||
{"seg":[{"col":[[%red%,%green%,%blue%]]}]}
|
||||
|
||||
Gesendet wird also nicht Argument fuer Argument, sondern die ausgefuellte
|
||||
Vorlage am Stueck. Deshalb haben die Parameter hier auch keine eigene URL:
|
||||
ihr Name ist der Platzhalter.
|
||||
|
||||
Die state_url ist ein Pfad in die Antwort ("on", "bri",
|
||||
"seg[0].col[0]") - dieselbe Schreibweise, die auch in current_value steht.
|
||||
"""
|
||||
|
||||
schema = "wled://"
|
||||
|
||||
def __init__(self, requests_modul, timeout=5, dry_run=False):
|
||||
self.requests = requests_modul
|
||||
self.timeout = timeout
|
||||
self.dry_run = dry_run
|
||||
self.states = []
|
||||
|
||||
@staticmethod
|
||||
def _adresse(actor_url):
|
||||
return "http://" + actor_url[len("wled://"):].rstrip("/")
|
||||
|
||||
def zustaende_anmelden(self, states):
|
||||
self.states = [s for s in states if s["state_url"]]
|
||||
logger.info("WLED: %d Messwerte an %d Lampen",
|
||||
len(self.states), len({s["actor_url"] for s in self.states}))
|
||||
|
||||
def zustaende_lesen(self):
|
||||
werte = {}
|
||||
nach_lampe = {}
|
||||
for s in self.states:
|
||||
nach_lampe.setdefault(s["actor_url"], []).append(s)
|
||||
for actor_url, states in nach_lampe.items():
|
||||
try:
|
||||
antwort = self.requests.get(self._adresse(actor_url) + "/json/state",
|
||||
timeout=self.timeout)
|
||||
daten = antwort.json()
|
||||
except Exception as fehler:
|
||||
logger.debug("WLED %s nicht erreichbar: %s", actor_url, fehler)
|
||||
continue
|
||||
for s in states:
|
||||
# Bei WLED ist die state_url selbst schon der Pfad. Ein
|
||||
# gesetzter value_path hat trotzdem Vorrang, falls das
|
||||
# Geraetemodell spaeter darauf umgestellt wird.
|
||||
pfad = s.get("value_path") or s["state_url"]
|
||||
try:
|
||||
werte[s["id"]] = str(wert_aus_pfad(daten, pfad))
|
||||
except (KeyError, IndexError, TypeError):
|
||||
logger.debug("WLED %s: Pfad %s nicht gefunden", actor_url, pfad)
|
||||
return werte
|
||||
|
||||
def senden(self, aktion):
|
||||
vorlage = aktion["command_url"]
|
||||
if not vorlage:
|
||||
raise RuntimeError("WLED-Kommando ohne Vorlage")
|
||||
for p in aktion["params"]:
|
||||
vorlage = vorlage.replace("%" + p["name"] + "%", str(p["wert"]))
|
||||
try:
|
||||
rumpf = json.loads(vorlage)
|
||||
except ValueError:
|
||||
# Ein nicht ersetzter Platzhalter oder ein Textwert an einer
|
||||
# Stelle, wo eine Zahl stehen muss. Lieber hier abbrechen als der
|
||||
# Lampe etwas Unverstaendliches schicken.
|
||||
raise RuntimeError("WLED-Vorlage ergibt kein gueltiges JSON: " + vorlage[:120])
|
||||
if self.dry_run:
|
||||
logger.info("[dry-run] WLED %s <- %s", aktion["actor_url"],
|
||||
json.dumps(rumpf, ensure_ascii=False))
|
||||
return
|
||||
antwort = self.requests.post(self._adresse(aktion["actor_url"]) + "/json/state",
|
||||
json=rumpf, timeout=self.timeout)
|
||||
if antwort.status_code >= 400:
|
||||
raise RuntimeError("WLED antwortete mit %d" % antwort.status_code)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tahoma
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TahomaTransport(Transport):
|
||||
"""
|
||||
Die actor_url ist die deviceURL, die state_url ein Statusname
|
||||
("core:ClosureState"), die command_url ein Kommandoname ("setClosure").
|
||||
Geschickt wird ueber exec/apply - genauso wie in ajax/tahoma.php, nur ohne
|
||||
die dortige Sonderbehandlung fuer "faehrt gerade".
|
||||
|
||||
Zustaendig ist dieser Transport fuer alles, was die Kennung der eigenen
|
||||
Box in der URL traegt. Am Schema laesst sich das nicht festmachen: es
|
||||
beschreibt die Funkart, und dieselbe Box liefert io:// fuer die
|
||||
Jalousien, rts:// fuer die Dachfenster und internal:// fuer die Alarm-
|
||||
anlage. Ohne PIN in der config.ini ist niemand zustaendig - dann meldet
|
||||
der Runner beim Ausloesen "kein Transport", statt still nichts zu tun.
|
||||
"""
|
||||
|
||||
schema = "io://"
|
||||
|
||||
# Bis zu dieser Neigung fahren die Aussenjalousien direkt.
|
||||
#
|
||||
# Sie haben eine Kugelschreiber-Mechanik: beim Herunterfahren stehen die
|
||||
# Lamellen bei etwa 30 %. In Richtung 0 % laesst sich von jeder Stellung
|
||||
# aus direkt neigen; darueber hinaus muss die Mechanik erst einmal auf
|
||||
# 0 % zurueck, sonst rastet sie nicht um - ein "Neigung 80 %" ohne
|
||||
# diesen Umweg bleibt wirkungslos.
|
||||
#
|
||||
# Das gilt fuer jedes Kommando, das die Neigung setzt - "Neigung" ebenso
|
||||
# wie "Position+Neigung". Erkannt wird es deshalb am Parameter und nicht
|
||||
# am Kommandonamen: beide heissen ihren Neigungsparameter "Neigung".
|
||||
#
|
||||
# Dasselbe steht in restricted/commands.php: beide Versender brauchen es.
|
||||
NEIGUNG_DIREKT_MAX = 30
|
||||
|
||||
# So heissen die beiden Parameter einer Jalousie im Geraetemodell.
|
||||
NEIGUNG_PARAMETER = "Neigung"
|
||||
POSITION_PARAMETER = "Position"
|
||||
|
||||
# So lange wird hoechstens auf das Ende einer Fahrt gewartet. Gemessen:
|
||||
# eine Neigung von 100 % auf 0 % dauert gut fuenfzehn Sekunden, eine
|
||||
# volle Fahrt von oben nach unten rund sechzig. Die Grenze ist die
|
||||
# Notbremse, nicht die uebliche Dauer.
|
||||
JALOUSIE_WARTE_SEKUNDEN = 120
|
||||
|
||||
# So lange gilt ein "faehrt nicht" direkt nach dem Absenden als noch
|
||||
# nicht aussagekraeftig: die Box meldet core:MovingState traege, kurz
|
||||
# nach einem Kommando steht dort noch der alte Wert.
|
||||
JALOUSIE_VORLAUF_SEKUNDEN = 8
|
||||
|
||||
def __init__(self, requests_modul, pin, token, timeout=10, dry_run=False):
|
||||
self.requests = requests_modul
|
||||
self.pin = pin
|
||||
self.token = token
|
||||
self.timeout = timeout
|
||||
self.dry_run = dry_run
|
||||
self.states = []
|
||||
self.kombigeraete = set()
|
||||
|
||||
def passt(self, actor_url):
|
||||
return bool(self.pin) and ("://" + self.pin + "/") in actor_url
|
||||
|
||||
@property
|
||||
def basis(self):
|
||||
return "https://gateway-%s:8443/enduser-mobile-web/1/enduserAPI" % self.pin
|
||||
|
||||
def _kopf(self):
|
||||
return {"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + self.token}
|
||||
|
||||
def zustaende_anmelden(self, states):
|
||||
self.states = [s for s in states if s["state_url"]]
|
||||
logger.info("Tahoma: %d Messwerte an %d Geraeten",
|
||||
len(self.states), len({s["actor_url"] for s in self.states}))
|
||||
|
||||
def kombigeraete_setzen(self, urls):
|
||||
"""
|
||||
Welche Geraete Position und Neigung zusammen koennen - vom Runner aus
|
||||
dem Geraetemodell gesetzt, damit der Transport dafuer nicht selbst in
|
||||
die Datenbank greifen muss.
|
||||
"""
|
||||
self.kombigeraete = set(urls)
|
||||
|
||||
def zustaende_lesen(self):
|
||||
if not self.token:
|
||||
return {}
|
||||
werte = {}
|
||||
nach_geraet = {}
|
||||
for s in self.states:
|
||||
nach_geraet.setdefault(s["actor_url"], []).append(s)
|
||||
for geraet, states in nach_geraet.items():
|
||||
try:
|
||||
antwort = self.requests.get(
|
||||
self.basis + "/setup/devices/" + quote(geraet, safe="") + "/states",
|
||||
headers=self._kopf(), timeout=self.timeout, verify=False)
|
||||
zustaende = {z["name"]: z.get("value") for z in antwort.json()}
|
||||
except Exception as fehler:
|
||||
logger.debug("Tahoma %s nicht erreichbar: %s", geraet, fehler)
|
||||
continue
|
||||
for s in states:
|
||||
if s["state_url"] in zustaende:
|
||||
werte[s["id"]] = str(zustaende[s["state_url"]])
|
||||
return werte
|
||||
|
||||
def senden(self, aktion):
|
||||
if not self.token:
|
||||
raise RuntimeError("Kein Tahoma-Token in der config.ini")
|
||||
# Die Reihenfolge der Parameter ist die aus command_parameters - bei
|
||||
# setClosureAndOrientation also erst Position, dann Winkel.
|
||||
befehl = aktion["command_url"]
|
||||
parameter = [self._zahl(p["wert"]) for p in aktion["params"]]
|
||||
neigung_index = None
|
||||
position_index = None
|
||||
for i, p in enumerate(aktion["params"]):
|
||||
if p["name"] == self.NEIGUNG_PARAMETER:
|
||||
neigung_index = i
|
||||
elif p["name"] == self.POSITION_PARAMETER:
|
||||
position_index = i
|
||||
|
||||
# "Zu" allein macht diese Jalousien nicht dicht: sie faehrt herunter,
|
||||
# die Lamellen bleiben durch die Mechanik aber bei etwa 30 % offen.
|
||||
# Gemeint ist "ganz unten, Lamellen geschlossen" - also dasselbe wie
|
||||
# Position 100 mit Neigung 100, und damit ein Fall fuer die Regel.
|
||||
if befehl == "down" and self._kannKombi(aktion["actor_url"]):
|
||||
befehl = "setClosureAndOrientation"
|
||||
parameter = [100, 100]
|
||||
position_index, neigung_index = 0, 1
|
||||
|
||||
# Kugelschreiber-Mechanik, siehe NEIGUNG_DIREKT_MAX. Geschickt wird
|
||||
# derselbe Befehl zweimal - erst mit Neigung 0, dann mit dem
|
||||
# gewuenschten Wert. Die Position bleibt dabei stehen, die Jalousie
|
||||
# faehrt also nur einmal.
|
||||
umweg = (neigung_index is not None
|
||||
and isinstance(parameter[neigung_index], (int, float))
|
||||
and parameter[neigung_index] > self.NEIGUNG_DIREKT_MAX)
|
||||
vorstufe = list(parameter)
|
||||
if umweg:
|
||||
vorstufe[neigung_index] = 0
|
||||
|
||||
if self.dry_run:
|
||||
logger.info("[dry-run] Tahoma %s %s%s", befehl, parameter,
|
||||
" (zuerst %s, dann warten)" % vorstufe if umweg else "")
|
||||
return
|
||||
|
||||
if umweg:
|
||||
self._apply(aktion["actor_url"], befehl, vorstufe)
|
||||
self._warteAufJalousie(aktion["actor_url"], 0,
|
||||
None if position_index is None else int(parameter[position_index]))
|
||||
self._apply(aktion["actor_url"], befehl, parameter)
|
||||
|
||||
def _kannKombi(self, actor_url):
|
||||
"""
|
||||
Hat das Geraet ein Kommando fuer Position und Neigung zusammen?
|
||||
Nur solche Geraete sind Jalousien mit der Kugelschreiber-Mechanik.
|
||||
"""
|
||||
return actor_url in self.kombigeraete
|
||||
|
||||
def _apply(self, actor_url, name, parameter):
|
||||
"""Ein Kommando an die Box schicken."""
|
||||
rumpf = {"label": "AutoAction",
|
||||
"actions": [{"deviceURL": actor_url,
|
||||
"commands": [{"name": name, "parameters": parameter}]}]}
|
||||
antwort = self.requests.post(self.basis + "/exec/apply", headers=self._kopf(),
|
||||
data=json.dumps(rumpf), timeout=self.timeout, verify=False)
|
||||
if antwort.status_code >= 400:
|
||||
raise RuntimeError("Tahoma antwortete mit %d: %s"
|
||||
% (antwort.status_code, antwort.text[:120]))
|
||||
|
||||
def _warteAufJalousie(self, actor_url, neigung_ziel, schliessung_ziel=None):
|
||||
"""
|
||||
Wartet, bis die Jalousie ihre Fahrt beendet hat und die Ziele zeigt.
|
||||
|
||||
Zwei Auskuenfte zusammen, weil einzeln keine traegt:
|
||||
core:MovingState taugt fuer die lange Fahrt hoch und runter, wird bei
|
||||
kurzen Neigungsfahrten aber nie gesetzt; die Zustandswerte sind die
|
||||
eigentliche Wahrheit, zeigen kurz nach dem Kommando aber noch den
|
||||
alten Stand. Fertig ist die Fahrt, wenn nichts mehr faehrt, die Ziele
|
||||
erreicht sind und entweder ein "faehrt" gesehen wurde oder der
|
||||
Vorlauf um ist.
|
||||
"""
|
||||
start = time.time()
|
||||
gestartet = False
|
||||
while time.time() - start < self.JALOUSIE_WARTE_SEKUNDEN:
|
||||
time.sleep(2)
|
||||
try:
|
||||
antwort = self.requests.get(
|
||||
self.basis + "/setup/devices/" + quote(actor_url, safe="") + "/states",
|
||||
headers=self._kopf(), timeout=self.timeout, verify=False)
|
||||
z = {x.get("name"): x.get("value") for x in antwort.json()}
|
||||
except Exception as fehler:
|
||||
logger.debug("Zustand nicht lesbar: %s", fehler)
|
||||
continue
|
||||
if z.get("core:MovingState") is True:
|
||||
gestartet = True
|
||||
continue
|
||||
neigung = z.get("core:SlateOrientationState")
|
||||
schliessung = z.get("core:ClosureState")
|
||||
# Ein fehlendes Feld darf nicht als 0 durchgehen - das waere
|
||||
# ausgerechnet beim Ziel 0 ein falsches Erfolgssignal.
|
||||
neigung_ok = neigung is not None and int(neigung) == neigung_ziel
|
||||
schliessung_ok = (schliessung_ziel is None
|
||||
or (schliessung is not None and int(schliessung) == schliessung_ziel))
|
||||
if neigung_ok and schliessung_ok and (
|
||||
gestartet or time.time() - start >= self.JALOUSIE_VORLAUF_SEKUNDEN):
|
||||
return True
|
||||
logger.warning("%s hat Neigung %s%% nicht innerhalb von %d s erreicht",
|
||||
actor_url, neigung_ziel, self.JALOUSIE_WARTE_SEKUNDEN)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _zahl(wert):
|
||||
"""Tahoma erwartet Zahlen als Zahlen, Text als Text."""
|
||||
try:
|
||||
return int(wert)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
return float(wert)
|
||||
except (TypeError, ValueError):
|
||||
return wert
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logic - das gerechnete Geraet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LogicTransport(Transport):
|
||||
"""
|
||||
Uhrzeit, Datum, Sonnenauf- und -untergang. Es gibt nichts zu abonnieren und
|
||||
nichts zu schalten, die Werte entstehen im Takt. Sonnenzeiten kommen aus
|
||||
solarLog.daylight, dieselbe Tabelle, aus der auch ajax/getSunrise.php liest.
|
||||
"""
|
||||
|
||||
schema = "Logic"
|
||||
|
||||
def __init__(self, sonnenzeiten):
|
||||
"""sonnenzeiten: Funktion() -> (sonnenaufgang, sonnenuntergang) als "HH:MM"."""
|
||||
self.sonnenzeiten = sonnenzeiten
|
||||
self.states = []
|
||||
|
||||
def passt(self, actor_url):
|
||||
return actor_url == "Logic"
|
||||
|
||||
def zustaende_anmelden(self, states):
|
||||
self.states = states
|
||||
logger.info("Logic: %d Messwerte", len(states))
|
||||
|
||||
def zustaende_lesen(self):
|
||||
jetzt = datetime.now()
|
||||
auf, unter = self.sonnenzeiten()
|
||||
tabelle = {
|
||||
"time": jetzt.strftime("%H:%M"),
|
||||
"date": jetzt.strftime("%d.%m.%Y"),
|
||||
"sunrise": auf,
|
||||
"sunset": unter,
|
||||
}
|
||||
return {s["id"]: tabelle[s["state_url"]]
|
||||
for s in self.states if s["state_url"] in tabelle}
|
||||
|
||||
def senden(self, aktion):
|
||||
raise RuntimeError("Das Geraet \"Zeitpunkt\" kann nichts schalten")
|
||||
@@ -41,8 +41,12 @@ function meshDb()
|
||||
*
|
||||
* Bei Zeit und Datum heissen die Operatoren "um", "ab" und "vor":
|
||||
*
|
||||
* um 16:30 genau in dieser Minute. Der uebliche Fall - einmal taeglich
|
||||
* zu einem festen Zeitpunkt.
|
||||
* um 16:30 ab dieser Minute, und noch ein paar Minuten danach
|
||||
* (catchup_minutes im Runner, Vorgabe fuenf). Der uebliche
|
||||
* Fall - einmal taeglich zu einem festen Zeitpunkt. Das
|
||||
* Fenster kostet nichts, weil ohnehin nur die steigende
|
||||
* Flanke zaehlt; es sorgt nur dafuer, dass ein Neustart oder
|
||||
* ein langsamer Durchlauf den Termin nicht verschluckt.
|
||||
* ab 16:30 ab da bis Mitternacht wahr. Ausgeloest wird trotzdem nur
|
||||
* einmal, weil nur die steigende Flanke zaehlt; dafuer kostet
|
||||
* ein verpasster Takt nicht den ganzen Tag. Dieselbe
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Ein Kommando aus `actor_commands` ausfuehren.
|
||||
*
|
||||
* Das Gegenstueck zu restricted/autoActions/transports.py, nur fuer den
|
||||
* Das Gegenstueck zu SolarManager/autoActions/transports.py, nur fuer den
|
||||
* Browser: dort schickt der Runner die Kommandos einer Automatik, hier
|
||||
* schickt sie der Benutzer aus dem Raum-Modal. Die Zuordnung "welche URL
|
||||
* gehoert zu welchem Weg" ist dieselbe und steht in beiden Dateien am selben
|
||||
@@ -39,7 +39,7 @@ const MQTT_PORT = 1883;
|
||||
* "Position+Neigung". Erkannt wird es deshalb am Parameter und nicht am
|
||||
* Kommandonamen: beide heissen ihren Neigungsparameter "Neigung".
|
||||
*
|
||||
* Dasselbe steht in restricted/autoActions/transports.py - beide Versender
|
||||
* Dasselbe steht in SolarManager/autoActions/transports.py - beide Versender
|
||||
* brauchen es, und beide muessen es wissen.
|
||||
*/
|
||||
const NEIGUNG_DIREKT_MAX = 30;
|
||||
|
||||
@@ -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 include __DIR__ . "/charts.html"; ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($page["leaflet"]): ?>
|
||||
<!-- Leaflet 1.9.4, BSD 2-Clause. Liegt wie alle anderen Bibliotheken lokal;
|
||||
die Kacheln kommen ueber ajax/tile.php aus dem eigenen Zwischenspeicher. -->
|
||||
<script src="js/leaflet.js"></script>
|
||||
<?php endif; ?>
|
||||
<script src="js/solar/common.js?v=<?= filemtime("js/solar/common.js") ?>"></script>
|
||||
<?php foreach ($page["scripts"] as $script): ?>
|
||||
<script src="<?= $script ?>?v=<?= filemtime($script) ?>"></script>
|
||||
|
||||
@@ -44,6 +44,12 @@ if (!isset($_GET["floor"]))
|
||||
<link rel="stylesheet" href="./css/adminlte.min.css?v=2" />
|
||||
<!--end::Required Plugin(AdminLTE)-->
|
||||
|
||||
<!--begin::Karte (Leaflet) - nur auf Seiten mit Karte-->
|
||||
<?php if ($page["leaflet"]): ?>
|
||||
<link rel="stylesheet" href="css/leaflet.css" />
|
||||
<?php endif; ?>
|
||||
<!--end::Karte-->
|
||||
|
||||
|
||||
</head>
|
||||
<!--end::Head-->
|
||||
@@ -142,6 +148,12 @@ if (!isset($_GET["floor"]))
|
||||
<p>Wetter</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="?action=skoda" class="nav-link <?php if($_GET["action"]=="skoda"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-ev-front"></i>
|
||||
<p>Škoda</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item menu-open">
|
||||
@@ -170,6 +182,14 @@ if (!isset($_GET["floor"]))
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<!-- Einstellungen stehen fuer sich: kein Dashboard,
|
||||
sondern die Parameter dahinter. -->
|
||||
<li class="nav-item">
|
||||
<a href="?action=settings" class="nav-link<?php if($_GET["action"]=="settings"){echo " active";} ?>">
|
||||
<i class="nav-icon bi bi-gear"></i>
|
||||
<p>Einstellungen</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!--end::Sidebar Menu-->
|
||||
</nav>
|
||||
|
||||
@@ -74,7 +74,7 @@ $statistics = [
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-cols-1 row-cols-md-1 g-4 p-2 pb-0" id="<?= $id ?>">
|
||||
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="<?= $id ?>">
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.card -->
|
||||
|
||||
@@ -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>
|
||||
</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>
|
||||
|
||||
@@ -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