Compare commits

...
3 Commits
Author SHA1 Message Date
adminandClaude Opus 5 d285ec27a0 Einstellungsseite, Skoda-Seite und Karte
Sammelstand der offenen Arbeit im Dashboard.

Einstellungen: eine Eingabemaske fuer die Energiepreise unter
restricted/settings.php, dazu costs.php als Modell und ajax/settings.php
als Endpunkt. Die Preise stehen jetzt in gridCosts, gasCosts und fuelCosts
statt fest im Quelltext von getStats.php; die Jahresstatistik rechnet ueber
LEAD() den jeweils gueltigen Zeitraum aus und schlaegt den anteiligen
Grundpreis auf. Eingegeben wird in gewohnten Einheiten - l/100 km,
kWh/100 km, Euro je Liter -, umgerechnet wird beim Speichern.
solarLog_costs.sql beschreibt die Umstellung der Tabellen.

Skoda: eigene Seite mit Live-Werten ueber MQTT und Historie ueber
ajax/skoda.php, Kommandos ueber ajax/skodaCmd.php.

Karte: Leaflet mit einem eigenen Kachel-Zwischenspeicher (ajax/tile.php),
dessen Ablage unter tiles/ nicht ins Repository gehoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:21:27 +02:00
adminandClaude Opus 5 c9a9e16b21 Verweise auf den Runner nachgezogen
Die Pfade in commands.php und homeMesh_automations.sql zeigten noch ins
Web-Verzeichnis. Dazu die Beschreibung der Zeit-Operatoren im Kopf von
automations.php: "um 16:30" gilt jetzt ab dieser Minute und noch
catchup_minutes lang, damit ein Neustart oder ein langsamer Durchlauf den
Termin nicht mehr verschluckt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:21:14 +02:00
adminandClaude Opus 5 cbddd664e3 Runner zieht zum SolarManager um
Der AutoAction-Runner ist ein Hintergrundprozess und gehoert damit zu den
anderen, nicht ins Web-Verzeichnis. Er liegt jetzt unter
/volume1/homes/wagner/SolarManager/autoActions und wird von dort zusammen
mit dem Manager gestartet; versioniert ist er im Repository SolarManager.

