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