Browser und Runner reden ohnehin nur ueber die Datenbank homeMesh
miteinander - das Web-UI kennt den Pfad nicht und muss ihn nicht kennen.
Die Verweise in commands.php und homeMesh_automations.sql zeigen bereits
auf den neuen Ort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:56:08 +02:00
34 changed files with 4871 additions and 2061 deletions
+4
View File
@@ -48,3 +48,7 @@ Thumbs.db
*.tmp
*.bak
*.backup
# Kartenkacheln des Zwischenspeichers (ajax/tile.php).
# Wachsen mit dem, was angesehen wurde - gehoeren nicht ins Repository.
tiles/
+82 -15
View File
@@ -4,30 +4,94 @@
* Ersetzt getConsData_{month,year,decade}.php und getProdData_{month,year,decade}.php.
*
* Aufruf: energyHistory.php?series=prod|cons&range=month|year|decade
*
* Zwei Quellen, je nach Zeitraum:
*
* month EnergyFlow, also die Rohdaten. Nach Tagen gruppiert, und der
* heutige Balken soll bis zur letzten Messung reichen. Das
* Stundenarchiv wird nur einmal nachts fortgeschrieben und wäre
* für den laufenden Tag noch leer.
*
* year EnergyFlow_hourly, das Stundenarchiv. Die Rohdaten werden nach
* decade zwölf Monaten ausgedünnt (solarlog-rollup.sh thin), diese
* Diagramme fänden dort also nichts mehr. Bei Gruppierung nach
* Monat bzw. Jahr fällt der fehlende laufende Tag nicht auf.
*
* Beide Quellen liefern Wattstunden, weil die Diagramme auf der y-Achse durch
* 1.000.000 teilen und MWh beschriften. Die Rohdaten sind Momentanleistungen
* alle fünf Minuten, daher /12; im Archiv stehen bereits kWh, daher *1000.
* Gegengerechnet über einen kompletten Monat stimmen beide auf 0,003 % überein
* - der Rest ist die /12-Näherung gegenüber der echten Zeitgewichtung.
*
* pv_fehlbetrag_kwh wird zur erfassten Erzeugung addiert. Wenn Wechselrichter
* die Verbindung zur OpenDTU verlieren, fehlt ihr Beitrag in pvP bzw. pv_kwh -
* die Einspeisung, die Batterieladung und der Heizstab sind aber vollständig
* gemessen. Ohne die Korrektur wird der Direktverbrauch als Differenz negativ
* und der Balken bleibt leer, etwa am 03.08.2026 mit -0,5 statt +15,9 kWh.
* Im Rohdaten-Zweig kommt der Betrag über eine nach Tagen vorverdichtete
* abgeleitete Tabelle dazu, sonst zählte ihn jede der zwölf Zeilen je Stunde
* erneut; für den laufenden Tag gibt es ihn noch nicht, dann bleibt er 0.
*
* Die Einspeisung kommt in beiden Fällen aus gridPfeed, dem Zählerregister,
* nicht aus dem Saldo gridP. Innerhalb eines Fensters kommen Bezug und
* Einspeisung gleichzeitig vor; der Saldo löscht kurze Bezüge weg und passt
* dann nicht mehr zu dem, was abgerechnet wird.
*/
require_once("../helper.php");
require_once("./chartData.php");
// Zeitraum -> [Format der Beschriftung, Filter auf datetime]
// Zeitraum -> [Format der Beschriftung, Quelle]
$ranges = [
"month" => ["'%d.%b.'", "DATE_FORMAT(datetime,'%Y%m%d') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 1 MONTH),'%Y%m%d')"],
"year" => ["'%b. %y'", "DATE_FORMAT(datetime,'%Y%m') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 13 MONTH),'%Y%m')"],
"decade" => ["'%Y'", "DATE_FORMAT(datetime,'%Y') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 10 YEAR),'%Y')"],
"month" => ["'%d.%b.'", "roh"],
"year" => ["'%b. %y'", "archiv"],
"decade" => ["'%Y'", "archiv"],
];
// Filter auf die Zeitspalte. ZEIT wird durch den Spaltennamen der Quelle ersetzt.
$filters = [
"month" => "DATE_FORMAT(ZEIT,'%Y%m%d') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 1 MONTH),'%Y%m%d')",
"year" => "DATE_FORMAT(ZEIT,'%Y%m') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 13 MONTH),'%Y%m')",
"decade" => "DATE_FORMAT(ZEIT,'%Y') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 10 YEAR),'%Y')",
];
// Datenreihen je Diagramm. Beide teilen sich Solarertrag und Heizstab und
// unterscheiden sich nur darin, wohin die Energie fließt (prod) bzw. woher
// sie kommt (cons).
$series = [
"prod" => "SUM(pvP/12 - IF(gridP < 0, -gridP/12,0) - IF(battP < 0, -battP/12,0) - heaterPwr/12) AS Direktverbrauch,
$quellen = [
"roh" => [
"tabelle" => "EnergyFlow",
"zeit" => "datetime",
"join" => "LEFT JOIN (SELECT DATE(stunde) AS tag, SUM(pv_fehlbetrag_kwh) AS fehlt
FROM EnergyFlow_hourly GROUP BY 1) f ON f.tag = DATE(datetime)",
"reihen" => [
"prod" => "SUM(pvP/12 - gridPfeed/12 - IF(battP < 0, -battP/12,0) - heaterPwr/12)
+ IFNULL(MAX(f.fehlt),0)*1000 AS Direktverbrauch,
SUM(heaterPwr/12) AS Heizstab,
SUM(IF(battP < 0, -battP/12,0)) AS Batterieladung,
SUM(gridPfeed/12) AS Einspeisung",
"cons" => "SUM(pvP/12 - IF(gridP < 0, -gridP/12,0) - IF(battP < 0, -battP/12,0) - heaterPwr/12) AS Solarverbrauch,
"cons" => "SUM(pvP/12 - gridPfeed/12 - IF(battP < 0, -battP/12,0) - heaterPwr/12)
+ IFNULL(MAX(f.fehlt),0)*1000 AS Solarverbrauch,
SUM(heaterPwr/12) AS Heizstab,
SUM(IF(battP > 0, battP/12,0)) AS Batteriebezug,
SUM(gridPcons/12) AS Netzbezug",
],
],
"archiv" => [
"tabelle" => "EnergyFlow_hourly",
"zeit" => "stunde",
"join" => "",
"reihen" => [
"prod" => "SUM((pv_kwh + IFNULL(pv_fehlbetrag_kwh,0) - netz_einsp_kwh - batt_laden_kwh - heizstab_kwh)*1000) AS Direktverbrauch,
SUM(heizstab_kwh*1000) AS Heizstab,
SUM(batt_laden_kwh*1000) AS Batterieladung,
SUM(netz_einsp_kwh*1000) AS Einspeisung",
"cons" => "SUM((pv_kwh + IFNULL(pv_fehlbetrag_kwh,0) - netz_einsp_kwh - batt_laden_kwh - heizstab_kwh)*1000) AS Solarverbrauch,
SUM(heizstab_kwh*1000) AS Heizstab,
SUM(batt_entladen_kwh*1000) AS Batteriebezug,
SUM(netz_bezug_kwh*1000) AS Netzbezug",
],
],
];
$linecolors["Direktverbrauch"] = "#FFFF00";
@@ -46,14 +110,17 @@ $linecolors["Vorhersage"] = "#2222FF";
header('Content-Type: application/json; charset=utf-8');
$rangeKey = isset($_GET["range"]) ? $_GET["range"] : "";
$rangeKey = isset($_GET["range"]) ? $_GET["range"] : "";
$seriesKey = isset($_GET["series"]) ? $_GET["series"] : "";
if (!isset($ranges[$rangeKey]) || !isset($series[$seriesKey])) {
if (!isset($ranges[$rangeKey]) || !in_array($seriesKey, ["prod", "cons"], true)) {
http_response_code(400);
echo json_encode(["error" => "Unbekannter Zeitraum oder Datenreihe."]);
exit;
}
list($labelFormat, $filter) = $ranges[$rangeKey];
list($labelFormat, $quelleKey) = $ranges[$rangeKey];
$quelle = $quellen[$quelleKey];
$zeit = $quelle["zeit"];
$filter = str_replace("ZEIT", $zeit, $filters[$rangeKey]);
$obj = (object)[];
$obj->labels = [];
@@ -62,12 +129,12 @@ $obj->datasets = [];
if (checkLogin()) {
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
$query = "SELECT
DATE_FORMAT(datetime, $labelFormat) AS 'time',
" . $series[$seriesKey] . "
FROM EnergyFlow
DATE_FORMAT($zeit, $labelFormat) AS 'time',
" . $quelle["reihen"][$seriesKey] . "
FROM " . $quelle["tabelle"] . " " . $quelle["join"] . "
WHERE $filter
GROUP BY DATE_FORMAT(datetime, $labelFormat)
ORDER BY datetime ; ";
GROUP BY DATE_FORMAT($zeit, $labelFormat)
ORDER BY MIN($zeit) ; ";
$obj = buildChartData($mysql, $query, $linecolors);
}
+97 -41
View File
@@ -1,6 +1,7 @@
<?php
require_once("../helper.php");
require_once("../restricted/costs.php");
if(!isset($_GET["year"])){
$_GET["year"] = date("Y");
}else{
@@ -17,47 +18,98 @@ if(isset($_GET["type"])){
}
}
$Query = "SELECT
SUM(gridPcons/12000) as 'Stromverbrauch',
SUM((gridPcons*cost)/12000)+140 as 'Stromkosten (140€ fix)',
SUM(pvP - IF(gridP < 0, -gridP,0) - IF(battP < 0, -battP,0)- heaterPwr + IF(battP > 0, battP,0))*cost/12000 as 'Verbrauchsersparnis',
SUM((gridPfeed*gain)/12000) as 'Einspeisevergütung',
SUM(IF(gridP < 500, heaterPwr, 0)*0.063/12000) as 'Ersparnis Heizung',
SUM(IF(battP > 0, battP, 0)*cost/12000) as 'Ersparnis Batterie',
AVG(autonomy) AS 'Ø Autarkie',
(SUM(PL1_EV+PL2_EV+PL3_EV))/12 AS 'Autoladung ges. EG',
(((SUM(IF(gridP>100,0,PL1_EV+PL2_EV+PL3_EV)))/(SUM(PL1_EV+PL2_EV+PL3_EV))))*100 AS 'Autoladung Solar EG',
(SUM(IF(gridP>100,0,(PL1_EV+PL2_EV+PL3_EV)*cost))/12) AS 'Ersparnis Solarladung EG',
(SUM(PL1_EV+PL2_EV+PL3_EV))/24.5 AS 'Benzin gespart EG',
(SUM(PL1_EVog+PL2_EVog+PL3_EVog))/12 AS 'Autoladung ges. OG',
(((SUM(IF(gridP>100,0,PL1_EVog+PL2_EVog+PL3_EVog)))/(SUM(PL1_EVog+PL2_EVog+PL3_EVog))))*100 AS 'Autoladung Solar OG',
(SUM(IF(gridP>100,0,(PL1_EVog+PL2_EVog+PL3_EVog)*cost))/12) AS 'Ersparnis Solarladung OG',
(SUM(PL1_EVog+PL2_EVog+PL3_EVog))/24.5 AS 'Benzin gespart OG'
FROM EnergyFlow JOIN gridCosts ON DATE(datetime) >= DATE(gridCosts.active_date) AND DATE(datetime) <= DATE(gridCosts.end_date)
WHERE year(datetime) = ".$_GET["year"].";";
/**
* Die Jahresstatistik als eine Abfrage - jetzt auf stats_daily.
*
* Gerechnet wird nicht mehr ueber die Rohdaten aus EnergyFlow, sondern ueber
* die Tageswerte. Zwei Gruende:
*
* 1. Die Rohdaten werden nach zwoelf Monaten ausgeduennt bzw. ins
* Stundenarchiv verschoben (solarlog-rollup.sh). Eine Kachel, die auf
* EnergyFlow rechnet, zeigt fuer alte Jahre danach zu wenig an.
* 2. Ein Jahr sind 365 Zeilen statt rund 105.000.
*
* Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt
* LEAD() beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie
* zwei Tarife haben - die Verdichtung auf Tage verliert hier also nichts.
*
* Was in stats_daily vorberechnet sein MUSS, weil es eine Bedingung je
* Messwert auswertet und sich aus Tagessummen nicht rekonstruieren laesst:
*
* eigenverbrauch_kwh pv - Einspeisung - Batterieladung
* - Heizstab + Batterieentladung. Dazu addiert
* wird pv_fehlbetrag_kwh: bei Ausfall einzelner
* Wechselrichter fehlt deren Beitrag in pv, alle
* Senken sind dagegen gemessen - die Differenz
* faellt sonst zu klein oder negativ aus.
* heizstab_solar_kwh Heizstab, anteilig nach autonomy
* auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom
* Geraet in feiner Aufloesung gerechneten
* Nicht-Netz-Anteil der Zufuhr
*
* Einheiten sind in stats_daily bereits bereinigt: PL*_EV und PL*_EVog
* liefern Kilowatt, alle uebrigen Leistungsspalten Watt. In der alten
* Abfrage stand das als /12 gegen /12000 nebeneinander.
*
* Die Solaranteile teilen jetzt durch NULLIF(...): ohne Ladung im Jahr kam
* vorher 0/0 heraus.
*
* "Ersparnis Batterie" rechnet den Nutzen netto: was die entladene Energie
* aus dem Netz gekostet haette, abzueglich der Verguetung, die fuer den zum
* Laden verwendeten Strom entgangen ist. Brutto waere die Kachel zu
* freundlich - der Speicher ist nicht umsonst zu fuellen.
*
* Achtung beim Lesen: "Verbrauchsersparnis" ist der GESAMTE Eigenverbrauch
* zum Strompreis. "Ersparnis Batterie" und "Ersparnis Solarladung" sind
* Teilmengen davon, nach Herkunft bzw. Verwendung herausgegriffen. Sie
* duerfen nicht addiert werden.
*
* Der Grundpreis fehlt weiterhin bewusst - er haengt an den Tagen des
* Jahres, nicht an einem Messwert, und kommt aus grundpreisImJahr() dazu.
*/
function statistikAbfrage($jahr)
{
return "WITH
preis AS (SELECT active_date, cost, gain,
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gridCosts),
gas AS (SELECT active_date, cost, kwhPerLitre,
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gasCosts),
sprit AS (SELECT active_date, cost, lPer100km, kwhPer100km,
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM fuelCosts)
SELECT
SUM(d.netz_bezug_kwh) AS 'Stromverbrauch',
SUM(d.netz_bezug_kwh * preis.cost) AS 'Stromkosten',
SUM((d.eigenverbrauch_kwh + IFNULL(d.pv_fehlbetrag_kwh,0)) * preis.cost) AS 'Verbrauchsersparnis',
SUM(d.netz_einsp_kwh * preis.gain) AS 'Einspeisevergütung',
SUM(d.heizstab_solar_kwh * gas.cost / NULLIF(gas.kwhPerLitre,0)) AS 'Ersparnis Heizung',
SUM(d.batt_entladen_kwh * preis.cost - d.batt_laden_kwh * preis.gain) AS 'Ersparnis Batterie',
SUM(d.autarkie_avg * d.stunden_erfasst) / NULLIF(SUM(d.stunden_erfasst),0) AS 'Ø Autarkie',
SUM(d.auto_eg_kwh) AS 'Autoladung ges. EG',
SUM(d.auto_eg_solar_kwh) / NULLIF(SUM(d.auto_eg_kwh),0) * 100 AS 'Autoladung Solar EG',
SUM(d.auto_eg_solar_kwh * preis.cost) AS 'Ersparnis Solarladung EG',
SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart EG',
SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin EG',
SUM(d.auto_og_kwh) AS 'Autoladung ges. OG',
SUM(d.auto_og_solar_kwh) / NULLIF(SUM(d.auto_og_kwh),0) * 100 AS 'Autoladung Solar OG',
SUM(d.auto_og_solar_kwh * preis.cost) AS 'Ersparnis Solarladung OG',
SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart OG',
SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin OG'
FROM stats_daily d
JOIN preis ON d.tag >= preis.active_date AND (preis.folgt IS NULL OR d.tag < preis.folgt)
LEFT JOIN gas ON d.tag >= gas.active_date AND (gas.folgt IS NULL OR d.tag < gas.folgt)
LEFT JOIN sprit ON d.tag >= sprit.active_date AND (sprit.folgt IS NULL OR d.tag < sprit.folgt)
WHERE YEAR(d.tag) = " . intval($jahr) . ";";
}
$PrevQuery = "SELECT
SUM(gridPcons/12000) as 'Stromverbrauch',
SUM((gridPcons*cost)/12000)+140 as 'Stromkosten (140€ fix)',
SUM(pvP - IF(gridP < 0, -gridP,0) - IF(battP < 0, -battP,0)- heaterPwr + IF(battP > 0, battP,0))*cost/12000 as 'Verbrauchsersparnis',
SUM((gridPfeed*gain)/12000) as 'Einspeisevergütung',
SUM(IF(gridP < 500, heaterPwr, 0)*0.063/12000) as 'Ersparnis Heizung',
SUM(IF(battP > 0, battP, 0)*cost/12000) as 'Ersparnis Batterie',
AVG(autonomy) AS 'Ø Autarkie',
(SUM(PL1_EV+PL2_EV+PL3_EV))/12 AS 'Autoladung ges. EG',
(((SUM(IF(gridP>100,0,PL1_EV+PL2_EV+PL3_EV)))/(SUM(PL1_EV+PL2_EV+PL3_EV))))*100 AS 'Autoladung Solar EG',
(SUM(IF(gridP>100,0,(PL1_EV+PL2_EV+PL3_EV)*cost))/12) AS 'Ersparnis Solarladung EG',
(SUM(PL1_EV+PL2_EV+PL3_EV))/24.5 AS 'Benzin gespart EG',
(SUM(PL1_EVog+PL2_EVog+PL3_EVog))/12 AS 'Autoladung ges. OG',
(((SUM(IF(gridP>100,0,PL1_EVog+PL2_EVog+PL3_EVog)))/(SUM(PL1_EVog+PL2_EVog+PL3_EVog))))*100 AS 'Autoladung Solar OG',
(SUM(IF(gridP>100,0,(PL1_EVog+PL2_EVog+PL3_EVog)*cost))/12) AS 'Ersparnis Solarladung OG',
(SUM(PL1_EVog+PL2_EVog+PL3_EVog))/24.5 AS 'Benzin gespart OG'
FROM EnergyFlow JOIN gridCosts ON DATE(datetime) >= DATE(gridCosts.active_date) AND DATE(datetime) <= DATE(gridCosts.end_date)
WHERE year(datetime) = ".($_GET["year"]-1).";";
$Query = statistikAbfrage($_GET["year"]);
$PrevQuery = statistikAbfrage($_GET["year"] - 1);
$units = Array("kWh","€","€","€","€","€","€","%","kWh","%","€","L","kWh","%","€","L");
$LessIsBetter = Array(true,true,false,false,false,false,false,false,false,false,false,false,false,false,false,false);
// Reihenfolge wie in der Abfrage, mit "Verg.+Einsp. Strom" an Stelle 4.
$units = Array("kWh","€","€","€","€","€","€","%",
"kWh","%","€","L","€",
"kWh","%","€","L","€");
// Nur bei Verbrauch und Kosten ist weniger besser.
$LessIsBetter = array_merge([true, true], array_fill(0, count($units) - 2, false));
function array_insert($array,$values,$offset) {
return array_slice($array, 0, $offset, true) + $values + array_slice($array, $offset, NULL, true);
@@ -67,7 +119,7 @@ function array_insert($array,$values,$offset) {
if (checkLogin()) {
$html = "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
$mysql = solarDb();
$Res = mysqli_query($mysql,$Query);
$ResPrev = mysqli_query($mysql,$PrevQuery);
if(!$Res || !$ResPrev){
@@ -77,13 +129,17 @@ if (checkLogin()) {
$i = 0;
$row = $Res->fetch_assoc();
$rowPrev = $ResPrev->fetch_assoc();
// Der Grundpreis faellt unabhaengig vom Verbrauch an und laesst sich
// deshalb nicht aus den Messwerten summieren.
$row["Stromkosten"] += grundpreisImJahr($_GET["year"]);
$rowPrev["Stromkosten"] += grundpreisImJahr($_GET["year"] - 1);
//$row["Vergütung+Einsparung Strom"] = $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"];
$row = array_insert($row,["Verg.+Einsp. Strom" => $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"]],4);
$rowPrev = array_insert($rowPrev,["Verg.+Einsp. Strom" => $rowPrev["Einspeisevergütung"]+$rowPrev["Verbrauchsersparnis"]],4);
$i = 0;
foreach ($row as $name => $value) {
if(str_starts_with($name,"Autoladung ges.")){
$html .= "</div><hr class='mt-2 mb-3 border-light' />";
$html .= "</div><hr class='mt-3 mb-3 border-light' />";
$html .= "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
}
/*$html .= "<div class='col-12 col-sm-4 col-md-2 col-xl-1'>
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* Endpunkt der Einstellungsseite.
*
* GET ?action=list&reihe=strom Zeitraeume einer Preistabelle
* POST ?action=save {"reihe": "strom", "zeilen": [ ... ]}
*
* Ein reiner Verteiler: geprueft und geschrieben wird in
* restricted/costs.php, damit Maske und Endpunkt dieselbe Wahrheit ueber
* einen Zeitraum haben.
*/
require_once("../helper.php");
require_once("../restricted/costs.php");
if (!checkLogin()) {
http_response_code(403);
exit;
}
function jsonAntwort($data, $code = 200)
{
http_response_code($code);
header("Content-Type: application/json; charset=utf-8");
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
$action = $_GET["action"] ?? "list";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$body = json_decode(file_get_contents("php://input"), true);
if (!is_array($body)) {
jsonAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
}
try {
switch ($action) {
case "save":
$anzahl = preiseSpeichern($body["reihe"] ?? "", $body["zeilen"] ?? []);
// Die gespeicherte Fassung zurueckgeben: die Maske zeigt danach
// die abgeleiteten Enddaten, nicht die eingetippten.
jsonAntwort([
"gespeichert" => $anzahl,
"zeilen" => preiseLaden($body["reihe"]),
]);
default:
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
}
} catch (InvalidArgumentException $e) {
jsonAntwort(["error" => $e->getMessage()], 400);
} catch (Throwable $e) {
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
}
}
if ($action === "list") {
try {
jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]);
} catch (InvalidArgumentException $e) {
jsonAntwort(["error" => $e->getMessage()], 400);
} catch (Throwable $e) {
jsonAntwort(["error" => "Laden fehlgeschlagen: " . $e->getMessage()], 500);
}
}
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
+366
View File
@@ -0,0 +1,366 @@
<?php
/*
* Daten der Škoda-Seite. Ein Endpunkt, mehrere Abschnitte:
*
* ?was=live letzter bekannter Stand, alle Spalten der jüngsten Zeile
* ?was=ladungen Ladevorgänge der letzten N Tage, aus Sitzungen gebildet
* ?was=kurve Verlauf einer einzelnen Ladung (SoC und kW über Zeit)
* ?was=gesundheit kWh je 100 Ladestandsprozent, je Ladung, mit Trend
* ?was=fahrten Fahrten zwischen zwei Parkständen, mit Koordinaten
*
* Die Fahrzeugdaten stehen in der Tabelle skoda, geschrieben von
* gatherSkodaData.py. Der Zeilentakt ist nicht fest: geschrieben wird, wenn
* das Fahrzeug etwas Neues meldet, mindestens stündlich. Alle Auswertungen
* hier müssen damit umgehen, dass die Abstände zwischen zwei Zeilen von vier
* Minuten bis zu einer Stunde reichen.
*
* Solange kein Fahrzeug angebunden ist, ist die Tabelle leer. Jeder Abschnitt
* liefert dann eine leere, aber wohlgeformte Antwort - die Seite zeigt
* daraufhin ihre Leerzustände und läuft nicht in Fehler.
*/
require_once("../helper.php");
header('Content-Type: application/json; charset=utf-8');
if (!checkLogin()) {
http_response_code(403);
echo json_encode(["error" => "Nicht angemeldet."]);
exit;
}
$was = isset($_GET["was"]) ? $_GET["was"] : "live";
$tage = isset($_GET["tage"]) ? max(1, min(3650, intval($_GET["tage"]))) : 90;
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
if ($mysql->connect_errno) {
http_response_code(500);
echo json_encode(["error" => "Datenbank nicht erreichbar."]);
exit;
}
$mysql->set_charset("utf8mb4");
/*
* Ladevorgänge aus den Einzelzeilen schneiden. Aufeinanderfolgende Zeilen mit
* chg_state = 'CHARGING' gehören zusammen, eine Pause über 30 Minuten trennt
* zwei Vorgänge. Wird von mehreren Abschnitten gebraucht, deshalb einmal hier.
*
* Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer
* Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
* Steht der Zähler still, wurde auswärts geladen (Schnellladesäule); dann
* bleibt wh null und die Kapazitätsrechnung lässt die Ladung aus.
*/
function sitzungenSQL($seit)
{
return "
SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
MIN(soc) AS soc_von, MAX(soc) AS soc_bis,
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc) AS SIGNED) AS zuwachs,
MAX(chg_type) AS art, MAX(target_soc) AS ziel,
ROUND(AVG(chg_kw), 2) AS kw_mittel, ROUND(MAX(chg_kw), 2) AS kw_spitze,
MAX(wb_wh_total) - MIN(wb_wh_total) AS wh,
ROUND(SUM(kwh_teil), 3) AS kwh_fahrzeug,
ROUND(AVG(pv_kw), 2) AS pv_kw, COUNT(*) AS punkte
FROM (
SELECT s.*, SUM(s.neu) OVER (ORDER BY s.datetime) AS sitzung FROM (
SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
wb_wh_total, pv_kw,
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
AND TIMESTAMPDIFF(MINUTE, LAG(datetime) OVER w, datetime) <= 30
THEN 0 ELSE 1 END AS neu,
/*
* Fahrzeugseitige Energie: chg_kw ist die Ladeleistung, die
* das Fahrzeug selbst meldet - gemessen HINTER dem Ladegerät.
* Die API kennt keine Kilowattstunden, deshalb wird hier über
* die Zeit integriert, je Schritt mit dem Mittel aus vorigem
* und aktuellem Wert (Trapez). Beim Laden mit Wechselstrom
* liegt die Leistung fast konstant an, da ist das genau genug.
*
* Die Bedingung hält die Summe innerhalb einer Ladung: über
* eine Lücke von mehr als 30 Minuten hinweg wäre die Fläche
* unter der Kurve frei erfunden.
*/
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
AND TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime)
BETWEEN 1 AND 1800
THEN (COALESCE(chg_kw, 0) + COALESCE(LAG(chg_kw) OVER w, 0)) / 2
* TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) / 3600
ELSE 0 END AS kwh_teil
FROM skoda WHERE datetime >= $seit
WINDOW w AS (ORDER BY datetime)) s) t
WHERE chg_state = 'CHARGING'
GROUP BY sitzung";
}
/*
* Rückfallwert für den Ladewirkungsgrad, solange er sich nicht messen lässt.
* Beim Laden mit Wechselstrom gehen im Ladegerät des Fahrzeugs grob zehn
* Prozent verloren.
*/
const LADE_WIRKUNGSGRAD_ANNAHME = 0.90;
/** Ab so vielen Ladungen gilt der gemessene Wirkungsgrad als belastbar. */
const WIRKUNGSGRAD_MIN_LADUNGEN = 5;
/**
* Wie viele Kilowattstunden ein Ladestandsprozent wert ist.
*
* Zwei Energien je Ladung stehen zur Verfügung, und der Unterschied zwischen
* ihnen ist genau das, was das Ladegerät verheizt:
*
* wh Differenz der Wallbox-Zählerstände - vor dem Ladegerät
* kwh_fahrzeug chg_kw über die Zeit integriert - hinter dem Ladegerät
*
* Für den Fahrverbrauch zählt, was die Batterie verlässt, also die zweite.
* Sie wird bevorzugt; erst wenn zu wenige Ladungen sie hergeben, wird der
* Zählerwert mit dem angenommenen Wirkungsgrad heruntergerechnet.
*
* Der Median statt des Mittels, weil einzelne Ladungen durch die Rundung des
* Ladestands weit ausreissen können.
*
* Gibt null zurück, solange es zu wenige Ladungen gibt. Die Oberfläche zeigt
* dann keine Kilowattstunden an, statt eine erfundene Zahl.
*/
function kapazitaetSchaetzen($mysql)
{
$sql = "SELECT ROUND(wh/1000/zuwachs*100, 3) AS je100_zaehler,
CASE WHEN kwh_fahrzeug > 0
THEN ROUND(kwh_fahrzeug/zuwachs*100, 3) END AS je100_fahrzeug
FROM (" . sitzungenSQL("NOW() - INTERVAL 365 DAY") . ") v
WHERE zuwachs >= 20 AND wh > 1000";
$res = $mysql->query($sql);
if (!$res) {
return null;
}
$zaehler = [];
$fahrzeug = [];
$paare = []; // beide Werte derselben Ladung, für den Wirkungsgrad
while ($z = $res->fetch_assoc()) {
$zaehler[] = floatval($z["je100_zaehler"]);
if ($z["je100_fahrzeug"] !== null) {
$fahrzeug[] = floatval($z["je100_fahrzeug"]);
$paare[] = floatval($z["je100_fahrzeug"]) / floatval($z["je100_zaehler"]);
}
}
if (count($zaehler) < 3) {
return null;
}
$gemessen = count($fahrzeug) >= WIRKUNGSGRAD_MIN_LADUNGEN;
if ($gemessen) {
$netto = median($fahrzeug);
$grad = median($paare);
} else {
$netto = median($zaehler) * LADE_WIRKUNGSGRAD_ANNAHME;
$grad = LADE_WIRKUNGSGRAD_ANNAHME;
}
return [
"je100_zaehler" => round(median($zaehler), 2),
"je100_netto" => round($netto, 2),
"wirkungsgrad" => round($grad, 3),
"gemessen" => $gemessen,
"je_soc_netto" => round($netto / 100, 4),
"n" => count($zaehler),
"n_fahrzeug" => count($fahrzeug),
];
}
/** Median eines nicht leeren Zahlenfeldes. */
function median($werte)
{
sort($werte);
return $werte[intdiv(count($werte), 2)];
}
$antwort = [];
switch ($was) {
/* ------------------------------------------------------------------ live -- */
case "live":
$res = $mysql->query("SELECT * FROM skoda ORDER BY id DESC LIMIT 1");
$zeile = $res ? $res->fetch_assoc() : null;
$antwort = ["stand" => $zeile];
// Wie lange schweigt das Fahrzeug schon? Sagt mehr als der Zeitstempel
// allein, weil die Seite auch aus dem Zwischenspeicher kommen kann.
if ($zeile) {
$res = $mysql->query("SELECT TIMESTAMPDIFF(SECOND, '"
. $mysql->real_escape_string($zeile["datetime"]) . "', NOW()) AS alter_s");
$r = $res ? $res->fetch_assoc() : null;
$antwort["alter_s"] = $r ? intval($r["alter_s"]) : null;
}
// Kennzahlen über den gesamten Bestand, für die Kopfzeile.
$res = $mysql->query("SELECT COUNT(*) AS zeilen, MIN(datetime) AS seit,
MAX(odo_km) AS km FROM skoda");
$antwort["bestand"] = $res ? $res->fetch_assoc() : null;
break;
/* -------------------------------------------------------------- ladungen -- */
case "ladungen":
$sql = "SELECT * FROM (" . sitzungenSQL("NOW() - INTERVAL $tage DAY") . ") v
ORDER BY beginn DESC LIMIT 200";
$res = $mysql->query($sql);
$antwort["ladungen"] = [];
while ($res && $z = $res->fetch_assoc()) {
$antwort["ladungen"][] = $z;
}
break;
/* ----------------------------------------------------------------- kurve -- */
case "kurve":
// Verlauf einer Ladung. Ohne Parameter die jüngste.
$von = isset($_GET["von"]) ? $_GET["von"] : null;
$bis = isset($_GET["bis"]) ? $_GET["bis"] : null;
if (!$von || !$bis) {
$sql = "SELECT beginn, ende FROM (" . sitzungenSQL("NOW() - INTERVAL 365 DAY") . ") v
ORDER BY beginn DESC LIMIT 1";
$res = $mysql->query($sql);
$z = $res ? $res->fetch_assoc() : null;
if (!$z) { $antwort["punkte"] = []; break; }
$von = $z["beginn"];
$bis = $z["ende"];
}
// Eine Zeile Vorlauf und Nachlauf, damit die Kurve nicht in der Luft endet.
$stmt = $mysql->prepare(
"SELECT datetime, soc, chg_kw, wb_kw, pv_kw, chg_state, wb_wh_total
FROM skoda
WHERE datetime BETWEEN (? - INTERVAL 15 MINUTE) AND (? + INTERVAL 15 MINUTE)
ORDER BY datetime");
$stmt->bind_param("ss", $von, $bis);
$stmt->execute();
$res = $stmt->get_result();
$antwort["punkte"] = [];
while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; }
$antwort["von"] = $von;
$antwort["bis"] = $bis;
break;
/* ------------------------------------------------------------ gesundheit -- */
case "gesundheit":
/*
* Kilowattstunden je hundert Ladestandsprozent. Der Wert liegt über der
* nutzbaren Kapazität, weil die Ladeverluste mitgezählt werden - absolut
* also nur eine Näherung, sein Verlauf über die Jahre dagegen die
* eigentliche Aussage.
*
* Nur lange Ladungen an der eigenen Wallbox taugen dazu: bei zwanzig
* Prozentpunkten Zuwachs wiegt ein gerundeter Ladestandspunkt fünf
* Prozent, und eine Schnellladung unterwegs hat auf unserer Seite keinen
* Zähler.
*/
$sql = "SELECT beginn, soc_von, soc_bis, zuwachs, art, wh,
ROUND(wh/1000/zuwachs*100, 2) AS je100
FROM (" . sitzungenSQL("NOW() - INTERVAL $tage DAY") . ") v
WHERE zuwachs >= 20 AND wh > 1000
ORDER BY beginn";
$res = $mysql->query($sql);
$antwort["ladungen"] = [];
while ($res && $z = $res->fetch_assoc()) { $antwort["ladungen"][] = $z; }
// Ausgleichsgerade über die Einzelwerte, x in Jahren. Die Steigung sagt,
// ob der Wert wandert; ihr Fehler, ab welcher Alterung man überhaupt
// etwas sehen könnte.
$n = count($antwort["ladungen"]);
$antwort["trend"] = null;
if ($n >= 6) {
$t0 = strtotime($antwort["ladungen"][0]["beginn"]);
$xs = []; $ys = [];
foreach ($antwort["ladungen"] as $l) {
$xs[] = (strtotime($l["beginn"]) - $t0) / (365.25 * 86400);
$ys[] = floatval($l["je100"]);
}
$mx = array_sum($xs) / $n;
$my = array_sum($ys) / $n;
$sxx = 0; $sxy = 0;
for ($i = 0; $i < $n; $i++) {
$sxx += ($xs[$i] - $mx) ** 2;
$sxy += ($xs[$i] - $mx) * ($ys[$i] - $my);
}
if ($sxx > 0) {
$steigung = $sxy / $sxx;
$achse = $my - $steigung * $mx;
$rest = 0;
for ($i = 0; $i < $n; $i++) {
$rest += ($ys[$i] - ($achse + $steigung * $xs[$i])) ** 2;
}
$streu = $n > 2 ? sqrt($rest / ($n - 2)) : 0;
$fehler = $streu / sqrt($sxx);
$antwort["trend"] = [
"steigung" => round($steigung, 3),
"fehler" => round($fehler, 3),
"achse" => round($achse, 2),
"prozent" => $achse != 0 ? round(100 * $steigung / $achse, 2) : null,
"nachweisbar" => $achse != 0 ? round(100 * 2 * $fehler / $achse, 1) : null,
"spanne_jahre" => round(max($xs), 2),
];
}
}
// Median je Jahr - der Stand, um den die Wolke streut.
$jahre = [];
foreach ($antwort["ladungen"] as $l) {
$j = substr($l["beginn"], 0, 4);
$jahre[$j][] = floatval($l["je100"]);
}
$antwort["jahre"] = [];
foreach ($jahre as $j => $werte) {
sort($werte);
$k = count($werte);
$antwort["jahre"][] = [
"jahr" => $j,
"n" => $k,
"q1" => round($werte[intdiv($k, 4)], 2),
"median" => round($werte[intdiv($k, 2)], 2),
"q3" => round($werte[intdiv(3 * $k, 4)], 2),
];
}
break;
/* --------------------------------------------------------------- fahrten -- */
case "fahrten":
/*
* Eine Fahrt liegt zwischen zwei Parkständen. Die API liefert nur die
* letzte bekannte PARKposition - fährt das Fahrzeug, kommt allein
* IN_MOTION ohne Koordinaten. Start und Ziel sind damit bekannt, die
* Strecke dazwischen nicht.
*
* CAST(... AS SIGNED) ist nötig: soc und odo_km sind vorzeichenlos, und
* eine Differenz unter null bricht die Abfrage sonst ab - genau das
* passiert, sobald zwischen zwei Ständen geladen wurde.
*/
$sql = "SELECT * FROM (
SELECT datetime AS ankunft, address AS nach, lat, lon, odo_km, soc,
LAG(datetime) OVER w AS abfahrt,
LAG(address) OVER w AS von,
LAG(lat) OVER w AS von_lat,
LAG(lon) OVER w AS von_lon,
CAST(odo_km AS SIGNED) - LAG(CAST(odo_km AS SIGNED)) OVER w AS km,
LAG(CAST(soc AS SIGNED)) OVER w - CAST(soc AS SIGNED) AS soc_ab
FROM skoda
WHERE park_state = 'PARKED' AND odo_km IS NOT NULL
AND datetime >= NOW() - INTERVAL $tage DAY
WINDOW w AS (ORDER BY datetime)) f
WHERE km > 0
ORDER BY ankunft DESC LIMIT 100";
$res = $mysql->query($sql);
$antwort["fahrten"] = [];
while ($res && $z = $res->fetch_assoc()) { $antwort["fahrten"][] = $z; }
// Aktueller Standort, für die Karte.
$res = $mysql->query("SELECT datetime, lat, lon, address, park_state
FROM skoda WHERE lat IS NOT NULL
ORDER BY id DESC LIMIT 1");
$antwort["standort"] = $res ? $res->fetch_assoc() : null;
$antwort["kapazitaet"] = kapazitaetSchaetzen($mysql);
break;
default:
http_response_code(400);
$antwort = ["error" => "Unbekannter Abschnitt."];
}
echo json_encode($antwort, JSON_UNESCAPED_UNICODE);
+269
View File
@@ -0,0 +1,269 @@
<?php
/*
* Befehle an das Fahrzeug.
*
* Aufruf per POST: befehl=laden_start | laden_stop | klima_start | klima_stop
* | lueftung_start | lueftung_stop
* temperatur=<Grad Celsius> (nur bei klima_start)
*
* Warum über den Server und nicht unmittelbar aus dem Browser? Der API-
* Schlüssel darf die Seite nicht verlassen. Er steht in skoda.conf neben
* gatherSkodaData.py, wird hier gelesen und geht nur von hier nach draußen.
*
* Nur POST. Ein Befehl, der sich über einen Link auslösen liesse, wäre über
* ein vorausgeladenes Bild oder einen Klick auf eine fremde Seite fernsteuerbar.
*
* ---------------------------------------------------------------------------
* Zwei Schlüssel, zwei Kontingente
*
* Die API begrenzt auf 20 Anfragen je Stunde **und Schlüssel**, und Befehle
* zählen genauso mit wie Abrufe. Steht in skoda.conf ein eigener
* CMD_API_KEY, hat die Steuerung damit ihr eigenes Kontingent und nimmt dem
* Abruf des Fahrzeugzustands nichts weg. Beide Schlüssel werden in der
* MyŠkoda-App angelegt.
*
* Ohne eigenen Schlüssel greift dieses Skript auf API_KEY zurück - dann
* teilen sich beide ein Kontingent, und weil sie in getrennten Prozessen
* laufen und keinen gemeinsamen Zähler haben, ist es fest aufgeteilt:
*
* eigener CMD_API_KEY nur API_KEY
* Abruf 18 / Stunde 14 / Stunde
* Befehle 15 / Stunde 4 / Stunde
* Reserve 2 / Stunde 2 / Stunde
*
* gatherSkodaData.py stellt sich selbst um, sobald der zweite Schlüssel in
* skoda.conf auftaucht - dort ist nichts von Hand nachzuziehen.
*
* Der zweite Schlüssel ist kein Freibrief, den Abruf zu beschleunigen: die
* Kachelrichtlinie zielt auf die Last, nicht auf die Buchführung.
* ---------------------------------------------------------------------------
*
* Nicht angebunden ist die Standheizung. Ihr Start verlangt laut Spec die
* Sicherheits-PIN des Fahrzeugs ("spin"). Die dauerhaft auf dem NAS zu
* hinterlegen, nur damit ein Knopf im Dashboard funktioniert, ist eine andere
* Größenordnung als ein API-Schlüssel - das sollte bewusst entschieden werden,
* nicht nebenbei.
*/
require_once("../helper.php");
header('Content-Type: application/json; charset=utf-8');
const API_BASIS = "https://public.api.connect.skoda-auto.cz/api/v1/vehicles/";
const KONFIG = "/volume1/homes/wagner/SolarManager/skoda.conf";
const KENNUNG = "SmartController-SkodaSteuerung/1.0 (+https://nas.el-wa.org/smart)";
/** Befehle je Stunde - siehe Kopfkommentar. Hängt davon ab, ob die
* Steuerung einen eigenen Schlüssel hat oder ihn mit dem Abruf teilt. */
const BEFEHLE_EIGENER_SCHLUESSEL = 15;
const BEFEHLE_GETEILT = 4;
/** Wo der Stundenzähler liegt. Muss für den Webserver beschreibbar sein. */
const ZAEHLER_ORTE = [
"/volume1/web/smart/tiles", // falls per synoacltool freigegeben
"/var/services/tmp/smart-tiles", // sonst
];
/*
* Die erlaubten Befehle. Der Schlüssel kommt aus dem Formular, der Pfad
* niemals - so kann über den Parameter kein beliebiger Endpunkt der API
* angesprochen werden.
*/
const BEFEHLE = [
"laden_start" => ["pfad" => "charging/start", "text" => "Laden gestartet"],
"laden_stop" => ["pfad" => "charging/stop", "text" => "Laden gestoppt"],
"klima_start" => ["pfad" => "air-conditioning/start", "text" => "Klimatisierung gestartet"],
"klima_stop" => ["pfad" => "air-conditioning/stop", "text" => "Klimatisierung gestoppt"],
"lueftung_start" => ["pfad" => "active-ventilation/start", "text" => "Lüftung gestartet"],
"lueftung_stop" => ["pfad" => "active-ventilation/stop", "text" => "Lüftung gestoppt"],
];
/* ------------------------------------------------------------------ Zugang */
if (!checkLogin()) {
antwort(403, false, "Nicht angemeldet.");
}
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
antwort(405, false, "Nur per POST.");
}
$name = isset($_POST["befehl"]) ? $_POST["befehl"] : "";
if (!isset(BEFEHLE[$name])) {
antwort(400, false, "Unbekannter Befehl.");
}
$befehl = BEFEHLE[$name];
$konfig = konfigLesen();
if (!$konfig) {
antwort(503, false, "Kein API-Schlüssel hinterlegt - skoda.conf fehlt.");
}
$grenze = $konfig["EIGENER"] ? BEFEHLE_EIGENER_SCHLUESSEL : BEFEHLE_GETEILT;
if (!budgetFrei($grenze)) {
antwort(429, false, "Stündliches Kontingent für Befehle erschöpft ("
. $grenze . " je Stunde)."
. ($konfig["EIGENER"] ? "" : " Ein eigener CMD_API_KEY in skoda.conf"
. " würde der Steuerung ein eigenes Kontingent geben.")
. " Später erneut versuchen.");
}
/* ---------------------------------------------------------------- Ausführen */
$rumpf = null;
if ($name === "klima_start") {
// Die Zieltemperatur kommt aus dem Formular und geht in einen Rumpf, den
// die API auswertet - deshalb hart begrenzen statt nur zu vertrauen.
$grad = isset($_POST["temperatur"]) ? floatval($_POST["temperatur"]) : 21.0;
$grad = max(16.0, min(29.5, round($grad * 2) / 2)); // halbe Grad
$rumpf = ["targetTemperature" => ["value" => $grad, "unit" => "CELSIUS"]];
}
list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf);
if ($status === 202) {
antwort(200, true, $befehl["text"] . ". Das Fahrzeug führt den Befehl aus; "
. "in der Anzeige erscheint es beim nächsten Abruf.");
}
antwort(200, false, fehlertext($status, $inhalt));
/* ------------------------------------------------------------- Bausteine -- */
/** Antwort ausgeben und beenden. */
function antwort($code, $ok, $text)
{
http_response_code($code);
echo json_encode(["ok" => $ok, "text" => $text], JSON_UNESCAPED_UNICODE);
exit;
}
/** API-Schlüssel und VIN aus skoda.conf lesen. Format: NAME=wert je Zeile. */
function konfigLesen()
{
if (!is_readable(KONFIG)) {
return null;
}
$werte = [];
foreach (file(KONFIG, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $zeile) {
$zeile = trim($zeile);
if ($zeile === "" || $zeile[0] === "#" || strpos($zeile, "=") === false) {
continue;
}
list($k, $v) = explode("=", $zeile, 2);
$werte[strtoupper(trim($k))] = trim(trim($v), "\"'");
}
if (empty($werte["API_KEY"]) || empty($werte["VIN"])) {
return null;
}
// Eigener Schluessel fuer die Steuerung, wenn hinterlegt - sonst der
// gemeinsame. Welcher es wurde, entscheidet unten ueber das Budget.
$werte["EIGENER"] = !empty($werte["CMD_API_KEY"]);
$werte["SCHLUESSEL"] = $werte["EIGENER"] ? $werte["CMD_API_KEY"] : $werte["API_KEY"];
return $werte;
}
/**
* Stundenzähler für Befehle. Gleiches Verfahren wie die Bremse in tile.php:
* eine kleine Datei, gegen gleichzeitige Zugriffe mit flock abgesichert.
*/
function budgetFrei($grenze)
{
$ort = null;
foreach (ZAEHLER_ORTE as $kandidat) {
if (is_dir($kandidat) && is_writable($kandidat)) {
$ort = $kandidat;
break;
}
}
if (!$ort) {
// Ohne Zähler lieber durchlassen als die Steuerung blockieren; die
// API bremst zur Not selbst mit 429.
return true;
}
$griff = fopen($ort . "/.befehle.json", "c+");
if (!$griff || !flock($griff, LOCK_EX)) {
return true;
}
$stand = json_decode(stream_get_contents($griff), true);
if (!is_array($stand)) {
$stand = ["fenster" => 0, "n" => 0];
}
$jetzt = time();
if ($jetzt - $stand["fenster"] >= 3600) {
$stand = ["fenster" => $jetzt, "n" => 0];
}
$frei = $stand["n"] < $grenze;
if ($frei) {
$stand["n"]++;
}
ftruncate($griff, 0);
rewind($griff);
fwrite($griff, json_encode($stand));
flock($griff, LOCK_UN);
fclose($griff);
return $frei;
}
/** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */
function senden($konfig, $pfad, $rumpf)
{
$url = API_BASIS . rawurlencode($konfig["VIN"]) . "/" . $pfad;
$kopf = [
"X-API-Key: " . $konfig["SCHLUESSEL"],
"Accept: application/json",
];
$ch = curl_init($url);
$optionen = [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => KENNUNG,
CURLOPT_TIMEOUT => 20,
CURLOPT_CONNECTTIMEOUT => 5,
];
if ($rumpf !== null) {
$kopf[] = "Content-Type: application/json";
$optionen[CURLOPT_POSTFIELDS] = json_encode($rumpf);
} else {
$optionen[CURLOPT_POSTFIELDS] = "";
}
$optionen[CURLOPT_HTTPHEADER] = $kopf;
curl_setopt_array($ch, $optionen);
$inhalt = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$fehler = curl_error($ch);
curl_close($ch);
if ($inhalt === false) {
error_log("skodaCmd: $url nicht erreichbar: $fehler");
return [0, null];
}
return [$status, $inhalt];
}
/**
* Aus dem Statuscode eine Auskunft machen, mit der man etwas anfangen kann.
* Die API liefert dazu einen Problembericht nach RFC 9457; dessen "detail"
* ist oft die eigentliche Erklärung.
*/
function fehlertext($status, $inhalt)
{
$detail = "";
$bericht = json_decode((string)$inhalt, true);
if (is_array($bericht) && !empty($bericht["detail"])) {
$detail = " (" . $bericht["detail"] . ")";
}
switch ($status) {
case 0: return "Die Škoda-API ist nicht erreichbar.";
case 400: return "Die API hat den Befehl abgelehnt." . $detail;
case 401: return "Der API-Schlüssel ist abgelaufen - in der MyŠkoda-App erneuern.";
case 403: return "Der Schlüssel darf diesen Befehl nicht ausführen." . $detail;
case 404: return "Fahrzeug nicht gefunden - stimmt die VIN in skoda.conf?";
case 422: return "Das Fahrzeug kann das gerade nicht." . $detail;
case 429: return "Kontingent der API erschöpft - später erneut versuchen.";
case 503:
case 504: return "Die API antwortet nicht rechtzeitig. Später erneut versuchen.";
default: return "Unerwartete Antwort der API (HTTP " . $status . ")." . $detail;
}
}
+290
View File
@@ -0,0 +1,290 @@
<?php
/*
* Kachel-Zwischenspeicher für die Karte.
*
* Aufruf: tile.php?z=16&x=34567&y=22456
*
* Liegt die Kachel im Zwischenspeicher, kommt sie von dort. Fehlt sie, holt
* das NAS sie einmal bei OpenStreetMap, legt sie ab und liefert aus. Danach
* verlässt für diese Kachel nie wieder eine Anfrage das Haus.
*
* Warum überhaupt ein Zwischenspeicher und nicht der direkte Weg vom Browser
* zu tile.openstreetmap.org? Drei Gründe:
*
* 1. Die Kachelrichtlinie der OSM Foundation verlangt Zwischenspeicherung
* von mindestens sieben Tagen. Der direkte Weg erfüllt das nur, soweit
* der Browser mitspielt.
* 2. Ohne Zwischenspeicher ginge die Parkposition des Fahrzeugs - also die
* Wohnadresse - bei jedem Seitenaufruf als Kachelanfrage nach draußen.
* 3. Einmal geholte Gegenden bleiben ohne Internet benutzbar.
*
* Was die Richtlinie ausdrücklich verbietet, ist Vorratsspeicherung: Kacheln
* auf Verdacht holen, Gebiete vorab abgrasen, Offline-Archive anlegen. Dieses
* Skript holt deshalb ausschließlich das, was gerade jemand ansieht, und die
* Bremse unten begrenzt zusätzlich, wie viel das pro Minute sein darf.
*
* Richtlinie: https://operations.osmfoundation.org/policies/tiles/
*/
require_once("../helper.php");
/* ------------------------------------------------------------ Einstellungen */
/** Woher die Kacheln kommen. {z}/{x}/{y} werden ersetzt. */
const KACHEL_QUELLE = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
/*
* Die Richtlinie verlangt einen eigenen, kontaktierbaren User-Agent. Vorgaben
* von Bibliotheken werden ohne Vorwarnung geblockt. Die Domain genügt als
* Kontakt; wer lieber eine Mailadresse hinterlegt, ersetzt sie hier.
*/
const KENNUNG = "SmartController-SkodaKarte/1.0 (+https://nas.el-wa.org/smart)";
/*
* Wo die Kacheln liegen.
*
* Bevorzugt neben der Seite unter tiles/ - dort bleiben sie über Neustarts
* erhalten und liegen im Backup. PHP läuft auf dem NAS aber unter einem
* eigenen Benutzer, und DSM vergibt im Web-Verzeichnis ACLs, die ihm das
* Schreiben verwehren. Der POSIX-Modus täuscht: drwxrwxrwx mit einem "+"
* dahinter heisst, dass die ACL entscheidet, nicht die Rechtebits.
*
* Damit die Karte auch ohne Eingriff funktioniert, wird ersatzweise das
* Systemverzeichnis für flüchtige Dateien genommen. Das kostet nur den
* Bestand: es wird bei Gelegenheit geleert, die Kacheln müssen dann erneut
* geholt werden. Dauerhaft wird es mit einem Befehl als root:
*
* synoacltool -add /volume1/web/smart/tiles \
* "user:http:allow:rwxpdDaARWc--:fd--"
*
* Welcher Ort gerade benutzt wird, sagt speicherOrt() im Fehlerprotokoll,
* sobald zum ersten Mal eine Kachel geholt wird.
*/
const SPEICHER_ORTE = [
__DIR__ . "/../tiles", // dauerhaft, bevorzugt
"/var/services/tmp/smart-tiles", // flüchtig, aber immer beschreibbar
];
/** Zoomstufen, die ausgeliefert werden. Feiner als Hausnummern braucht es nicht. */
const ZOOM_MIN = 6;
const ZOOM_MAX = 18;
/** Wie lange der Browser eine Kachel behalten darf (Sekunden). */
const BROWSER_FRIST = 7 * 24 * 3600;
/**
* Nach dieser Zeit wird eine Kachel beim nächsten Ansehen neu geholt. Die
* Karte ändert sich, aber nicht schnell. Scheitert das Nachholen, bleibt die
* alte Kachel - die Karte ist dann veraltet, aber nie kaputt.
*/
const AUFFRISCHEN_NACH = 60 * 24 * 3600;
/*
* Bremse gegen den eigenen Fehler. Ein Kartenfeld dieser Größe braucht rund
* zwölf Kacheln je Ansicht; wer sich durch mehrere Zoomstufen klickt, kommt
* auf einige Dutzend. Alles weit darüber wäre eine Schleife im Frontend - und
* die sähe für OSM wie Vorratsspeicherung aus. Ist die Grenze erreicht, wird
* eine graue Ersatzkachel geliefert statt weiter nach draußen zu gehen.
*/
const HOLEN_JE_MINUTE = 150;
const HOLEN_JE_STUNDE = 2000;
/* ------------------------------------------------------------------ Zugriff */
if (!checkLogin()) {
http_response_code(403);
exit;
}
/* ----------------------------------------------------------------- Prüfung */
$z = filter_input(INPUT_GET, "z", FILTER_VALIDATE_INT);
$x = filter_input(INPUT_GET, "x", FILTER_VALIDATE_INT);
$y = filter_input(INPUT_GET, "y", FILTER_VALIDATE_INT);
// Ohne diese Prüfung liesse sich über z/x/y aus dem Speicherverzeichnis
// heraus schreiben - die Werte gehen schliesslich in einen Dateipfad.
$grenze = ($z === false || $z === null) ? 0 : (1 << $z);
if ($z === false || $z === null || $z < ZOOM_MIN || $z > ZOOM_MAX
|| $x === false || $x === null || $x < 0 || $x >= $grenze
|| $y === false || $y === null || $y < 0 || $y >= $grenze) {
http_response_code(400);
exit;
}
$pfad = speicherOrt() . "/$z/$x/$y.png";
/* -------------------------------------------------------------- Ausliefern */
$vorhanden = is_file($pfad);
$veraltet = $vorhanden && (time() - filemtime($pfad) > AUFFRISCHEN_NACH);
if (!$vorhanden || $veraltet) {
if (holeUndSpeichere($z, $x, $y, $pfad)) {
$vorhanden = true;
}
}
if ($vorhanden) {
liefereDatei($pfad);
} else {
liefereErsatzkachel();
}
exit;
/* ------------------------------------------------------------- Bausteine -- */
/**
* Der erste beschreibbare Ort aus SPEICHER_ORTE, einmal je Aufruf ermittelt.
* Existiert keiner, wird der letzte angelegt - der liegt im Systemverzeichnis
* für flüchtige Dateien und ist immer beschreibbar.
*/
function speicherOrt()
{
static $ort = null;
if ($ort !== null) {
return $ort;
}
foreach (SPEICHER_ORTE as $kandidat) {
if (is_dir($kandidat) && is_writable($kandidat)) {
return $ort = $kandidat;
}
}
$ort = SPEICHER_ORTE[count(SPEICHER_ORTE) - 1];
if (!is_dir($ort)) {
@mkdir($ort, 0755, true);
}
return $ort;
}
/**
* Eine Kachel bei der Quelle holen und ablegen.
* Gibt true zurück, wenn danach eine brauchbare Datei vorliegt.
*/
function holeUndSpeichere($z, $x, $y, $pfad)
{
if (!bremseErlaubt()) {
return is_file($pfad); // notfalls die veraltete Kachel
}
$url = str_replace(["{z}", "{x}", "{y}"], [$z, $x, $y], KACHEL_QUELLE);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => KENNUNG,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 2,
]);
$inhalt = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
// Auf den PNG-Kopf prüfen, statt nur auf den Statuscode: eine Fehlerseite
// mit Status 200 wäre sonst als Kachel im Speicher gelandet und bliebe
// dort für immer.
if ($status !== 200 || $inhalt === false || strncmp($inhalt, "\x89PNG", 4) !== 0) {
return is_file($pfad);
}
if (!is_dir(dirname($pfad)) && !mkdir(dirname($pfad), 0755, true)) {
return is_file($pfad);
}
// Erst daneben schreiben, dann umbenennen. Sonst könnte ein zweiter
// Aufruf eine halb geschriebene Datei ausliefern.
$temp = $pfad . "." . getmypid() . ".teil";
if (file_put_contents($temp, $inhalt) === false) {
return is_file($pfad);
}
if (!rename($temp, $pfad)) {
unlink($temp);
return is_file($pfad);
}
return true;
}
/**
* Darf jetzt noch eine Kachel geholt werden?
*
* Gezählt wird in einer kleinen Datei, gegen gleichzeitige Zugriffe mit
* flock abgesichert. Zwei Fenster: eines je Minute, eines je Stunde. Läuft
* ein Fenster ab, fängt sein Zähler wieder bei null an.
*/
function bremseErlaubt()
{
$datei = speicherOrt() . "/.bremse.json";
$griff = fopen($datei, "c+");
if (!$griff) {
return true;
}
if (!flock($griff, LOCK_EX)) {
fclose($griff);
return true;
}
$stand = json_decode(stream_get_contents($griff), true);
if (!is_array($stand)) {
$stand = ["minute" => 0, "min_n" => 0, "stunde" => 0, "std_n" => 0];
}
$jetzt = time();
if ($jetzt - $stand["minute"] >= 60) { $stand["minute"] = $jetzt; $stand["min_n"] = 0; }
if ($jetzt - $stand["stunde"] >= 3600) { $stand["stunde"] = $jetzt; $stand["std_n"] = 0; }
$erlaubt = $stand["min_n"] < HOLEN_JE_MINUTE && $stand["std_n"] < HOLEN_JE_STUNDE;
if ($erlaubt) {
$stand["min_n"]++;
$stand["std_n"]++;
}
ftruncate($griff, 0);
rewind($griff);
fwrite($griff, json_encode($stand));
flock($griff, LOCK_UN);
fclose($griff);
if (!$erlaubt) {
error_log("tile.php: Bremse greift, keine weiteren Kacheln in diesem Fenster.");
}
return $erlaubt;
}
/** Eine gespeicherte Kachel ausliefern, mit Unterstützung für 304. */
function liefereDatei($pfad)
{
$zeit = filemtime($pfad);
$marke = '"' . md5($pfad . $zeit) . '"';
header("Content-Type: image/png");
header("Cache-Control: public, max-age=" . BROWSER_FRIST);
header("ETag: $marke");
header("Last-Modified: " . gmdate("D, d M Y H:i:s", $zeit) . " GMT");
$bekannt = isset($_SERVER["HTTP_IF_NONE_MATCH"]) ? trim($_SERVER["HTTP_IF_NONE_MATCH"]) : "";
if ($bekannt === $marke) {
http_response_code(304);
return;
}
header("Content-Length: " . filesize($pfad));
readfile($pfad);
}
/**
* Graue Ersatzkachel, wenn nichts zu holen war - kein Internet, Quelle
* gestört oder Bremse aktiv. Ein leeres Feld ist besser als ein kaputtes
* Bildsymbol, und es wird bewusst nicht zwischengespeichert.
*/
function liefereErsatzkachel()
{
header("Content-Type: image/png");
header("Cache-Control: no-store");
// 1x1 Punkt in Grau, von Leaflet auf Kachelgröße gezogen.
echo base64_decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
. "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
+531
View File
@@ -375,3 +375,534 @@ input[type='range']::-webkit-slider-runnable-track {
fill: currentColor;
opacity: 0.85;
}
/* --- Einstellungen: Preistabellen ------------------------------------ */
/* Der Spaltenkopf steht ueber der ersten Zeile und muss deren Einrueckung
treffen, sonst rutschen Beschriftung und Feld auseinander. Er bleibt
einzeilig, braucht also keinen senkrechten Gutter - und ohne den entfaellt
auch Bootstraps negative Marge von selbst. */
.preis-kopf {
--bs-gutter-y: 0;
padding: 0 0.25rem 0.35rem;
}
/* Eine Zeile ist selbst eine .row mit g-2. Bootstrap zieht so eine Reihe um
die Gutter-Hoehe nach oben und gibt jeder Spalte dieselbe Marge nach unten
zurueck - gedacht fuer Reihen, die nebeneinander im Raster stehen. Hier
stehen sie untereinander, und der Zug nach oben frisst den unteren
Innenabstand der vorigen Zeile auf: auf dem Handy, wo der Papierkorb in
einer eigenen Zeile landet, klebte er am Trennstrich.
Die senkrechte Gutter-Marge wird hier gebraucht - sie haelt die umbrochenen
Feldzeilen auseinander -, der Zug nach oben nicht. Die Regel braucht eine
Klasse mehr als Bootstraps .row: solar.css wird vor AdminLTE geladen und
verliert bei gleicher Spezifitaet. */
.preis-zeilen .preis-zeile {
margin-top: 0;
}
/* Oben um die Gutter-Hoehe weniger Innenabstand, damit ueber der ersten und
unter der letzten Feldzeile gleich viel Luft steht. */
.preis-zeile {
padding: 0.15rem 0.25rem 0.65rem;
border-top: 1px solid rgba(128, 128, 128, 0.25);
}
/* Die Einheit ist Beiwerk und darf der Zahl nicht den Platz nehmen: hinter
"kWh/100 km" bliebe in voller Schrift kaum ein Drittel der Spalte fuer das
Eingabefeld uebrig. */
.preis-zeile .input-group-text {
font-size: 0.75rem;
padding-left: 0.4rem;
padding-right: 0.4rem;
}
/* Der Zeitraum, der heute gilt - der einzige, den man beim Nachschlagen
wirklich sucht. */
.preis-heute {
background: rgba(0, 120, 143, 0.12);
border-radius: 0.4rem;
}
/* ==========================================================================
Skoda-Seite (restricted/skoda.php)
Farben kommen aus den Bootstrap-Variablen des Themes, damit die Seite dem
Hell-/Dunkelmodus folgt, ohne eine zweite Palette zu pflegen. Nur drei
Bedeutungsfarben stehen fuer sich: bernstein fuer "offen", gelb fuer
eingeschaltetes Licht, gruen fuer Laden.
========================================================================== */
.skoda-auto,
.skoda-teile,
.skoda-karte {
--skoda-offen: #e0904f;
--skoda-licht: #f2d16b;
--skoda-laden: #4caf7d;
--skoda-blech: 128, 138, 148;
}
/* --- Fahrzeug in Draufsicht ----------------------------------------------
Massstaeblich: 4648 x 1877 mm ergeben das Seitenverhaeltnis 2,48:1, der
Radstand von 2765 mm die Lage der Raeder. */
.skoda-auto {
width: 100%;
max-width: 215px;
height: auto;
}
.skoda-auto .karosserie {
fill: rgba(var(--skoda-blech), 0.17);
stroke: rgba(var(--skoda-blech), 0.55);
stroke-width: 2;
}
.skoda-auto .rad rect { fill: rgba(18, 20, 24, 0.9); }
.skoda-auto .spiegel rect { fill: rgba(var(--skoda-blech), 0.55); }
.skoda-auto .reling rect { fill: rgba(var(--skoda-blech), 0.62); }
.skoda-auto .spoiler { fill: rgba(var(--skoda-blech), 0.5); }
.skoda-auto .sicke {
fill: none;
stroke: rgba(var(--skoda-blech), 0.35);
stroke-width: 1.5;
}
/* Das Tech-Deck Face: beim Enyaq eine glaenzend schwarze Blende ueber die
volle Bugbreite. Von oben sieht man ihre Oberkante. */
.skoda-auto .techdeck {
fill: rgba(12, 14, 18, 0.92);
stroke: rgba(var(--skoda-blech), 0.4);
stroke-width: 1;
}
.skoda-auto .scheibe,
.skoda-auto .dach {
fill: rgba(var(--skoda-blech), 0.28);
stroke: rgba(var(--skoda-blech), 0.45);
stroke-width: 1.5;
}
/* Grundzustand aller schaltbaren Teile: geschlossen und unauffaellig. */
.skoda-auto .teil > *,
.skoda-auto .teil > g > * {
fill: rgba(var(--skoda-blech), 0.34);
stroke: rgba(var(--skoda-blech), 0.6);
stroke-width: 1.5;
transition: fill .25s, stroke .25s, filter .25s;
}
.skoda-auto .teil-fenster > rect { fill: rgba(var(--skoda-blech), 0.5); }
/* Offen: bernstein und leicht leuchtend. Die Farbe allein traegt die Aussage
nicht - die Zeile unter dem Bild benennt jedes offene Teil zusaetzlich. */
.skoda-auto .teil.offen > *,
.skoda-auto .teil.offen > g > * {
fill: var(--skoda-offen);
stroke: var(--skoda-offen);
filter: drop-shadow(0 0 6px rgba(224, 144, 79, 0.65));
}
/* Licht: vorn die vier LED-Elemente je Seite, hinten das Leuchtenband. */
.skoda-auto .teil-licht.an > rect {
fill: var(--skoda-licht);
stroke: var(--skoda-licht);
filter: drop-shadow(0 0 8px rgba(242, 209, 107, 0.75));
}
.skoda-auto .teil-licht.an > rect.rueck {
fill: #e06060;
stroke: #e06060;
filter: drop-shadow(0 0 8px rgba(224, 96, 96, 0.7));
}
.skoda-auto .teil-stecker > path { fill: none; stroke-width: 2; }
.skoda-auto .teil-stecker.laedt > circle {
fill: var(--skoda-laden);
stroke: var(--skoda-laden);
animation: skodaPuls 1.8s ease-in-out infinite;
}
.skoda-auto .teil-stecker.laedt > path { stroke: #0d1210; }
@keyframes skodaPuls {
0%, 100% { filter: drop-shadow(0 0 3px rgba(76, 175, 125, 0.5)); }
50% { filter: drop-shadow(0 0 12px rgba(76, 175, 125, 0.95)); }
}
/* Hervorheben, wenn der zugehoerige Eintrag darunter beruehrt wird - und
umgekehrt. Sichtbarer Fokus, damit das auch mit der Tastatur geht. */
.skoda-auto .teil.hervor > *,
.skoda-auto .teil.hervor > g > * {
stroke: var(--bs-primary, #0d6efd);
stroke-width: 2.5;
}
@media (hover: hover) {
.skoda-auto .teil:hover > *,
.skoda-auto .teil > g:hover > * {
stroke: var(--bs-primary, #0d6efd);
stroke-width: 2.5;
}
}
.skoda-auto .teil:focus,
.skoda-auto .teil > g:focus { outline: none; }
.skoda-auto .teil:focus-visible > *,
.skoda-auto .teil > g:focus-visible > * {
stroke: var(--bs-primary, #0d6efd);
stroke-width: 3;
}
@media (prefers-reduced-motion: reduce) {
.skoda-auto .teil > *,
.skoda-auto .teil > g > * { transition: none; }
.skoda-auto .teil-stecker.laedt > circle { animation: none; }
}
/* --- Zustandszeile unter dem Fahrzeug ------------------------------------ */
.skoda-teile {
display: flex;
flex-wrap: wrap;
gap: .35rem;
justify-content: center;
margin-top: .9rem;
}
.skoda-teile .chip {
display: inline-flex;
align-items: center;
gap: .35rem;
font-size: .78rem;
padding: .2rem .55rem;
border-radius: 1rem;
border: 1px solid var(--bs-border-color, rgba(128, 128, 128, .35));
color: var(--bs-secondary-color, #9aa0a6);
cursor: default;
white-space: nowrap;
}
.skoda-teile .chip.offen { border-color: var(--skoda-offen); color: var(--skoda-offen); }
.skoda-teile .chip.hervor {
border-color: var(--bs-primary, #0d6efd);
color: var(--bs-body-color, #e8ede9);
}
/* Auf Beruehrungsgeraeten sind die Ziele zu klein: eine Fingerkuppe deckt
rund 9 mm ab, die Chips waren keine 6 mm hoch. Gleiches gilt fuer die
Fahrtzeilen und den Umschalter. */
@media (pointer: coarse) {
.skoda-teile { gap: .5rem; }
.skoda-teile .chip {
font-size: .85rem;
padding: .45rem .8rem;
cursor: pointer;
}
.skoda-liste .zeile { padding: .75rem .9rem; }
.skoda-umschalter button { font-size: .85rem; padding: .5rem .9rem; }
}
.skoda-teile .chip.an { border-color: var(--skoda-licht); color: var(--skoda-licht); }
/* --- Ladestandsring ------------------------------------------------------ */
.skoda-ring { width: 100%; max-width: 190px; height: auto; }
.skoda-ring .ring-bahn {
fill: none;
stroke: rgba(128, 138, 148, 0.22);
stroke-width: 16;
}
.skoda-ring .ring-wert {
fill: none;
stroke: #4caf7d;
stroke-width: 16;
stroke-linecap: round;
transform: rotate(-90deg);
transform-origin: 100px 100px;
transition: stroke-dashoffset .6s ease, stroke .3s;
}
.skoda-ring .ring-ziel {
stroke: var(--bs-body-color, #e8ede9);
stroke-width: 3;
opacity: .75;
transform-origin: 100px 100px;
}
.skoda-ring .ring-zahl {
fill: var(--bs-body-color, #e8ede9);
font-size: 46px;
font-weight: 600;
text-anchor: middle;
font-variant-numeric: tabular-nums;
}
.skoda-ring .ring-einheit {
fill: var(--bs-secondary-color, #9aa0a6);
font-size: 14px;
letter-spacing: .12em;
text-anchor: middle;
text-transform: uppercase;
}
/* --- Wertetabelle -------------------------------------------------------- */
.skoda-werte {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
gap: .75rem 1.25rem;
}
.skoda-wert { display: flex; flex-direction: column; }
.skoda-wert .k {
font-size: .7rem;
letter-spacing: .09em;
text-transform: uppercase;
color: var(--bs-secondary-color, #9aa0a6);
}
.skoda-wert .v {
font-size: 1.15rem;
font-variant-numeric: tabular-nums;
}
/* --- Herkunft des Ladestroms --------------------------------------------- */
.skoda-balken {
display: flex;
height: .7rem;
border-radius: .35rem;
overflow: hidden;
background: rgba(128, 138, 148, 0.2);
}
.skoda-balken > div { transition: width .5s ease; }
.skoda-balken .teil-pv { background: #f0b429; }
.skoda-balken .teil-netz { background: #7a8791; }
/* --- Listen (Ladungen, Fahrten) ------------------------------------------ */
/* Der Innenabstand oben trennt die erste Zeile von der Ueberschrift. Ohne
ihn klebt sie am Umschalter, was auf dem Handy besonders auffaellt. */
.skoda-liste {
max-height: 400px;
overflow-y: auto;
padding-top: .5rem;
}
.skoda-liste .zeile {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: .75rem;
padding: .5rem .9rem;
border-top: 1px solid var(--bs-border-color, rgba(128, 128, 128, .25));
}
.skoda-liste .zeile:first-child { border-top: none; }
@media (hover: hover) {
.skoda-liste .zeile:hover { background: rgba(128, 138, 148, 0.08); }
}
.skoda-liste .wann { font-size: .82rem; color: var(--bs-secondary-color, #9aa0a6); }
.skoda-liste .was { font-size: .95rem; font-variant-numeric: tabular-nums; text-align: right; }
.skoda-liste .neben { display: block; font-size: .75rem; color: var(--bs-secondary-color, #9aa0a6); }
.skoda-liste .leer { padding: 1.5rem .9rem; color: var(--bs-secondary-color, #9aa0a6); }
/* Umschalter kWh <-> Prozent in der Ueberschrift der Fahrtenliste. Welche
Einheit sichtbar ist, entscheidet die Klasse auf der Kachel - so bleibt die
Auswahl bestehen, wenn die Liste neu gebaut wird. */
.zeigt-kwh .v-prozent,
.zeigt-prozent .v-kwh,
.zeigt-prozent #skodaVerbrauchBasis { display: none; }
.skoda-umschalter {
display: inline-flex;
border: 1px solid var(--bs-border-color, rgba(128, 128, 128, .35));
border-radius: .3rem;
overflow: hidden;
}
.skoda-umschalter button {
background: none;
border: none;
color: var(--bs-secondary-color, #9aa0a6);
font-size: .72rem;
line-height: 1;
padding: .25rem .5rem;
cursor: pointer;
}
.skoda-umschalter button + button {
border-left: 1px solid var(--bs-border-color, rgba(128, 128, 128, .35));
}
.skoda-umschalter button:hover { background: rgba(128, 138, 148, .12); }
.skoda-umschalter button.aktiv {
background: rgba(128, 138, 148, .22);
color: var(--bs-body-color, #e8ede9);
}
.skoda-umschalter button:focus-visible {
outline: 2px solid var(--bs-primary, #0d6efd);
outline-offset: -2px;
}
/* Fahrtzeilen, die sich auf der Karte zeigen lassen. Der Zeiger und die
Markierung sind der einzige Hinweis darauf - ohne den waere die Kopplung
nicht zu erraten. */
.skoda-liste .zeile.anklickbar { cursor: pointer; }
.skoda-liste .zeile.anklickbar:focus-visible {
outline: 2px solid var(--bs-primary, #0d6efd);
outline-offset: -2px;
}
/* Dieselbe Farbe wie die gewaehlte Linie auf der Karte - die Zuordnung
zwischen Zeile und Linie soll ohne Nachdenken erkennbar sein. */
.skoda-liste .zeile.gewaehlt {
background: rgba(224, 144, 79, 0.14);
box-shadow: inset 3px 0 0 #e0904f;
}
/* --- Karte (Leaflet) -----------------------------------------------------
Die Kacheln kommen ueber ajax/tile.php aus dem eigenen Zwischenspeicher.
OpenStreetMap-Kacheln sind hell; im dunklen Dashboard wuerden sie wie ein
Scheinwerfer wirken. invert + hue-rotate dreht sie ins Dunkle, ohne einen
zweiten Anbieter samt eigener Bedingungen ins Spiel zu bringen. Der Filter
sitzt auf den Kacheln allein - Marker, Linien und Bedienelemente behalten
ihre Farben. */
.skoda-karte {
height: 420px;
width: 100%;
border-radius: .4rem;
background: rgba(128, 138, 148, 0.12);
z-index: 0; /* sonst legt sich die Karte ueber die Seitenleiste */
}
.skoda-karte .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(0.9);
}
/* Bedienelemente und Sprechblasen an das dunkle Theme angleichen. */
.skoda-karte .leaflet-bar a,
.skoda-karte .leaflet-bar a:hover {
background: #23282c;
color: var(--bs-body-color, #e8ede9);
border-bottom-color: #33393e;
}
.skoda-karte .leaflet-bar a:hover { background: #2d3338; }
.skoda-karte .leaflet-popup-content-wrapper,
.skoda-karte .leaflet-popup-tip {
background: #23282c;
color: var(--bs-body-color, #e8ede9);
box-shadow: 0 3px 14px rgba(0, 0, 0, .5);
}
.skoda-karte .leaflet-popup-content { margin: .6rem .8rem; line-height: 1.5; }
.skoda-karte .leaflet-popup-close-button { color: var(--bs-secondary-color, #9aa0a6); }
/* Die Nennung von OpenStreetMap ist Pflicht und darf nicht versteckt werden -
nur ruhiger gesetzt, damit sie das Bild nicht dominiert.
Ohne Hintergrundkasten, dafuer mit Schattenwurf: ein durchsichtiger
Hintergrund allein liesse den Text ueber hellen Kartenstellen verschwinden,
und unlesbar waere so gut wie versteckt. Der Schatten haelt ihn auf jedem
Untergrund erkennbar, ohne einen Balken ins Bild zu setzen. */
/* Der doppelte Klassenname ist noetig, nicht Zierde: Leaflet setzt den
Hintergrund ueber .leaflet-container .leaflet-control-attribution, also mit
derselben Spezifitaet wie ein einfaches .skoda-karte davor - und leaflet.css
wird spaeter geladen, gewinnt bei Gleichstand also. Der Kartencontainer
traegt beide Klassen, damit sticht diese Regel. */
.skoda-karte.leaflet-container .leaflet-control-attribution {
background: transparent;
color: #c9d0d6;
font-size: .68rem;
padding: 0 .4rem;
text-shadow: 0 1px 2px rgba(0, 0, 0, .9), 0 0 4px rgba(0, 0, 0, .75);
}
.skoda-karte.leaflet-container .leaflet-control-attribution a {
color: #a9c8f0;
text-shadow: inherit;
}
.skoda-kartenlegende {
display: inline-flex;
gap: .9rem;
font-size: .75rem;
color: var(--bs-secondary-color, #9aa0a6);
}
.skoda-kartenlegende span { display: inline-flex; align-items: center; gap: .3rem; }
.skoda-kartenlegende .pkt { width: .55rem; height: .55rem; border-radius: 50%; }
.skoda-kartenlegende .pkt.jetzt { background: #4caf7d; }
.skoda-kartenlegende .pkt.halt { background: #8a949c; }
.skoda-kartenlegende .lin {
width: 1.1rem;
border-top: 2px dashed #8a949c;
}
/* --- Befund -------------------------------------------------------------- */
.skoda-befund .zahl {
font-size: 2.4rem;
font-weight: 600;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.skoda-befund .zahl .einheit {
font-size: .95rem;
font-weight: 400;
color: var(--bs-secondary-color, #9aa0a6);
margin-left: .35rem;
}
.skoda-befund dl { margin: 1rem 0 0; display: grid; gap: .5rem; }
.skoda-befund dl > div { display: flex; justify-content: space-between; gap: 1rem; }
.skoda-befund dt {
font-weight: 400;
color: var(--bs-secondary-color, #9aa0a6);
font-size: .85rem;
}
.skoda-befund dd { margin: 0; font-variant-numeric: tabular-nums; font-size: .9rem; }
/* --- Steuerung ----------------------------------------------------------
Die Befehle sitzen im Modal, das ueber die anklickbaren Stellen im
Fahrzeugbild aufgeht. */
.skoda-steuerung .knoepfe { display: flex; flex-wrap: wrap; gap: .5rem; }
.skoda-steuerung .knoepfe button { flex: 1 1 11rem; }
.skoda-steuerung .temperatur {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
font-size: .9rem;
}
.skoda-steuerung .temperatur span { display: flex; align-items: center; gap: .35rem; }
.skoda-steuerung .temperatur input { width: 5.5rem; }
@media (pointer: coarse) {
.skoda-steuerung .knoepfe button { padding: .7rem 1rem; }
}
.skoda-rueckmeldung {
font-size: .9rem;
padding: .5rem .75rem;
border-radius: .35rem;
display: none;
}
.skoda-rueckmeldung.warte,
.skoda-rueckmeldung.gut,
.skoda-rueckmeldung.schlecht { display: block; }
.skoda-rueckmeldung.warte { background: rgba(128, 138, 148, .16); color: var(--bs-secondary-color, #9aa0a6); }
.skoda-rueckmeldung.gut { background: rgba(76, 175, 125, .16); color: #6fd3a0; }
.skoda-rueckmeldung.schlecht { background: rgba(224, 96, 96, .16); color: #f08a8a; }
/* --- Anklickbare Stellen im Fahrzeugbild --------------------------------
Der Ring unterscheidet Bedienung von Anzeige: die uebrigen Teile zeigen
nur ihren Zustand, diese beiden aendern etwas. */
.skoda-auto .steuer { cursor: pointer; }
.skoda-auto .steuer .ring {
fill: none;
stroke: var(--bs-primary, #4b8fd6);
stroke-width: 1.5;
stroke-dasharray: 3 3;
opacity: .55;
transition: opacity .2s, stroke-width .2s;
}
.skoda-auto .steuer .flaeche {
fill: rgba(75, 143, 214, .22);
stroke: var(--bs-primary, #4b8fd6);
stroke-width: 1.5;
}
.skoda-auto .steuer .glyphe {
fill: none;
stroke: var(--bs-primary, #4b8fd6);
stroke-width: 1.8;
stroke-linecap: round;
}
@media (hover: hover) {
.skoda-auto .steuer:hover .ring { opacity: 1; stroke-width: 2.5; }
}
.skoda-auto .steuer:focus { outline: none; }
.skoda-auto .steuer:focus-visible .ring { opacity: 1; stroke-width: 3; }
.skoda-hinweis {
margin: .6rem 0 0;
font-size: .75rem;
color: var(--bs-secondary-color, #9aa0a6);
text-align: center;
}
.skoda-alter { font-size: .85rem; }
+1 -1
View File
@@ -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,
+136 -132
View File
@@ -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;
}
+6
View File
File diff suppressed because one or more lines are too long
+254
View File
@@ -0,0 +1,254 @@
/*
* Einstellungsseite: Preistabellen bearbeiten.
*
* Eine Preistabelle ist eine Zeitreihe - ab einem Stichtag gilt ein Preis, bis
* der naechste Stichtag kommt. Deshalb gibt es hier nur ein Datumsfeld je
* Zeile. Wo eine Zeile endet, steht als grauer Text daneben und ergibt sich
* aus der naechsten; die juengste Zeile endet gar nicht, sie gilt weiter, bis
* ein neuer Preis eingetragen wird.
*
* Frueher stand das Ende als eigene Spalte in der Datenbank, und weil es
* eigenstaendig gepflegt wurde, konnte es dem naechsten Stichtag widersprechen:
* zwei Zeilen liefen ueber dasselbe Quartal, die Jahresstatistik zaehlte diese
* Tage doppelt. Ohne die Spalte laesst sich das nicht mehr hinschreiben.
*
* Gezeichnet wird aus dem Modell, nicht aus dem DOM: karte._modell ist die
* Wahrheit, jede Eingabe schreibt hinein und loest ein Neuzeichnen aus.
*/
// --- Datumskram ------------------------------------------------------------
/** Ein Datum "JJJJ-MM-TT" um $tage verschieben. */
function tagVersatz(iso, tage) {
const d = new Date(iso + "T00:00:00Z");
d.setUTCDate(d.getUTCDate() + tage);
return d.toISOString().slice(0, 10);
}
/** "2025-01-01" -> "01.01.2025" */
function datumLesbar(iso) {
if (!iso) return "–";
const t = iso.split("-");
return t[2] + "." + t[1] + "." + t[0];
}
function heuteISO() {
const d = new Date();
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
}
// --- Modell ---------------------------------------------------------------
/** Zeilen aufsteigend nach Stichtag. */
function sortiert(zeilen) {
return zeilen.slice().sort((a, b) => (a.ab < b.ab ? -1 : a.ab > b.ab ? 1 : 0));
}
/**
* Die einzige Luecke, die es noch geben kann: vor dem aeltesten Stichtag gibt
* es keinen Preis. Messwerte, die davor liegen, fallen aus der Jahresstatistik
* heraus, weil der JOIN fuer sie keine Zeile findet. Passieren kann das, wenn
* jemand den aeltesten Zeitraum loescht oder nach hinten schiebt - deshalb
* wird die Warnung bei jedem Neuzeichnen geprueft, nicht nur beim Laden.
*/
function zeitraumWarnung(zeilen) {
const liste = sortiert(zeilen);
if (!ersteMessung || !liste.length || liste[0].ab <= ersteMessung) return [];
return ["Die Messwerte beginnen am " + datumLesbar(ersteMessung) + ", der älteste Preis"
+ " erst am " + datumLesbar(liste[0].ab) + ". Alles davor fehlt in der Statistik."];
}
// --- Zeichnen -------------------------------------------------------------
function preisZeile(karte, zeile, i, anzahl) {
const daten = preisDaten[karte.dataset.reihe];
const felder = daten.felder;
// Die Spaltenbreiten kommen aus preisSpalten() im PHP - Kopfzeile und
// Felder muessen dieselben benutzen, sonst stehen die Ueberschriften schief.
const breit = daten.spalten;
const letzte = (i === anzahl - 1);
const heute = heuteISO();
// Die juengste Zeile hat kein Ende; alle anderen enden am Tag vor der
// naechsten.
const bis = letzte ? null : tagVersatz(karte._modell[i + 1].ab, -1);
const gilt = zeile.ab <= heute && (bis === null || heute <= bis);
let html = "<div class='row g-2 align-items-center preis-zeile" + (gilt ? " preis-heute" : "")
+ "' data-i='" + i + "'>";
html += "<div class='col-6 col-xl-" + breit.ab + "'>"
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>Gültig ab</label>"
+ "<input type='date' class='form-control form-control-sm' data-feld='ab' value='" + zeile.ab + "'>"
+ "</div>";
// Die Marke "gilt heute" steht beim Zeitraum, nicht beim Papierkorb: sie
// beschreibt ihn, und die Aktionsspalte bleibt schmal genug, dass die
// Felder den Platz bekommen.
html += "<div class='col-6 col-xl-" + breit.bis + "'>"
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>Gültig bis</label>"
+ "<div class='form-control form-control-sm border-0 bg-transparent px-0"
+ " d-flex align-items-center gap-2'>"
+ "<span class='text-secondary'>" + (bis === null ? "offen" : datumLesbar(bis)) + "</span>"
+ (gilt ? "<span class='badge text-bg-primary'>gilt heute</span>" : "")
+ "</div></div>";
Object.keys(felder).forEach(function (spalte, nr) {
const feld = felder[spalte];
// Schrittweite aus den Nachkommastellen: der Energieinhalt je Liter will
// vier Stellen, ein Preis in Cent nur zwei.
const schritt = Math.pow(10, -feld.stellen).toFixed(feld.stellen);
html += "<div class='col-6 col-xl-" + breit.felder[nr] + "'>"
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>" + feld.titel + "</label>"
+ "<div class='input-group input-group-sm'>"
+ "<input type='number' step='" + schritt + "' min='0' max='" + feld.max + "'"
+ " class='form-control text-end' data-feld='" + spalte + "'"
+ " value='" + zeile[spalte] + "'>"
+ "<span class='input-group-text'>" + feld.einheit + "</span>"
+ "</div></div>";
});
html += "<div class='col-12 col-xl-" + breit.aktion + " d-flex align-items-center justify-content-end'>";
html += "<button type='button' class='btn btn-sm btn-outline-danger preis-weg' title='Zeitraum löschen'"
+ (anzahl > 1 ? "" : " disabled") + "><i class='bi bi-trash'></i></button>"
+ "</div></div>";
return html;
}
function zeichnePreise(karte) {
karte._modell = sortiert(karte._modell);
const anzahl = karte._modell.length;
const warnung = zeitraumWarnung(karte._modell);
let html = "";
if (warnung.length) {
html += "<div class='alert alert-warning py-2 small'>"
+ "<i class='bi bi-exclamation-triangle me-1'></i>"
+ warnung.join(" ") + "</div>";
}
for (let i = 0; i < anzahl; i++) {
html += preisZeile(karte, karte._modell[i], i, anzahl);
}
karte.querySelector(".preis-zeilen").innerHTML = html;
}
// --- Bedienung ------------------------------------------------------------
function preisHinweis(karte, text, klasse) {
const feld = karte.querySelector(".preis-hinweis");
feld.className = "preis-hinweis small " + (klasse || "text-secondary");
feld.textContent = text;
}
function preisNeueZeile(karte) {
const modell = karte._modell;
const vorlage = modell[modell.length - 1];
// Der neue Stichtag ist heute; ist der schon vergeben, der naechste freie
// Tag. So entsteht nie eine Zeile, die das Speichern gleich ablehnt.
let ab = heuteISO();
while (modell.some(z => z.ab === ab)) {
ab = tagVersatz(ab, 1);
}
const zeile = { ab: ab };
for (const spalte in preisDaten[karte.dataset.reihe].felder) {
zeile[spalte] = vorlage[spalte]; // Preise uebernehmen, geaendert wird meist nur einer
}
modell.push(zeile);
zeichnePreise(karte);
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
const zeilen = karte.querySelectorAll(".preis-zeile");
const feld = zeilen[zeilen.length - 1].querySelector("[data-feld='ab']");
feld.focus();
}
async function preiseSenden(karte) {
const knopf = karte.querySelector(".preis-speichern");
knopf.disabled = true;
preisHinweis(karte, "Wird gespeichert …");
try {
const antwort = await fetch("ajax/settings.php?action=save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reihe: karte.dataset.reihe, zeilen: karte._modell })
});
const daten = await antwort.json();
if (!antwort.ok) {
throw new Error(daten.error || "Fehler " + antwort.status);
}
// Mit der Antwort des Servers weiterarbeiten, nicht mit dem eigenen
// Modell: sortiert und gerundet wird dort.
karte._modell = daten.zeilen;
zeichnePreise(karte);
preisHinweis(karte, "Gespeichert – " + daten.gespeichert + " Zeiträume.", "text-success");
} catch (fehler) {
preisHinweis(karte, fehler.message, "text-danger");
}
knopf.disabled = false;
}
async function preiseHolen(karte) {
preisHinweis(karte, "Wird geladen …");
try {
const antwort = await fetch("ajax/settings.php?action=list&reihe=" + encodeURIComponent(karte.dataset.reihe));
const daten = await antwort.json();
if (!antwort.ok) {
throw new Error(daten.error || "Fehler " + antwort.status);
}
karte._modell = daten.zeilen;
zeichnePreise(karte);
preisHinweis(karte, "");
} catch (fehler) {
preisHinweis(karte, fehler.message, "text-danger");
}
}
function preisKarteBinden(karte) {
karte.addEventListener("input", function (ereignis) {
const feld = ereignis.target.closest("[data-feld]");
if (!feld) return;
const zeile = karte._modell[parseInt(feld.closest(".preis-zeile").dataset.i, 10)];
if (feld.dataset.feld === "ab") {
if (!feld.value) return; // waehrend des Tippens ist das Feld kurz leer
zeile.ab = feld.value;
} else {
zeile[feld.dataset.feld] = isNaN(feld.valueAsNumber) ? 0 : feld.valueAsNumber;
}
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
});
// Ein geaenderter Stichtag verschiebt die Reihenfolge und damit die Enden
// aller Zeilen - aber erst, wenn das Feld fertig ist. Waehrend des Tippens
// neu zu zeichnen wuerde den Eingabefokus wegreissen.
karte.addEventListener("change", function (ereignis) {
if (ereignis.target.dataset.feld === "ab") {
zeichnePreise(karte);
}
});
karte.addEventListener("click", function (ereignis) {
if (ereignis.target.closest(".preis-neu")) {
preisNeueZeile(karte);
} else if (ereignis.target.closest(".preis-weg")) {
const i = parseInt(ereignis.target.closest(".preis-zeile").dataset.i, 10);
karte._modell.splice(i, 1);
zeichnePreise(karte);
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
} else if (ereignis.target.closest(".preis-zurueck")) {
preiseHolen(karte);
} else if (ereignis.target.closest(".preis-speichern")) {
preiseSenden(karte);
}
});
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".preis-karte").forEach(function (karte) {
const daten = preisDaten[karte.dataset.reihe];
if (!daten) return;
karte._modell = sortiert(daten.zeilen);
zeichnePreise(karte);
preisKarteBinden(karte);
});
});
File diff suppressed because it is too large Load Diff
-215
View File
@@ -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.
-848
View File
@@ -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())
-75
View File
@@ -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
-102
View File
@@ -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())
-626
View File
@@ -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")
+6 -2
View File
@@ -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 -2
View File
@@ -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;
+344
View File
@@ -0,0 +1,344 @@
<?php
/**
* Modell-Schicht fuer die Preistabellen der Einstellungsseite.
*
* Eine Preistabelle ist eine Zeitreihe: ab einem Stichtag gelten neue Preise,
* bis der naechste Eintrag beginnt. Mehr steht dort auch nicht - insbesondere
* kein Enddatum. Das gab es frueher als eigene Spalte, und weil es eigenstaendig
* gepflegt wurde, konnte es etwas anderes behaupten als der naechste Stichtag:
* in gridCosts liefen zwei Zeilen ueber das erste Quartal 2023, der JOIN in
* ajax/getStats.php fand fuer jeden Messwert zwei Preise, und der
* Stromverbrauch 2023 stand mit 2527 statt 1674 kWh in der Statistik.
*
* Ohne die Spalte kann das nicht mehr passieren: zwei Zeitraeume koennen sich
* nicht ueberlappen, weil der eine endet, wo der andere beginnt, und eine
* Luecke kann es auch nicht geben. Die juengste Zeile hat kein Ende und gilt
* weiter, bis ein neuer Preis eingetragen wird - so verhaelt sich ein Tarif ja
* auch. Wo die Statistik das Ende braucht, holt sie es sich mit
* LEAD(active_date) beim naechsten Stichtag.
*
* Was bleibt: vor dem aeltesten Stichtag gibt es keinen Preis. Messwerte, die
* davor liegen, fallen aus der Statistik heraus - dagegen hilft nur hinsehen,
* siehe preisLuecke().
*/
require_once(__DIR__ . "/mysql.php");
/** Verbindung zur Messwert-Datenbank. */
function solarDb()
{
static $db = null;
if ($db === null) {
$db = new mysqli($GLOBALS["mysql_server"], $GLOBALS["mysql_solarUser"],
$GLOBALS["mysql_solarPass"], $GLOBALS["mysql_solarDB"]);
$db->set_charset("utf8mb4");
}
return $db;
}
/**
* Die bearbeitbaren Preistabellen.
*
* Je Feld:
* faktor rechnet zwischen Speicher- und Anzeigeeinheit um. Gespeichert
* wird in Euro, angezeigt und eingegeben in Cent - so, wie es auf
* der Rechnung steht. 0,3163 EUR/kWh sind 31,63 ct/kWh; niemand
* tippt gern vier Nachkommastellen. Vorgabe 1, also unveraendert.
* stellen Nachkommastellen der Anzeige, zugleich die Schrittweite im Feld.
* max Obergrenze. Was darueber liegt, ist ein Tippfehler und keine
* Preiserhoehung.
*
* Nicht jedes Feld ist ein Preis. Der Energieinhalt des Gases stand frueher
* als 6.58 im SQL, die Benzinersparnis als Teiler 24.5 - beides gehoert neben
* den Preis, weil es am Brennstoff und am Fahrzeug haengt und nicht an der
* Physik. Beim Auto aber nicht als "kWh je Liter": diese Zahl steht auf keinem
* Datenblatt. Eingetragen werden die beiden Verbraeuche, die man kennt, und
* die Statistik rechnet daraus, wieviele Liter eine geladene kWh ersetzt.
*
* Kommt spaeter eine weitere Tabelle desselben Zuschnitts dazu, reicht hier
* ein Eintrag - Endpunkt, Maske und Speicherlogik bleiben unveraendert.
*/
function preisReihen()
{
return [
"strom" => [
"titel" => "Strompreise",
"hinweis" => "Arbeitspreis, Einspeisevergütung und Grundpreis, wie sie in die "
. "Jahresstatistik auf der Historienseite eingehen.",
"tabelle" => "gridCosts",
"symbol" => "bi-plug",
"felder" => [
"cost" => ["titel" => "Arbeitspreis", "einheit" => "ct/kWh",
"faktor" => 100, "stellen" => 2, "max" => 500],
"gain" => ["titel" => "Einspeisung", "einheit" => "ct/kWh",
"faktor" => 100, "stellen" => 2, "max" => 500],
"base" => ["titel" => "Grundpreis", "einheit" => "€/Jahr",
"faktor" => 1, "stellen" => 2, "max" => 5000],
],
],
"gas" => [
"titel" => "Gaspreise",
"hinweis" => "Womit die Ersparnis der Heizung gerechnet wird: was das Gas kostet, "
. "das der Heizstab bei Sonnenüberschuss ersetzt.",
"tabelle" => "gasCosts",
"symbol" => "bi-fire",
"felder" => [
"cost" => ["titel" => "Gaspreis", "einheit" => "ct/l",
"faktor" => 100, "stellen" => 2, "max" => 1000],
"kwhPerLitre" => ["titel" => "Energieinhalt", "einheit" => "kWh/l",
"faktor" => 1, "stellen" => 2, "max" => 20],
],
],
"sprit" => [
"titel" => "Benzinpreise",
"hinweis" => "Für „Benzin gespart“ und „Ersparnis Benzin“: der Preis an der Säule "
. "und was die beiden Autos verbrauchen — daraus ergibt sich, wieviele "
. "Liter eine geladene Kilowattstunde ersetzt.",
"tabelle" => "fuelCosts",
"symbol" => "bi-fuel-pump",
"felder" => [
"cost" => ["titel" => "Benzinpreis", "einheit" => "€/l",
"faktor" => 1, "stellen" => 3, "max" => 10],
"lPer100km" => ["titel" => "Verbrenner", "einheit" => "l/100 km",
"faktor" => 1, "stellen" => 1, "max" => 50],
"kwhPer100km" => ["titel" => "Elektro", "einheit" => "kWh/100 km",
"faktor" => 1, "stellen" => 1, "max" => 100],
],
],
];
}
/** Beschreibung einer Reihe, oder null bei unbekanntem Schluessel. */
function preisReihe($key)
{
$reihen = preisReihen();
return isset($reihen[$key]) ? $reihen[$key] : null;
}
/**
* Spaltenbreiten einer Zeile im Bootstrap-Raster (zusammen zwoelf).
*
* Damit Kopfzeile und Felder aus derselben Quelle kommen: das PHP zeichnet die
* Ueberschriften, das JavaScript die Zeilen, und beide holen sich die Breiten
* hier.
*
* Datum, Ende und Papierkorb nehmen sich so wenig wie moeglich - das Ende ist
* nur noch Text, der Papierkorb ein Knopf. Was uebrig bleibt, teilen sich die
* Felder. Geht die Teilung nicht auf, bekommt der Rest das Feld mit der
* laengsten Einheit: hinter "kWh/100 km" bleiben von einer Sechstelspalte
* keine 50 Pixel fuer die Zahl, hinter "EUR/l" ist reichlich Platz.
*
* Ab vier Feldern wird es eng; die Zeile bricht dann um, was haesslicher, aber
* nicht kaputt ist.
*/
function preisSpalten($felder)
{
$felder = array_values($felder);
$anzahl = max(1, count($felder));
$rand = $anzahl >= 3 ? ["ab" => 2, "bis" => 2, "aktion" => 1]
: ["ab" => 3, "bis" => 3, "aktion" => 1];
$rest = 12 - $rand["ab"] - $rand["bis"] - $rand["aktion"];
$breiten = array_fill(0, $anzahl, max(1, intdiv($rest, $anzahl)));
$uebrig = $rest - array_sum($breiten);
if ($uebrig > 0) {
$laengen = array_map(function ($f) { return mb_strlen($f["einheit"]); }, $felder);
$breiten[array_search(max($laengen), $laengen)] += $uebrig;
}
return $rand + ["felder" => $breiten];
}
/**
* Alle Zeitraeume einer Reihe, aeltester zuerst, Werte in Anzeigeeinheit.
*
* Geliefert wird nur der Stichtag; wo eine Zeile endet, ergibt sich aus der
* naechsten und rechnet die Maske selbst aus.
*/
function preiseLaden($key)
{
$reihe = preisReihe($key);
if (!$reihe) {
throw new InvalidArgumentException("Unbekannte Preistabelle.");
}
$spalten = array_keys($reihe["felder"]);
$res = solarDb()->query("SELECT active_date, " . implode(", ", $spalten)
. " FROM " . $reihe["tabelle"] . " ORDER BY active_date");
if (!$res) {
throw new RuntimeException(solarDb()->error);
}
$zeilen = [];
while ($row = $res->fetch_assoc()) {
$zeile = ["ab" => $row["active_date"]];
foreach ($spalten as $spalte) {
$feld = $reihe["felder"][$spalte];
$zeile[$spalte] = round(floatval($row[$spalte]) * $feld["faktor"], $feld["stellen"]);
}
$zeilen[] = $zeile;
}
return $zeilen;
}
/** Datum in der Form JJJJ-MM-TT, sonst null. */
function preisDatum($wert)
{
if (!is_string($wert) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert)) {
return null;
}
$datum = DateTimeImmutable::createFromFormat("!Y-m-d", $wert);
// createFromFormat schluckt den 31. Februar und macht den 3. Maerz daraus;
// der Rueckvergleich faengt das ab.
return ($datum && $datum->format("Y-m-d") === $wert) ? $datum : null;
}
/** Tag der aeltesten Messung in EnergyFlow, als "JJJJ-MM-TT". */
function ersteMessung()
{
static $tag = null;
if ($tag === null) {
$res = solarDb()->query("SELECT MIN(datetime) FROM EnergyFlow");
$zeile = $res ? $res->fetch_row() : null;
$tag = ($zeile && $zeile[0]) ? substr($zeile[0], 0, 10) : "";
}
return $tag;
}
/**
* Beginnt die Reihe zu spaet? Dann fehlt den aeltesten Messwerten der Preis.
*
* Das ist die einzige Luecke, die es noch geben kann - alle anderen hat das
* Weglassen der Spalte end_date erledigt. Auftreten kann sie, wenn jemand den
* aeltesten Zeitraum loescht.
*/
function preisLuecke($zeilen)
{
$erste = ersteMessung();
if (!$erste || !$zeilen) {
return "";
}
$stichtag = $zeilen[0]["ab"];
return $stichtag > $erste ? $erste : "";
}
/**
* Eine ganze Reihe schreiben: pruefen und ersetzen.
*
* Ersetzt statt abzugleichen. Die Tabellen haben eine Handvoll Zeilen und
* keinen kuenstlichen Schluessel - der Stichtag selbst ist der
* Primaerschluessel, und beim Verschieben eines Stichtags waere ein Abgleich
* mehr Buchhaltung als Gewinn. Alles laeuft in einer Transaktion, es gibt also
* keinen Moment, in dem die Statistik auf eine halbe Tabelle blickt.
*/
function preiseSpeichern($key, $zeilen)
{
$reihe = preisReihe($key);
if (!$reihe) {
throw new InvalidArgumentException("Unbekannte Preistabelle.");
}
if (!is_array($zeilen) || !$zeilen) {
throw new InvalidArgumentException("Es muss mindestens ein Zeitraum bleiben.");
}
$spalten = array_keys($reihe["felder"]);
// --- Pruefen --------------------------------------------------------
$geprueft = [];
foreach ($zeilen as $zeile) {
$ab = preisDatum($zeile["ab"] ?? "");
if (!$ab) {
throw new InvalidArgumentException("Ein Zeitraum hat kein gültiges Startdatum.");
}
$satz = ["ab" => $ab];
foreach ($spalten as $spalte) {
$feld = $reihe["felder"][$spalte];
$wert = $zeile[$spalte] ?? null;
if (!is_numeric($wert)) {
throw new InvalidArgumentException(
$feld["titel"] . " ab " . $ab->format("d.m.Y") . " ist keine Zahl.");
}
$wert = floatval($wert);
if ($wert < 0 || $wert > $feld["max"]) {
throw new InvalidArgumentException(
$feld["titel"] . " ab " . $ab->format("d.m.Y")
. " liegt außerhalb des Erwartbaren (0 bis " . $feld["max"] . " "
. $feld["einheit"] . ").");
}
$satz[$spalte] = $wert / $feld["faktor"];
}
$geprueft[] = $satz;
}
// Nach Stichtag sortieren; zwei gleiche Stichtage kann die Tabelle nicht
// halten (Primaerschluessel) und die Statistik nicht deuten.
usort($geprueft, function ($a, $b) { return $a["ab"] <=> $b["ab"]; });
for ($i = 1; $i < count($geprueft); $i++) {
if ($geprueft[$i]["ab"] == $geprueft[$i - 1]["ab"]) {
throw new InvalidArgumentException(
"Zwei Zeiträume beginnen am " . $geprueft[$i]["ab"]->format("d.m.Y") . ".");
}
}
// --- Schreiben ------------------------------------------------------
$db = solarDb();
$db->begin_transaction();
try {
if (!$db->query("DELETE FROM " . $reihe["tabelle"])) {
throw new RuntimeException($db->error);
}
$platzhalter = implode(", ", array_fill(0, count($spalten) + 1, "?"));
$stmt = $db->prepare("INSERT INTO " . $reihe["tabelle"]
. " (active_date, " . implode(", ", $spalten) . ")"
. " VALUES (" . $platzhalter . ")");
if (!$stmt) {
throw new RuntimeException($db->error);
}
foreach ($geprueft as $satz) {
$werte = [$satz["ab"]->format("Y-m-d")];
foreach ($spalten as $spalte) {
$werte[] = $satz[$spalte];
}
$stmt->bind_param("s" . str_repeat("d", count($spalten)), ...$werte);
if (!$stmt->execute()) {
throw new RuntimeException($stmt->error);
}
}
$db->commit();
} catch (Throwable $e) {
$db->rollback();
throw $e;
}
return count($geprueft);
}
/**
* Grundpreis Strom fuer ein Kalenderjahr, in Euro.
*
* Der Grundpreis ist der einzige Posten, der nicht an einem Messwert haengt -
* er faellt auch an, wenn nichts verbraucht wird. Deshalb steht er nicht im
* grossen SUM-Ausdruck der Statistik, sondern wird hier eigens gerechnet:
* jeder Zeitraum steuert seinen Anteil an den Tagen des Jahres bei. Das ist
* unabhaengig davon, wieviele Messwerte tatsaechlich aufgezeichnet wurden -
* 2022 hat merklich Luecken, und ein fehlender Nachmittag darf den Grundpreis
* nicht senken.
*
* LEAD() holt den naechsten Stichtag; einen Tag davor endet der Zeitraum. Die
* juengste Zeile hat keinen Nachfolger und laeuft bis zum Jahresende weiter.
*/
function grundpreisImJahr($jahr)
{
$jahr = intval($jahr);
$von = $jahr . "-01-01";
$bis = $jahr . "-12-31";
$tage = intval((new DateTimeImmutable($bis))->format("z")) + 1; // 365 oder 366
$stmt = solarDb()->prepare(
"WITH p AS (SELECT active_date, base,
LEAD(active_date) OVER (ORDER BY active_date) AS folgt
FROM gridCosts)
SELECT SUM(base * (DATEDIFF(LEAST(IFNULL(DATE_SUB(folgt, INTERVAL 1 DAY), ?), ?),
GREATEST(active_date, ?)) + 1) / ?)
FROM p
WHERE active_date <= ? AND (folgt IS NULL OR folgt > ?)");
$stmt->bind_param("sssiss", $bis, $bis, $von, $tage, $bis, $von);
$stmt->execute();
$wert = $stmt->get_result()->fetch_row()[0];
return $wert === null ? 0.0 : floatval($wert);
}
+5
View File
@@ -62,6 +62,11 @@
<?php if ($page["charts"]): ?>
<?php include __DIR__ . "/charts.html"; ?>
<?php endif; ?>
<?php if ($page["leaflet"]): ?>
<!-- Leaflet 1.9.4, BSD 2-Clause. Liegt wie alle anderen Bibliotheken lokal;
die Kacheln kommen ueber ajax/tile.php aus dem eigenen Zwischenspeicher. -->
<script src="js/leaflet.js"></script>
<?php endif; ?>
<script src="js/solar/common.js?v=<?= filemtime("js/solar/common.js") ?>"></script>
<?php foreach ($page["scripts"] as $script): ?>
<script src="<?= $script ?>?v=<?= filemtime($script) ?>"></script>
+20
View File
@@ -44,6 +44,12 @@ if (!isset($_GET["floor"]))
<link rel="stylesheet" href="./css/adminlte.min.css?v=2" />
<!--end::Required Plugin(AdminLTE)-->
<!--begin::Karte (Leaflet) - nur auf Seiten mit Karte-->
<?php if ($page["leaflet"]): ?>
<link rel="stylesheet" href="css/leaflet.css" />
<?php endif; ?>
<!--end::Karte-->
</head>
<!--end::Head-->
@@ -142,6 +148,12 @@ if (!isset($_GET["floor"]))
<p>Wetter</p>
</a>
</li>
<li class="nav-item">
<a href="?action=skoda" class="nav-link <?php if($_GET["action"]=="skoda"){echo " active";} ?>">
<i class="nav-icon bi bi-ev-front"></i>
<p>Škoda</p>
</a>
</li>
</ul>
</li>
<li class="nav-item menu-open">
@@ -170,6 +182,14 @@ if (!isset($_GET["floor"]))
</li>
</ul>
</li>
<!-- Einstellungen stehen fuer sich: kein Dashboard,
sondern die Parameter dahinter. -->
<li class="nav-item">
<a href="?action=settings" class="nav-link<?php if($_GET["action"]=="settings"){echo " active";} ?>">
<i class="nav-icon bi bi-gear"></i>
<p>Einstellungen</p>
</a>
</li>
</ul>
<!--end::Sidebar Menu-->
</nav>
+1 -1
View File
@@ -74,7 +74,7 @@ $statistics = [
</button>
</div>
</div>
<div class="row-cols-1 row-cols-md-1 g-4 p-2 pb-0" id="<?= $id ?>">
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="<?= $id ?>">
</div>
</div>
<!-- /.card -->
+109
View File
@@ -0,0 +1,109 @@
<?php
/**
* Einstellungsseite: Parameter, die von Hand gepflegt werden.
*
* Bisher sind das die Preistabellen aus restricted/costs.php. Die Seite ist
* als Sammlung von Karten angelegt - eine Karte je Thema. Kommt spaeter ein
* weiterer Parameter dazu, kommt er als eigene Karte hinzu, ohne dass die
* vorhandenen etwas davon merken.
*
* Die Zeilen zeichnet js/solar/settings.js aus dem Modell, das hier
* eingebettet wird - dieselbe Arbeitsteilung wie im Automatik-Editor. Sonst
* gaebe es die erste Zeile als PHP und jede weitere als JavaScript, und die
* beiden Fassungen liefen frueher oder spaeter auseinander. Aus demselben
* Grund kommen die Spaltenbreiten aus preisSpalten(): die Kopfzeile hier und
* die Felder dort muessen dieselben sein.
*/
require_once(__DIR__ . "/costs.php");
$reihen = preisReihen();
$daten = [];
$fehler = "";
try {
foreach ($reihen as $key => $reihe) {
$daten[$key] = [
"felder" => $reihe["felder"],
"spalten" => preisSpalten($reihe["felder"]),
"zeilen" => preiseLaden($key),
];
}
} catch (Throwable $e) {
$fehler = $e->getMessage();
}
?>
<!--begin::App Main-->
<main class="app-main">
<!--begin::App Content Header-->
<div class="app-content-header">
<div class="container-fluid">
<div class="row">
<div class="col-sm-12">
<h3 class="mb-0">Einstellungen</h3>
</div>
</div>
</div>
</div>
<!--end::App Content Header-->
<!--begin::App Content-->
<div class="app-content">
<div class="container-fluid">
<?php if ($fehler): ?>
<div class="alert alert-danger">Die Einstellungen sind nicht erreichbar: <?= htmlspecialchars($fehler) ?></div>
<?php endif; ?>
<?php foreach ($reihen as $key => $reihe): ?>
<?php $breiten = preisSpalten($reihe["felder"]); ?>
<div class="row">
<div class="col-12 col-xxl-9">
<div class="card mb-4 preis-karte" data-reihe="<?= htmlspecialchars($key) ?>">
<div class="card-header pb-2 pt-2">
<h3 class="card-title"><i class="bi <?= $reihe["symbol"] ?> me-2"></i><?= htmlspecialchars($reihe["titel"]) ?></h3>
<div class="card-tools">
<button type="button" class="btn btn-tool preis-neu" title="Zeitraum hinzufügen">
<i class="bi bi-plus-lg"></i>
</button>
<button type="button" class="btn btn-tool preis-zurueck" title="Änderungen verwerfen">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</div>
</div>
<div class="card-body pb-2">
<p class="text-secondary small mb-3"><?= htmlspecialchars($reihe["hinweis"]) ?></p>
<!-- Spaltenköpfe nur auf breiten Schirmen; auf dem Handy trägt
jedes Feld seine eigene Beschriftung. -->
<div class="row g-2 d-none d-xl-flex preis-kopf text-secondary small fw-semibold">
<div class="col-xl-<?= $breiten["ab"] ?>">Gültig ab</div>
<div class="col-xl-<?= $breiten["bis"] ?>">Gültig bis</div>
<?php foreach (array_values($reihe["felder"]) as $i => $feld): ?>
<div class="col-xl-<?= $breiten["felder"][$i] ?>"><?= htmlspecialchars($feld["titel"]) ?></div>
<?php endforeach; ?>
<div class="col-xl-<?= $breiten["aktion"] ?>"></div>
</div>
<div class="preis-zeilen"></div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-3 preis-neu">
<i class="bi bi-plus-lg me-1"></i>Zeitraum hinzufügen
</button>
</div>
<div class="card-footer d-flex align-items-center gap-3">
<button type="button" class="btn btn-primary preis-speichern">Speichern</button>
<span class="preis-hinweis small"></span>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<!--end::App Content-->
</main>
<!--end::App Main-->
<script>
// Ausgangsstand der Preistabellen. Werte in der Anzeigeeinheit (ct statt
// Euro); umgerechnet wird in restricted/costs.php - an genau einer Stelle.
const preisDaten = <?= json_encode($daten, JSON_UNESCAPED_UNICODE) ?>;
// Aelteste Messung in EnergyFlow. Beginnt eine Preisreihe spaeter, fehlt
// den Werten davor der Preis - dann warnt die Maske.
const ersteMessung = <?= json_encode(ersteMessung()) ?>;
</script>
+383
View File
@@ -0,0 +1,383 @@
<?php
/*
* Škoda-Dashboard.
*
* Zwei Datenwege, bewusst getrennt:
*
* MQTT Ladestand, Verriegelung, Reichweite, Ladezustand und
* Wallbox-Leistung kommen laufend über solarManager/# und
* aktualisieren die Anzeige sofort.
*
* ajax/skoda.php Alles, was nur in der Tabelle steht - Türen, Fenster,
* Licht, Kilometerstand, Position, Klima - sowie sämtliche
* Auswertungen. Wird beim Laden und danach minütlich geholt.
*
* Solange kein Fahrzeug angebunden ist, bleibt die Tabelle leer; jeder
* Abschnitt zeigt dann seinen Leerzustand statt eines Fehlers.
*
* Die Fahrzeuggrafik ist eine maßstäbliche Draufsicht des Enyaq:
* 4648 x 1877 mm, Radstand 2765 mm, daraus das Seitenverhältnis 2,48:1 und
* die Lage der Achsen. Kein Foto, sondern ein Schema - die Teile sollen
* ihren Zustand zeigen, nicht das Auto porträtieren.
*/
?>
<!--begin::App Main-->
<main class="app-main">
<div class="app-content-header">
<div class="container-fluid">
<div class="row align-items-center">
<div class="col-sm-8">
<h3 class="mb-0">Škoda</h3>
</div>
<div class="col-sm-4 text-sm-end">
<span class="skoda-alter text-secondary" id="skodaAlter">&nbsp;</span>
</div>
</div>
</div>
</div>
<div class="app-content">
<div class="container-fluid">
<!-- ================================================ Zustand ===== -->
<div class="row">
<!-- Das Fahrzeug als Anzeigeelement. Jedes Teil trägt seinen Zustand
selbst: offene Türen und Klappen leuchten bernstein, Licht gelb,
der Ladeanschluss pulst beim Laden. Die Zeile darunter benennt
dasselbe in Worten und hebt beim Überfahren das zugehörige Teil
hervor - Farbe allein soll die Aussage nicht tragen. -->
<div class="col-xxl-4 col-lg-5 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Fahrzeug</h3>
<div class="card-tools">
<span class="badge text-bg-secondary" id="skodaSchloss">–</span>
</div>
</div>
<div class="card-body d-flex flex-column align-items-center pt-2">
<svg id="skodaAuto" class="skoda-auto" viewBox="0 0 224 500"
role="img" aria-label="Draufsicht des Fahrzeugs mit dem Zustand von Türen, Fenstern, Klappen und Licht">
<!-- Räder. Vorderachse bei 860 mm, Hinterachse bei 3625 mm
ab Bugspitze - der Radstand von 2765 mm maßstäblich. -->
<g class="rad">
<rect x="5" y="68" width="21" height="58" rx="9"/>
<rect x="198" y="68" width="21" height="58" rx="9"/>
<rect x="5" y="362" width="21" height="58" rx="9"/>
<rect x="198" y="362" width="21" height="58" rx="9"/>
</g>
<!-- Karosserie -->
<path class="karosserie" d="M112 6
C150 6, 198 16, 204 50 C209 90, 212 160, 212 250
C212 340, 209 410, 204 450 C198 484, 150 494, 112 494
C74 494, 26 484, 20 450 C15 410, 12 340, 12 250
C12 160, 15 90, 20 50 C26 16, 74 6, 112 6 Z"/>
<!-- Tech-Deck Face: die glänzend schwarze Blende über die
volle Bugbreite, die den Enyaq seit der Überarbeitung
kennzeichnet. Von oben sieht man ihre Oberkante. -->
<path class="techdeck" d="M112 7
C150 7, 197 17, 203 49 L194 53 C187 27, 148 18, 112 18
C76 18, 37 27, 30 53 L21 49 C27 17, 74 7, 112 7 Z"/>
<!-- Scheinwerfer, vier Elemente je Seite wie beim Original,
dazu die Rückleuchten als durchgehendes Band. -->
<g id="teil-lights" class="teil teil-licht" data-teil="lights">
<rect x="36" y="30" width="9" height="13" rx="4"/>
<rect x="48" y="27" width="9" height="13" rx="4"/>
<rect x="60" y="25" width="9" height="13" rx="4"/>
<rect x="72" y="24" width="9" height="13" rx="4"/>
<rect x="143" y="24" width="9" height="13" rx="4"/>
<rect x="155" y="25" width="9" height="13" rx="4"/>
<rect x="167" y="27" width="9" height="13" rx="4"/>
<rect x="179" y="30" width="9" height="13" rx="4"/>
<rect class="rueck" x="40" y="462" width="52" height="13" rx="6"/>
<rect class="rueck" x="132" y="462" width="52" height="13" rx="6"/>
<title>Beleuchtung</title>
</g>
<!-- Motorhaube mit angedeuteter Mittelsicke -->
<g id="teil-bonnet" class="teil" tabindex="0" data-teil="bonnet">
<path d="M31 152 L193 152 L189 62 C169 40, 55 40, 35 62 Z"/>
<title>Motorhaube</title>
</g>
<path class="sicke" d="M112 60 L112 150"/>
<!-- Windschutzscheibe, Dach, Heckscheibe -->
<path class="scheibe" d="M34 156 L190 156 L178 200 L46 200 Z"/>
<rect class="dach" x="46" y="200" width="132" height="136" rx="10"/>
<!-- Panoramaglasdach: beim Enyaq nahezu die ganze Dachfläche -->
<g id="teil-sunroof" class="teil" tabindex="0" data-teil="sunroof">
<rect x="62" y="206" width="100" height="120" rx="8"/>
<title>Panoramadach</title>
</g>
<!-- Dachreling -->
<g class="reling">
<rect x="48" y="204" width="9" height="134" rx="4"/>
<rect x="167" y="204" width="9" height="134" rx="4"/>
</g>
<path class="scheibe" d="M46 336 L178 336 L190 382 L34 382 Z"/>
<rect class="spoiler" x="52" y="330" width="120" height="9" rx="4"/>
<!-- Heckklappe -->
<g id="teil-trunk" class="teil" tabindex="0" data-teil="trunk">
<path d="M34 384 L190 384 L186 458 C168 478, 56 478, 38 458 Z"/>
<title>Heckklappe</title>
</g>
<!-- Türen, jeweils mit Seitenfenster darin -->
<g id="teil-doors" class="teil" data-teil="doors">
<g tabindex="0"><rect x="12" y="202" width="23" height="74" rx="6"/><title>Tür vorn links</title></g>
<g tabindex="0"><rect x="12" y="278" width="23" height="66" rx="6"/><title>Tür hinten links</title></g>
<g tabindex="0"><rect x="189" y="202" width="23" height="74" rx="6"/><title>Tür vorn rechts</title></g>
<g tabindex="0"><rect x="189" y="278" width="23" height="66" rx="6"/><title>Tür hinten rechts</title></g>
</g>
<g id="teil-windows" class="teil teil-fenster" data-teil="windows">
<rect x="17" y="210" width="13" height="58" rx="4"/>
<rect x="17" y="286" width="13" height="50" rx="4"/>
<rect x="194" y="210" width="13" height="58" rx="4"/>
<rect x="194" y="286" width="13" height="50" rx="4"/>
<title>Seitenfenster</title>
</g>
<!-- Außenspiegel -->
<g class="spiegel">
<rect x="1" y="194" width="16" height="10" rx="5"/>
<rect x="207" y="194" width="16" height="10" rx="5"/>
</g>
<!-- ============================================================
Steuerung: die beiden anklickbaren Stellen.
Bedient wird das, worum es geht - der Ladeanschluss für
das Laden, der Innenraum für Klima und Lüftung. Beide
tragen einen Ring als Hinweis darauf, dass sie mehr sind
als Anzeige, und öffnen ein Modal mit den Befehlen.
Sie stehen bewusst NICHT in der Zustandszeile darunter:
die zeigt, was ist. Diese beiden ändern etwas.
============================================================ -->
<!-- Ladeanschluss. Beim Enyaq sitzt die Klappe am hinteren
rechten Kotflügel; sie pulst, solange geladen wird. -->
<g id="teil-plug" class="teil teil-stecker steuer" tabindex="0"
role="button" data-steuer="laden" aria-label="Laden steuern">
<circle class="ring" cx="203" cy="408" r="17"/>
<circle cx="203" cy="408" r="11"/>
<path d="M198 402 l6 5 -4 0 4 6"/>
<title>Laden steuern</title>
</g>
<!-- Innenraum, auf der Windschutzscheibe: Klima und Lüftung. -->
<g id="steuer-klima" class="steuer" tabindex="0"
role="button" data-steuer="klima" aria-label="Klima und Lüftung steuern">
<circle class="ring" cx="112" cy="178" r="19"/>
<circle class="flaeche" cx="112" cy="178" r="13"/>
<path class="glyphe" d="M112 170 v10 M108 174 h8 M105 185 q7 -4 14 0"/>
<title>Klima und Lüftung steuern</title>
</g>
</svg>
<div class="skoda-teile" id="skodaTeile"></div>
<p class="skoda-hinweis">
Ladeanschluss und Innenraum antippen, um zu steuern
</p>
</div>
</div>
</div>
<!-- Batterie, Reichweite, Ladung -->
<div class="col-xxl-8 col-lg-7 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Batterie und Ladung</h3>
<div class="card-tools">
<span class="badge text-bg-secondary" id="skodaLadeZustand">–</span>
</div>
</div>
<div class="card-body">
<div class="row align-items-center g-3">
<!-- Ladestandsring. Der Strich auf dem Ring ist das
eingestellte Ladeziel - eine Ladung, die dort endet, ist
kein Fehler, sondern Absicht. -->
<div class="col-md-5 text-center">
<svg class="skoda-ring" viewBox="0 0 200 200" role="img" aria-labelledby="skodaSocText">
<circle class="ring-bahn" cx="100" cy="100" r="82"/>
<circle class="ring-wert" id="skodaRing" cx="100" cy="100" r="82"/>
<line class="ring-ziel" id="skodaZiel" x1="100" y1="8" x2="100" y2="26"/>
<text id="skodaSocText" class="ring-zahl" x="100" y="98">–</text>
<text class="ring-einheit" x="100" y="126">Prozent</text>
</svg>
</div>
<div class="col-md-7">
<div class="skoda-werte">
<div class="skoda-wert"><span class="k">Reichweite</span><span class="v" id="skodaReichweite">–</span></div>
<div class="skoda-wert"><span class="k">Ladeleistung</span><span class="v" id="skodaLeistung">–</span></div>
<div class="skoda-wert"><span class="k">Restzeit</span><span class="v" id="skodaRestzeit">–</span></div>
<div class="skoda-wert"><span class="k">Ladeziel</span><span class="v" id="skodaZielText">–</span></div>
<div class="skoda-wert"><span class="k">Kilometerstand</span><span class="v" id="skodaKm">–</span></div>
<div class="skoda-wert"><span class="k">Klima</span><span class="v" id="skodaKlima">–</span></div>
</div>
<!-- Woher der Strom gerade kommt: der Balken teilt die
Ladeleistung in den Anteil, den die PV deckt, und den
Rest aus dem Netz. -->
<div class="skoda-quelle mt-3" id="skodaQuelleBox">
<div class="d-flex justify-content-between small text-secondary mb-1">
<span>Ladestrom</span><span id="skodaQuelleText">–</span>
</div>
<div class="skoda-balken">
<div class="teil-pv" id="skodaAnteilPV"></div>
<div class="teil-netz" id="skodaAnteilNetz"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ================================================= Ladungen ==== -->
<div class="row">
<div class="col-xl-8 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Verlauf der letzten Ladung</h3>
<div class="card-tools">
<span class="text-secondary small" id="skodaKurveZeit"></span>
</div>
</div>
<div class="card-body pb-1">
<div class="chart-container"><canvas id="skodaKurve"></canvas></div>
</div>
</div>
</div>
<div class="col-xl-4 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Ladungen</h3>
</div>
<div class="card-body p-0">
<div class="skoda-liste" id="skodaLadungen"></div>
</div>
</div>
</div>
</div>
<!-- ============================================== Akkugesundheit == -->
<div class="row">
<div class="col-xl-8 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Akkugesundheit</h3>
<div class="card-tools">
<span class="text-secondary small">kWh je 100 Ladestandsprozent</span>
</div>
</div>
<div class="card-body pb-1">
<div class="chart-container"><canvas id="skodaGesundheit"></canvas></div>
</div>
</div>
</div>
<div class="col-xl-4 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Befund</h3>
</div>
<div class="card-body">
<div class="skoda-befund" id="skodaBefund">
<p class="text-secondary mb-0">Noch keine ausgewerteten Ladungen.</p>
</div>
<p class="text-secondary small mb-0 mt-3">
Gemessen wird am Wallbox-Zähler, die Ladeverluste sind also
enthalten. Absolut ist der Wert eine Näherung — aussagekräftig
ist sein Verlauf, denn ein alternder Akku bräuchte <em>weniger</em>
Kilowattstunden für dieselben hundert Prozentpunkte.
</p>
</div>
</div>
</div>
</div>
<!-- ============================================ Standort ========== -->
<!-- Karte auf Leaflet. Die Kacheln kommen nicht unmittelbar von
OpenStreetMap, sondern über ajax/tile.php aus dem eigenen
Zwischenspeicher auf dem NAS - siehe dort, warum.
Die Fahrtlinien sind gestrichelt, und zwar mit Absicht: die API
liefert nur die letzte bekannte PARKposition, also Start und Ziel.
Die tatsächlich gefahrene Strecke kennt niemand, und eine
durchgezogene Linie auf einer Straßenkarte würde genau das
behaupten. -->
<div class="row">
<div class="col-xl-7 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Standort und Fahrten</h3>
<div class="card-tools">
<button type="button" id="skodaKarteHeim" class="btn btn-tool"
title="Auf den aktuellen Standort zentrieren">
<i class="bi bi-geo-alt"></i>
</button>
<a href="#" id="skodaOsmLink" class="btn btn-tool" target="_blank"
rel="noopener noreferrer" title="Auf OpenStreetMap öffnen">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
</div>
<div class="card-body p-2">
<div id="skodaKarte" class="skoda-karte"></div>
<div class="d-flex justify-content-between align-items-center px-2 pt-2 pb-1">
<span class="text-secondary small" id="skodaStandort">–</span>
<span class="skoda-kartenlegende">
<span><i class="pkt jetzt"></i>jetzt</span>
<span><i class="pkt halt"></i>Halt</span>
<span><i class="lin"></i>Luftlinie</span>
</span>
</div>
</div>
</div>
</div>
<div class="col-xl-5 d-flex align-items-stretch">
<!-- Die Klasse zeigt-kwh / zeigt-prozent auf dieser Kachel
entscheidet, welche Einheit die Zeilen anzeigen. Beide Werte
stehen im Markup, umgeschaltet wird allein per CSS - so muss
beim Wechsel nichts neu gebaut werden, und die Auswahl
übersteht auch die minütliche Auffrischung der Liste. -->
<div class="card mb-4 p-0 w-100 zeigt-kwh" id="skodaFahrtenKachel">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Fahrten</h3>
<div class="card-tools d-flex align-items-center gap-2">
<!-- Text kommt aus zeigeVerbrauchsbasis(): der Verbrauch in
Kilowattstunden ist nicht gemessen, sondern aus dem
Ladestand und der eigenen Ladehistorie gerechnet. Womit
genau, steht im Titel dieses Elements. -->
<span class="text-secondary small" id="skodaVerbrauchBasis"></span>
<span class="skoda-umschalter" id="skodaEinheit" role="group"
aria-label="Einheit des Verbrauchs">
<button type="button" data-einheit="kwh" aria-pressed="true">kWh</button>
<button type="button" data-einheit="prozent" aria-pressed="false">%</button>
</span>
</div>
</div>
<div class="card-body p-0">
<div class="skoda-liste" id="skodaFahrten"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!--end::App Main-->
+1 -1
View File
@@ -74,7 +74,7 @@
</button>
</div>
</div>
<div class="row-cols-1 row-cols-md-1 g-4 p-2 pb-0" id="Stats-Year">
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="Stats-Year">
</div>
</div>
+63
View File
@@ -0,0 +1,63 @@
-- Preistabellen der Statistik: feste Zahlen rein, Redundanz raus.
--
-- 1) gasCosts und fuelCosts lagen unbenutzt herum; ajax/getStats.php rechnete
-- stattdessen mit drei fest eingetragenen Zahlen:
--
-- 140 Grundpreis Strom pro Jahr -> gridCosts.base
-- 0.063 Gaspreis je kWh -> gasCosts.cost / gasCosts.kwhPerLitre
-- 24.5 12 * kWh je Liter Benzin -> fuelCosts.lPer100km / kwhPer100km
--
-- Beim Gas stand der Energieinhalt bisher nur als Kommentar an der Spalte
-- ("6.58kWh/l"), beim Benzin steckte er unsichtbar im Teiler 24.5. Beides
-- ist keine Physik, die sich nie aendert, sondern haengt am Brennstoff und
-- am Ersatzfahrzeug - und gehoert deshalb neben den Preis.
--
-- Beim Auto nicht als "kWh je Liter": diese Zahl steht auf keinem Datenblatt
-- und niemand hat sie im Kopf. Eingetragen werden die beiden Verbraeuche,
-- die man kennt - Liter je 100 km beim Verbrenner, kWh je 100 km beim
-- Stromer. Wieviele Liter eine geladene kWh ersetzt, rechnet die Abfrage
-- daraus aus.
--
-- 2) end_date faellt weg. Die Spalte war reine Redundanz: sie sagte nie etwas
-- anderes als "Tag vor dem naechsten Stichtag" - konnte aber, weil sie
-- eigenstaendig gepflegt wurde, etwas anderes sagen. Genau das war
-- passiert: gridCosts liess zwei Zeilen ueber das erste Quartal 2023
-- laufen, der JOIN in getStats.php fand fuer jeden Messwert zwei Preise,
-- und der Stromverbrauch 2023 stand mit 2527 statt 1674 kWh in der
-- Statistik - 51 % zu hoch.
--
-- Ohne die Spalte laesst sich eine Ueberlappung nicht mehr hinschreiben,
-- und eine Luecke auch nicht. Das Ende einer Zeile holt sich die Abfrage
-- mit LEAD(active_date) beim naechsten Stichtag; die juengste Zeile hat
-- kein Ende und gilt weiter, bis ein neuer Preis eingetragen wird. So
-- verhaelt sich ein Tarif ja auch.
--
-- Nebenbei ist die Statistik dadurch rund viermal so schnell (2,9 s -> 0,7 s
-- je Jahr): der Optimierer schlaegt die drei kleinen Preistabellen einmal
-- auf, statt fuer jeden der 100.000 Messwerte einen Bereich zu durchsuchen.
--
-- Anwenden: mysql -h <host> -P 3310 -u solarLog -p solarLog < solarLog_costs.sql
ALTER TABLE `gridCosts`
ADD COLUMN `base` float NOT NULL DEFAULT 0
COMMENT 'Grundpreis pro Jahr in Euro' AFTER `gain`,
DROP KEY `end_date`,
DROP COLUMN `end_date`;
-- Bis hierher stand die 140 fest im SQL - fuer alle Jahre gleich.
UPDATE `gridCosts` SET `base` = 140;
-- Die Zeile ab 2022-12-13 lief bis 2023-03-31 und ueberlappte damit die
-- naechste. Ohne end_date endet sie automatisch am 31.12.2022.
ALTER TABLE `gasCosts`
ADD COLUMN `kwhPerLitre` float NOT NULL DEFAULT 6.58
COMMENT 'Energieinhalt: kWh je Liter';
ALTER TABLE `fuelCosts`
ADD COLUMN `lPer100km` float NOT NULL DEFAULT 9.8
COMMENT 'Verbrauch des Verbrenners in Liter je 100 km',
ADD COLUMN `kwhPer100km` float NOT NULL DEFAULT 20
COMMENT 'Verbrauch des Elektroautos in kWh je 100 km',
DROP KEY `end_date`,
DROP COLUMN `end_date`;