Skoda: Schluessel in den Einstellungen, Ladungen am Stueck, Wallbox in kW
- neuer Reiter "Fahrzeug": API-Schluessel eintragen und pruefen, ohne dass ein Wert je an den Browser zurueckgeht (restricted/skodaKeys.php) - ajax/skoda.php trennt Ladungen am Zustand statt an 30 Minuten Pause und rahmt sie mit der Zeile davor und danach ein - solarLog_skoda_ladepunkte.sql: Wallbox-Verlauf waehrend einer Ladung - Kachelformat "leistung_kw" fuer Quellen, die schon Kilowatt melden - Steckersymbol versteht evPlug als 1/0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,9 @@
|
|||||||
* GET ?action=geraete Geraete mit Raum, Verwendung und Alter
|
* GET ?action=geraete Geraete mit Raum, Verwendung und Alter
|
||||||
* POST ?action=geraete-raeume {"rooms": {"12": "OG/Bad", ...}}
|
* POST ?action=geraete-raeume {"rooms": {"12": "OG/Bad", ...}}
|
||||||
* POST ?action=geraet-loeschen {"id": 12}
|
* POST ?action=geraet-loeschen {"id": 12}
|
||||||
|
* GET ?action=skoda-keys Stand der Fahrzeug-Schluessel (ohne Werte!)
|
||||||
|
* POST ?action=skoda-keys-save {"felder": {"API_KEY2": "..."},
|
||||||
|
* "loeschen": ["API_KEY3"]}
|
||||||
*
|
*
|
||||||
* Ein reiner Verteiler: geprueft und geschrieben wird in
|
* Ein reiner Verteiler: geprueft und geschrieben wird in
|
||||||
* restricted/costs.php bzw. restricted/kacheln.php, damit Maske und Endpunkt
|
* restricted/costs.php bzw. restricted/kacheln.php, damit Maske und Endpunkt
|
||||||
@@ -20,6 +23,7 @@ require_once("../helper.php");
|
|||||||
require_once("../restricted/costs.php");
|
require_once("../restricted/costs.php");
|
||||||
require_once("../restricted/kacheln.php");
|
require_once("../restricted/kacheln.php");
|
||||||
require_once("../restricted/automations.php");
|
require_once("../restricted/automations.php");
|
||||||
|
require_once("../restricted/skodaKeys.php");
|
||||||
|
|
||||||
if (!checkLogin()) {
|
if (!checkLogin()) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
@@ -86,11 +90,23 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
|||||||
$name = geraetLoeschen($body["id"] ?? 0);
|
$name = geraetLoeschen($body["id"] ?? 0);
|
||||||
jsonAntwort(["geloescht" => $name, "geraete" => geraeteListe()]);
|
jsonAntwort(["geloescht" => $name, "geraete" => geraeteListe()]);
|
||||||
|
|
||||||
|
case "skoda-keys-save":
|
||||||
|
/*
|
||||||
|
* Neue Schluessel eintragen. Zurueck kommt der neue Stand -
|
||||||
|
* aber nie ein Wert, siehe restricted/skodaKeys.php.
|
||||||
|
*/
|
||||||
|
jsonAntwort(skodaSchluesselSpeichern(
|
||||||
|
$body["felder"] ?? [], $body["loeschen"] ?? []));
|
||||||
|
|
||||||
default:
|
default:
|
||||||
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||||
}
|
}
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
jsonAntwort(["error" => $e->getMessage()], 400);
|
jsonAntwort(["error" => $e->getMessage()], 400);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
// Nicht die Eingabe, sondern die Umgebung - Datei fehlt, Rechte
|
||||||
|
// fehlen. Die Meldung sagt schon, was zu tun ist; kein Vorspann.
|
||||||
|
jsonAntwort(["error" => $e->getMessage()], 500);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
|
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
|
||||||
}
|
}
|
||||||
@@ -114,6 +130,14 @@ if ($action === "kacheln") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($action === "skoda-keys") {
|
||||||
|
try {
|
||||||
|
jsonAntwort(skodaStand());
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
jsonAntwort(["error" => "Schlüssel nicht abrufbar: " . $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($action === "geraete") {
|
if ($action === "geraete") {
|
||||||
try {
|
try {
|
||||||
jsonAntwort(["geraete" => geraeteListe(), "raeume" => raumListe()]);
|
jsonAntwort(["geraete" => geraeteListe(), "raeume" => raumListe()]);
|
||||||
|
|||||||
+232
-35
@@ -42,54 +42,100 @@ if ($mysql->connect_errno) {
|
|||||||
$mysql->set_charset("utf8mb4");
|
$mysql->set_charset("utf8mb4");
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Ladevorgänge aus den Einzelzeilen schneiden. Aufeinanderfolgende Zeilen mit
|
* Ladevorgänge aus den Einzelzeilen schneiden. Wird von mehreren Abschnitten
|
||||||
* chg_state = 'CHARGING' gehören zusammen, eine Pause über 30 Minuten trennt
|
* gebraucht, deshalb einmal hier.
|
||||||
* zwei Vorgänge. Wird von mehreren Abschnitten gebraucht, deshalb einmal hier.
|
*
|
||||||
|
* Eine Ladung ist eine ununterbrochene Folge von Zeilen mit chg_state =
|
||||||
|
* 'CHARGING'. Getrennt wird durch eine Zeile mit einem anderen Zustand, nicht
|
||||||
|
* durch die Zeit dazwischen: gatherSkodaData.py schreibt nur, wenn das
|
||||||
|
* Fahrzeug etwas Neues meldet, und beim Laden an der Wallbox tut es das
|
||||||
|
* mitunter nur einmal in der Stunde. Früher trennte eine Pause über 30
|
||||||
|
* Minuten - eine Ladung von 51 auf 80 % am 13.09.2026 zerfiel dadurch in drei
|
||||||
|
* Stücke mit 0, 4 und 0 Prozentpunkten. Hört das Fahrzeug auf zu laden,
|
||||||
|
* steht das in der nächsten Zeile; die Zeit allein trennt nur noch, wenn sie
|
||||||
|
* so lang ist, dass der Abruf offenbar nicht lief (LADUNG_LUECKE_S).
|
||||||
|
*
|
||||||
|
* Dazu gehören die beiden Zeilen, die die Ladung einrahmen. Die davor sagt,
|
||||||
|
* mit welchem Ladestand und Zählerstand sie begann - die erste CHARGING-Zeile
|
||||||
|
* kommt oft erst Minuten später und hat dann schon geladen. Die danach
|
||||||
|
* (CONNECT_CABLE, READY_FOR_CHARGING, ...) trägt den erreichten Ladestand:
|
||||||
|
* bei einer Ladung bis zum Ziel ist das die einzige Zeile mit 80 %.
|
||||||
*
|
*
|
||||||
* Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer
|
* Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer
|
||||||
* Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
|
* Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
|
||||||
* Steht der Zähler still, wurde auswärts geladen (Schnellladesäule); dann
|
* Ein Zählerstand 0 ist kein Stand, sondern ein ausgefallener Abruf der
|
||||||
* bleibt wh null und die Kapazitätsrechnung lässt die Ladung aus.
|
* Wallbox, und bleibt außen vor. Steht der Zähler still, wurde auswärts
|
||||||
|
* geladen (Schnellladesäule); dann bleibt wh null und die Kapazitätsrechnung
|
||||||
|
* lässt die Ladung aus.
|
||||||
*/
|
*/
|
||||||
|
const LADUNG_LUECKE_S = 3 * 3600;
|
||||||
|
|
||||||
function sitzungenSQL($seit)
|
function sitzungenSQL($seit)
|
||||||
{
|
{
|
||||||
|
$luecke = LADUNG_LUECKE_S;
|
||||||
return "
|
return "
|
||||||
SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
|
SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
|
||||||
MIN(soc) AS soc_von, MAX(soc) AS soc_bis,
|
MIN(soc_start) AS soc_von, MAX(soc) AS soc_bis,
|
||||||
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc) AS SIGNED) AS zuwachs,
|
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc_start) AS SIGNED) AS zuwachs,
|
||||||
MAX(chg_type) AS art, MAX(target_soc) AS ziel,
|
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,
|
ROUND(AVG(CASE WHEN laedt THEN chg_kw END), 2) AS kw_mittel,
|
||||||
MAX(wb_wh_total) - MIN(wb_wh_total) AS wh,
|
ROUND(MAX(CASE WHEN laedt THEN chg_kw END), 2) AS kw_spitze,
|
||||||
ROUND(SUM(kwh_teil), 3) AS kwh_fahrzeug,
|
COALESCE(MAX(NULLIF(wb_wh_total, 0)) - MIN(wh_start), 0) AS wh,
|
||||||
ROUND(AVG(pv_kw), 2) AS pv_kw, COUNT(*) AS punkte
|
/*
|
||||||
|
* Fahrzeugseitige Energie nur, wenn sie lückenlos integriert werden
|
||||||
|
* konnte - siehe kwh_teil. Sonst NULL: eine halbe Summe sähe aus
|
||||||
|
* wie eine kleine Ladung und verfälschte die Kapazitätsrechnung.
|
||||||
|
*/
|
||||||
|
CASE WHEN SUM(zu_lang) = 0 THEN ROUND(SUM(kwh_teil), 3) END AS kwh_fahrzeug,
|
||||||
|
ROUND(AVG(CASE WHEN laedt THEN pv_kw END), 2) AS pv_kw,
|
||||||
|
COUNT(*) AS punkte
|
||||||
|
FROM (
|
||||||
|
SELECT b.*, SUM(b.neu) OVER (ORDER BY b.datetime) AS sitzung FROM (
|
||||||
|
SELECT a.*,
|
||||||
|
a.chg_state = 'CHARGING' AS laedt,
|
||||||
|
(a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke) AS weiter,
|
||||||
|
CASE WHEN a.chg_state = 'CHARGING'
|
||||||
|
AND NOT (a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke)
|
||||||
|
THEN 1 ELSE 0 END AS neu,
|
||||||
|
-- Beginn aus der Zeile davor, wenn sie nah genug liegt.
|
||||||
|
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
|
||||||
|
AND a.dt_vor <= $luecke
|
||||||
|
THEN LEAST(a.soc, COALESCE(a.soc_vor, a.soc))
|
||||||
|
ELSE a.soc END AS soc_start,
|
||||||
|
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
|
||||||
|
AND a.dt_vor <= $luecke
|
||||||
|
THEN COALESCE(NULLIF(a.wh_vor, 0), NULLIF(a.wb_wh_total, 0))
|
||||||
|
ELSE NULLIF(a.wb_wh_total, 0) END AS wh_start,
|
||||||
|
/*
|
||||||
|
* chg_kw ist die Ladeleistung, die das Fahrzeug selbst meldet -
|
||||||
|
* gemessen HINTER dem Ladegerät. Die API kennt keine
|
||||||
|
* Kilowattstunden, deshalb wird über die Zeit integriert, je
|
||||||
|
* Schritt mit dem Mittel aus vorigem und aktuellem Wert.
|
||||||
|
*
|
||||||
|
* Über mehr als 30 Minuten ohne Meldung wäre die Fläche unter
|
||||||
|
* der Kurve erfunden: die Leistung kann in der Zeit von 4 auf
|
||||||
|
* 10 kW gestiegen sein, ohne dass eine Zeile davon weiß.
|
||||||
|
* zu_lang merkt sich das, und die Ladung bekommt dann keinen
|
||||||
|
* fahrzeugseitigen Wert.
|
||||||
|
*/
|
||||||
|
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor BETWEEN 1 AND $luecke
|
||||||
|
THEN (COALESCE(a.chg_kw, 0) + COALESCE(a.kw_vor, 0)) / 2
|
||||||
|
* a.dt_vor / 3600
|
||||||
|
ELSE 0 END AS kwh_teil,
|
||||||
|
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor > 1800
|
||||||
|
THEN 1 ELSE 0 END AS zu_lang
|
||||||
FROM (
|
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,
|
SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
|
||||||
wb_wh_total, pv_kw,
|
wb_wh_total, pv_kw,
|
||||||
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
|
LAG(chg_state) OVER w AS st_vor,
|
||||||
AND TIMESTAMPDIFF(MINUTE, LAG(datetime) OVER w, datetime) <= 30
|
TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) AS dt_vor,
|
||||||
THEN 0 ELSE 1 END AS neu,
|
LAG(soc) OVER w AS soc_vor,
|
||||||
/*
|
LAG(wb_wh_total) OVER w AS wh_vor,
|
||||||
* Fahrzeugseitige Energie: chg_kw ist die Ladeleistung, die
|
LAG(chg_kw) OVER w AS kw_vor
|
||||||
* 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
|
FROM skoda WHERE datetime >= $seit
|
||||||
WINDOW w AS (ORDER BY datetime)) s) t
|
WINDOW w AS (ORDER BY datetime)) a) b) t
|
||||||
WHERE chg_state = 'CHARGING'
|
-- Die Ladezeilen und die eine Zeile, mit der die Ladung endet.
|
||||||
|
WHERE laedt OR weiter
|
||||||
GROUP BY sitzung";
|
GROUP BY sitzung";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +213,154 @@ function kapazitaetSchaetzen($mysql)
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verlauf einer Ladung: Wallbox-Leistung dicht, Ladestand dazwischen gefüllt.
|
||||||
|
*
|
||||||
|
* Das Fahrzeug meldet beim Laden an der Wallbox mitunter nur einmal in der
|
||||||
|
* Stunde, die Wallbox steht in skoda_ladepunkte im Minutentakt (siehe
|
||||||
|
* solarLog_skoda_ladepunkte.sql). Der Ladestand zwischen zwei Meldungen wird
|
||||||
|
* nach der geflossenen Energie aufgefüllt, nicht nach der Zeit:
|
||||||
|
*
|
||||||
|
* soc(t) = soc_a + (soc_b - soc_a) * (wh(t) - wh_a) / (wh_b - wh_a)
|
||||||
|
*
|
||||||
|
* Das braucht weder Akkukapazität noch Wirkungsgrad. Gegengeprüft am
|
||||||
|
* 13.09.2026: zwischen 56 % (09:59) und 70 % (11:22) ergibt es für 10:29
|
||||||
|
* 59,6 % - gemeldet waren 60 %. Nach der Zeit wären es 61,1 % gewesen.
|
||||||
|
*
|
||||||
|
* Eine Meldung gilt für den Zeitpunkt, zu dem das Fahrzeug sie erfasst hat
|
||||||
|
* (cap_chg), nicht für den Abruf - dazwischen können Minuten mit 11 kW
|
||||||
|
* liegen. Der Zählerstand zu diesem Zeitpunkt kommt deshalb aus dem
|
||||||
|
* Wallbox-Verlauf selbst und nicht aus der Zeile.
|
||||||
|
*
|
||||||
|
* Vor der ersten und nach der letzten Meldung bleibt der Ladestand leer:
|
||||||
|
* hochgerechnet wird nichts.
|
||||||
|
*
|
||||||
|
* Liefert [verlauf, meldungen, wallbox]; ohne Punkte in skoda_ladepunkte
|
||||||
|
* zwei leere Listen - dann zeichnet die Seite wie früher aus den Zeilen.
|
||||||
|
*/
|
||||||
|
function ladeverlauf($mysql, $von, $bis)
|
||||||
|
{
|
||||||
|
// Welche Wallbox: die, die im Zeitraum Energie abgegeben hat.
|
||||||
|
$stmt = $mysql->prepare(
|
||||||
|
"SELECT wallbox, MAX(wh_total) - MIN(wh_total) AS wh, COUNT(*) AS n
|
||||||
|
FROM skoda_ladepunkte
|
||||||
|
WHERE datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
|
||||||
|
GROUP BY wallbox ORDER BY wh DESC, n DESC LIMIT 1");
|
||||||
|
if (!$stmt) {
|
||||||
|
return [[], [], null]; // Tabelle fehlt noch
|
||||||
|
}
|
||||||
|
$stmt->bind_param("ss", $von, $bis);
|
||||||
|
$stmt->execute();
|
||||||
|
$wahl = $stmt->get_result()->fetch_assoc();
|
||||||
|
if (!$wahl) {
|
||||||
|
return [[], [], null];
|
||||||
|
}
|
||||||
|
$wallbox = $wahl["wallbox"];
|
||||||
|
$spalte = $wallbox === "og" ? "wbog_wh_total" : "wb_wh_total";
|
||||||
|
|
||||||
|
$stmt = $mysql->prepare(
|
||||||
|
"SELECT datetime, kw, wh_total, quelle FROM skoda_ladepunkte
|
||||||
|
WHERE wallbox = ? AND datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
|
||||||
|
ORDER BY datetime");
|
||||||
|
$stmt->bind_param("sss", $wallbox, $von, $bis);
|
||||||
|
$stmt->execute();
|
||||||
|
$res = $stmt->get_result();
|
||||||
|
$verlauf = [];
|
||||||
|
while ($z = $res->fetch_assoc()) {
|
||||||
|
$verlauf[] = [
|
||||||
|
"datetime" => $z["datetime"],
|
||||||
|
"t" => strtotime($z["datetime"]),
|
||||||
|
"kw" => floatval($z["kw"]),
|
||||||
|
"wh" => $z["wh_total"] === null ? null : intval($z["wh_total"]),
|
||||||
|
"quelle" => $z["quelle"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$verlauf) {
|
||||||
|
return [[], [], null];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zählerstand zu einem beliebigen Zeitpunkt, aus dem Verlauf. Vor dem
|
||||||
|
// ersten Punkt ruhte die Wallbox - dann gilt der Stand, den die Zeile
|
||||||
|
// selbst trägt.
|
||||||
|
$zaehlerBei = function ($t, $ersatz) use ($verlauf) {
|
||||||
|
$vor = null;
|
||||||
|
foreach ($verlauf as $p) {
|
||||||
|
if ($p["wh"] === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($p["t"] >= $t) {
|
||||||
|
if ($vor === null) {
|
||||||
|
return $ersatz ?: $p["wh"];
|
||||||
|
}
|
||||||
|
$spanne = $p["t"] - $vor["t"];
|
||||||
|
return $spanne > 0
|
||||||
|
? $vor["wh"] + ($p["wh"] - $vor["wh"]) * ($t - $vor["t"]) / $spanne
|
||||||
|
: $p["wh"];
|
||||||
|
}
|
||||||
|
$vor = $p;
|
||||||
|
}
|
||||||
|
return $vor ? $vor["wh"] : $ersatz;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Die Meldungen des Fahrzeugs, die den Verlauf einrahmen: die letzte vor
|
||||||
|
// dem ersten Punkt, alle dazwischen, die erste nach dem letzten.
|
||||||
|
$erster = $verlauf[0]["datetime"];
|
||||||
|
$letzter = end($verlauf)["datetime"];
|
||||||
|
$stmt = $mysql->prepare(
|
||||||
|
"(SELECT COALESCE(cap_chg, datetime) AS erfasst, soc, chg_kw, chg_state, $spalte AS wh
|
||||||
|
FROM skoda WHERE COALESCE(cap_chg, datetime) <= ? AND soc IS NOT NULL
|
||||||
|
ORDER BY erfasst DESC LIMIT 1)
|
||||||
|
UNION
|
||||||
|
(SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
|
||||||
|
FROM skoda WHERE COALESCE(cap_chg, datetime) > ? AND COALESCE(cap_chg, datetime) < ?
|
||||||
|
AND soc IS NOT NULL)
|
||||||
|
UNION
|
||||||
|
(SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
|
||||||
|
FROM skoda WHERE COALESCE(cap_chg, datetime) >= ? AND soc IS NOT NULL
|
||||||
|
ORDER BY 1 LIMIT 1)
|
||||||
|
ORDER BY erfasst");
|
||||||
|
$stmt->bind_param("ssss", $erster, $erster, $letzter, $letzter);
|
||||||
|
$stmt->execute();
|
||||||
|
$res = $stmt->get_result();
|
||||||
|
$meldungen = [];
|
||||||
|
while ($z = $res->fetch_assoc()) {
|
||||||
|
$t = strtotime($z["erfasst"]);
|
||||||
|
$meldungen[] = [
|
||||||
|
"datetime" => $z["erfasst"],
|
||||||
|
"t" => $t,
|
||||||
|
"soc" => intval($z["soc"]),
|
||||||
|
"chg_kw" => $z["chg_kw"] === null ? null : floatval($z["chg_kw"]),
|
||||||
|
"chg_state"=> $z["chg_state"],
|
||||||
|
"wh" => $zaehlerBei($t, intval($z["wh"])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ladestand je Punkt, zwischen den beiden Meldungen, die ihn einrahmen.
|
||||||
|
foreach ($verlauf as &$p) {
|
||||||
|
$p["soc"] = null;
|
||||||
|
for ($i = 1; $p["wh"] !== null && $i < count($meldungen); $i++) {
|
||||||
|
$a = $meldungen[$i - 1];
|
||||||
|
$b = $meldungen[$i];
|
||||||
|
if ($p["t"] < $a["t"] || $p["t"] > $b["t"]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$spanne = $b["wh"] - $a["wh"];
|
||||||
|
$p["soc"] = $spanne > 0
|
||||||
|
? round($a["soc"] + ($b["soc"] - $a["soc"]) * ($p["wh"] - $a["wh"]) / $spanne, 1)
|
||||||
|
: floatval($a["soc"]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
unset($p["t"]);
|
||||||
|
}
|
||||||
|
unset($p);
|
||||||
|
foreach ($meldungen as &$m) {
|
||||||
|
unset($m["t"], $m["wh"]);
|
||||||
|
}
|
||||||
|
unset($m);
|
||||||
|
|
||||||
|
return [$verlauf, $meldungen, $wallbox];
|
||||||
|
}
|
||||||
|
|
||||||
/** Median eines nicht leeren Zahlenfeldes. */
|
/** Median eines nicht leeren Zahlenfeldes. */
|
||||||
function median($werte)
|
function median($werte)
|
||||||
{
|
{
|
||||||
@@ -237,6 +431,9 @@ case "kurve":
|
|||||||
while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; }
|
while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; }
|
||||||
$antwort["von"] = $von;
|
$antwort["von"] = $von;
|
||||||
$antwort["bis"] = $bis;
|
$antwort["bis"] = $bis;
|
||||||
|
|
||||||
|
list($antwort["verlauf"], $antwort["meldungen"], $antwort["wallbox"])
|
||||||
|
= ladeverlauf($mysql, $von, $bis);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
/* ------------------------------------------------------------ gesundheit -- */
|
/* ------------------------------------------------------------ gesundheit -- */
|
||||||
|
|||||||
+39
-73
@@ -14,28 +14,25 @@
|
|||||||
* ein vorausgeladenes Bild oder einen Klick auf eine fremde Seite fernsteuerbar.
|
* ein vorausgeladenes Bild oder einen Klick auf eine fremde Seite fernsteuerbar.
|
||||||
*
|
*
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
* Zwei Schlüssel, zwei Kontingente
|
* Ein Kontingent für alles
|
||||||
*
|
*
|
||||||
* Die API begrenzt auf 20 Anfragen je Stunde **und Schlüssel**, und Befehle
|
* Die API begrenzt auf 20 Anfragen je Stunde, und Befehle zählen genauso mit
|
||||||
* zählen genauso mit wie Abrufe. Steht in skoda.conf ein eigener
|
* wie Abrufe. Die Doku schreibt "je Schlüssel" - gemessen stimmt das nicht,
|
||||||
* CMD_API_KEY, hat die Steuerung damit ihr eigenes Kontingent und nimmt dem
|
* der Zähler gilt für alle Schlüssel des Kontos zusammen (die Rechnung steht
|
||||||
* Abruf des Fahrzeugzustands nichts weg. Beide Schlüssel werden in der
|
* bei _LIMIT in gatherSkodaData.py). Mehrere Schlüssel bringen also kein
|
||||||
* MyŠkoda-App angelegt.
|
* zusätzliches Kontingent.
|
||||||
*
|
*
|
||||||
* Ohne eigenen Schlüssel greift dieses Skript auf API_KEY zurück - dann
|
* Abruf und Steuerung laufen in getrennten Prozessen ohne gemeinsamen Zähler,
|
||||||
* teilen sich beide ein Kontingent, und weil sie in getrennten Prozessen
|
* deshalb ist fest aufgeteilt:
|
||||||
* laufen und keinen gemeinsamen Zähler haben, ist es fest aufgeteilt:
|
|
||||||
*
|
*
|
||||||
* eigener CMD_API_KEY nur API_KEY
|
* Abruf 14 / Stunde (gatherSkodaData.py)
|
||||||
* Abruf 18 / Stunde 14 / Stunde
|
* Befehle 4 / Stunde
|
||||||
* Befehle 15 / Stunde 4 / Stunde
|
* Reserve 2 / Stunde
|
||||||
* Reserve 2 / Stunde 2 / Stunde
|
|
||||||
*
|
*
|
||||||
* gatherSkodaData.py stellt sich selbst um, sobald der zweite Schlüssel in
|
* Ein eigener CMD_API_KEY trennt damit nicht die Kontingente, sondern nur,
|
||||||
* skoda.conf auftaucht - dort ist nichts von Hand nachzuziehen.
|
* wer womit fragt - er lässt sich einzeln widerrufen, ohne den Abruf
|
||||||
*
|
* mitzunehmen. Welchen dieses Skript nimmt, entscheidet
|
||||||
* Der zweite Schlüssel ist kein Freibrief, den Abruf zu beschleunigen: die
|
* skodaBefehlsSchluessel(): den eigenen, wenn es ihn gibt, sonst API_KEY.
|
||||||
* Kachelrichtlinie zielt auf die Last, nicht auf die Buchführung.
|
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
*
|
*
|
||||||
* Nicht angebunden ist die Standheizung. Ihr Start verlangt laut Spec die
|
* Nicht angebunden ist die Standheizung. Ihr Start verlangt laut Spec die
|
||||||
@@ -46,24 +43,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once("../helper.php");
|
require_once("../helper.php");
|
||||||
|
// Wo skoda.conf liegt, wie sie aussieht, welcher Schlüssel die Befehle
|
||||||
|
// schickt und wie viele je Stunde erlaubt sind: alles dort, damit der Reiter
|
||||||
|
// in den Einstellungen und dieser Endpunkt dieselbe Wahrheit benutzen.
|
||||||
|
require_once("../restricted/skodaKeys.php");
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
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
|
* Die erlaubten Befehle. Der Schlüssel kommt aus dem Formular, der Pfad
|
||||||
* niemals - so kann über den Parameter kein beliebiger Endpunkt der API
|
* niemals - so kann über den Parameter kein beliebiger Endpunkt der API
|
||||||
@@ -93,18 +79,16 @@ if (!isset(BEFEHLE[$name])) {
|
|||||||
}
|
}
|
||||||
$befehl = BEFEHLE[$name];
|
$befehl = BEFEHLE[$name];
|
||||||
|
|
||||||
$konfig = konfigLesen();
|
$konfig = skodaBefehlsSchluessel();
|
||||||
if (!$konfig) {
|
if (!$konfig) {
|
||||||
antwort(503, false, "Kein API-Schlüssel hinterlegt - skoda.conf fehlt.");
|
antwort(503, false, "Kein API-Schlüssel hinterlegt - unter Einstellungen "
|
||||||
|
. "▸ Fahrzeug lässt sich einer eintragen.");
|
||||||
}
|
}
|
||||||
|
|
||||||
$grenze = $konfig["EIGENER"] ? BEFEHLE_EIGENER_SCHLUESSEL : BEFEHLE_GETEILT;
|
if (!budgetFrei(SKODA_BEFEHLE)) {
|
||||||
if (!budgetFrei($grenze)) {
|
|
||||||
antwort(429, false, "Stündliches Kontingent für Befehle erschöpft ("
|
antwort(429, false, "Stündliches Kontingent für Befehle erschöpft ("
|
||||||
. $grenze . " je Stunde)."
|
. SKODA_BEFEHLE . " je Stunde). Es teilt sich die 20 Anfragen der API"
|
||||||
. ($konfig["EIGENER"] ? "" : " Ein eigener CMD_API_KEY in skoda.conf"
|
. " mit dem Abruf des Fahrzeugzustands. Später erneut versuchen.");
|
||||||
. " würde der Steuerung ein eigenes Kontingent geben.")
|
|
||||||
. " Später erneut versuchen.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- Ausführen */
|
/* ---------------------------------------------------------------- Ausführen */
|
||||||
@@ -121,8 +105,9 @@ if ($name === "klima_start") {
|
|||||||
list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf);
|
list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf);
|
||||||
|
|
||||||
if ($status === 202) {
|
if ($status === 202) {
|
||||||
|
skodaAbrufAnstossen();
|
||||||
antwort(200, true, $befehl["text"] . ". Das Fahrzeug führt den Befehl aus; "
|
antwort(200, true, $befehl["text"] . ". Das Fahrzeug führt den Befehl aus; "
|
||||||
. "in der Anzeige erscheint es beim nächsten Abruf.");
|
. "in der Anzeige erscheint es in einer halben Minute.");
|
||||||
}
|
}
|
||||||
antwort(200, false, fehlertext($status, $inhalt));
|
antwort(200, false, fehlertext($status, $inhalt));
|
||||||
|
|
||||||
@@ -136,39 +121,19 @@ function antwort($code, $ok, $text)
|
|||||||
exit;
|
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:
|
* Stundenzähler für Befehle. Gleiches Verfahren wie die Bremse in tile.php:
|
||||||
* eine kleine Datei, gegen gleichzeitige Zugriffe mit flock abgesichert.
|
* eine kleine Datei, gegen gleichzeitige Zugriffe mit flock abgesichert.
|
||||||
|
*
|
||||||
|
* Das Fenster ist fest: es beginnt mit dem ersten Befehl und läuft eine Stunde.
|
||||||
|
* Die API zählt gleitend, hier stehen also im Grenzfall zwei Fenster
|
||||||
|
* nebeneinander - garantiert sind die $grenze Befehle, mehr geht auf das Risiko
|
||||||
|
* eines 429 von der API selbst.
|
||||||
*/
|
*/
|
||||||
function budgetFrei($grenze)
|
function budgetFrei($grenze)
|
||||||
{
|
{
|
||||||
$ort = null;
|
$ort = null;
|
||||||
foreach (ZAEHLER_ORTE as $kandidat) {
|
foreach (SKODA_ARBEITSORTE as $kandidat) {
|
||||||
if (is_dir($kandidat) && is_writable($kandidat)) {
|
if (is_dir($kandidat) && is_writable($kandidat)) {
|
||||||
$ort = $kandidat;
|
$ort = $kandidat;
|
||||||
break;
|
break;
|
||||||
@@ -180,7 +145,7 @@ function budgetFrei($grenze)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$griff = fopen($ort . "/.befehle.json", "c+");
|
$griff = fopen($ort . "/" . SKODA_ZAEHLER_DATEI, "c+");
|
||||||
if (!$griff || !flock($griff, LOCK_EX)) {
|
if (!$griff || !flock($griff, LOCK_EX)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -207,16 +172,16 @@ function budgetFrei($grenze)
|
|||||||
/** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */
|
/** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */
|
||||||
function senden($konfig, $pfad, $rumpf)
|
function senden($konfig, $pfad, $rumpf)
|
||||||
{
|
{
|
||||||
$url = API_BASIS . rawurlencode($konfig["VIN"]) . "/" . $pfad;
|
$url = SKODA_API . rawurlencode($konfig["vin"]) . "/" . $pfad;
|
||||||
$kopf = [
|
$kopf = [
|
||||||
"X-API-Key: " . $konfig["SCHLUESSEL"],
|
"X-API-Key: " . $konfig["schluessel"],
|
||||||
"Accept: application/json",
|
"Accept: application/json",
|
||||||
];
|
];
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
$optionen = [
|
$optionen = [
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_USERAGENT => KENNUNG,
|
CURLOPT_USERAGENT => SKODA_KENNUNG,
|
||||||
CURLOPT_TIMEOUT => 20,
|
CURLOPT_TIMEOUT => 20,
|
||||||
CURLOPT_CONNECTTIMEOUT => 5,
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
];
|
];
|
||||||
@@ -257,7 +222,8 @@ function fehlertext($status, $inhalt)
|
|||||||
switch ($status) {
|
switch ($status) {
|
||||||
case 0: return "Die Škoda-API ist nicht erreichbar.";
|
case 0: return "Die Škoda-API ist nicht erreichbar.";
|
||||||
case 400: return "Die API hat den Befehl abgelehnt." . $detail;
|
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 401: return "Der API-Schlüssel ist abgelaufen - in der MyŠkoda-App "
|
||||||
|
. "erneuern und unter Einstellungen ▸ Fahrzeug eintragen.";
|
||||||
case 403: return "Der Schlüssel darf diesen Befehl nicht ausführen." . $detail;
|
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 404: return "Fahrzeug nicht gefunden - stimmt die VIN in skoda.conf?";
|
||||||
case 422: return "Das Fahrzeug kann das gerade nicht." . $detail;
|
case 422: return "Das Fahrzeug kann das gerade nicht." . $detail;
|
||||||
|
|||||||
@@ -2394,6 +2394,12 @@ input[type='range']::-webkit-slider-runnable-track {
|
|||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.kachel-wert .kw-schalter .kw-format {
|
||||||
|
width: auto;
|
||||||
|
font-size: .78rem;
|
||||||
|
padding-top: .1rem;
|
||||||
|
padding-bottom: .1rem;
|
||||||
|
}
|
||||||
.kachel-wert .kw-schalter label {
|
.kachel-wert .kw-schalter label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -156,8 +156,12 @@ const homeSVG = {
|
|||||||
// Solarseite fuer dieselben Zahlen benutzt - eine eigene Regel hier
|
// Solarseite fuer dieselben Zahlen benutzt - eine eigene Regel hier
|
||||||
// haette bedeutet, dass dieselbe Wallbox an zwei Stellen verschieden
|
// haette bedeutet, dass dieselbe Wallbox an zwei Stellen verschieden
|
||||||
// gerundet dasteht.
|
// gerundet dasteht.
|
||||||
if (wert.format === "leistung") {
|
//
|
||||||
const zahl = Number(text);
|
// "leistung_kw" ist dasselbe fuer Quellen, die schon Kilowatt melden -
|
||||||
|
// die Wallboxen im SolarManager. Umgerechnet wird hier in Watt, damit
|
||||||
|
// es eine Regel fuer beide bleibt.
|
||||||
|
if (wert.format === "leistung" || wert.format === "leistung_kw") {
|
||||||
|
const zahl = Number(text) * (wert.format === "leistung_kw" ? 1000 : 1);
|
||||||
if (text === "" || isNaN(zahl)) return null;
|
if (text === "" || isNaN(zahl)) return null;
|
||||||
// Ersetzt wird nur das Trennzeichen: auf der Kachel steht ein
|
// Ersetzt wird nur das Trennzeichen: auf der Kachel steht ein
|
||||||
// schmales Leerzeichen, im Rest der Anwendung ein gewoehnliches.
|
// schmales Leerzeichen, im Rest der Anwendung ein gewoehnliches.
|
||||||
|
|||||||
+269
-8
@@ -316,7 +316,7 @@ function kachelQuelle(wert) {
|
|||||||
* und aus drei Kuerzeln wuerden drei Zeilen.
|
* und aus drei Kuerzeln wuerden drei Zeilen.
|
||||||
*/
|
*/
|
||||||
function kachelWertKurz(wert, kachel) {
|
function kachelWertKurz(wert, kachel) {
|
||||||
const einheit = wert.format === "leistung" ? "W/kW" : (wert.einheit || "");
|
const einheit = istLeistung(wert) ? "W/kW" : (wert.einheit || "");
|
||||||
const treffer = wert.wechselrichter ? null : katalogTreffer(wert);
|
const treffer = wert.wechselrichter ? null : katalogTreffer(wert);
|
||||||
let text;
|
let text;
|
||||||
|
|
||||||
@@ -568,20 +568,29 @@ function kachelWertZeile(wert, i, kachel) {
|
|||||||
|
|
||||||
+ '<div class="kw-einheit"><input type="text" class="form-control form-control-sm"'
|
+ '<div class="kw-einheit"><input type="text" class="form-control form-control-sm"'
|
||||||
+ ' data-feld="einheit" placeholder="Einheit" maxlength="8"'
|
+ ' data-feld="einheit" placeholder="Einheit" maxlength="8"'
|
||||||
+ (wert.format === "leistung" ? " disabled" : "")
|
+ (istLeistung(wert) ? " disabled" : "")
|
||||||
+ ' value="' + textSchuetzen(wert.einheit || "") + '"></div>'
|
+ ' value="' + textSchuetzen(wert.einheit || "") + '"></div>'
|
||||||
|
|
||||||
+ '<div class="kw-stellen"><input type="number" class="form-control form-control-sm"'
|
+ '<div class="kw-stellen"><input type="number" class="form-control form-control-sm"'
|
||||||
+ ' data-feld="stellen" min="0" max="3" placeholder="0"'
|
+ ' data-feld="stellen" min="0" max="3" placeholder="0"'
|
||||||
+ (wert.format === "leistung" ? " disabled" : "")
|
+ (istLeistung(wert) ? " disabled" : "")
|
||||||
+ ' value="' + (wert.stellen === undefined ? "" : wert.stellen) + '"></div>'
|
+ ' value="' + (wert.stellen === undefined ? "" : wert.stellen) + '"></div>'
|
||||||
|
|
||||||
+ '<div class="kw-symbol">' + kachelSymbolFeld(wert.icon || "") + "</div>"
|
+ '<div class="kw-symbol">' + kachelSymbolFeld(wert.icon || "") + "</div>"
|
||||||
|
|
||||||
+ '<div class="kw-schalter">'
|
+ '<div class="kw-schalter">'
|
||||||
+ '<label title="Leistung: skaliert selbst zwischen W und kW">'
|
// Leistung: die Anzeige skaliert selbst zwischen W und kW. Gewählt wird
|
||||||
+ '<input type="checkbox" class="form-check-input" data-feld="format"'
|
// nur, in welcher Einheit die Quelle meldet - die Wallboxen schicken kW,
|
||||||
+ (wert.format === "leistung" ? " checked" : "") + "> kW</label>"
|
// die Zähler W. Früher ein Häkchen, das stillschweigend Watt annahm.
|
||||||
|
+ '<select class="form-select form-select-sm kw-format" data-feld="format"'
|
||||||
|
+ ' title="Leistung: in welcher Einheit die Quelle meldet. Angezeigt wird'
|
||||||
|
+ ' dann selbst in W oder kW.">'
|
||||||
|
+ '<option value=""' + (istLeistung(wert) ? "" : " selected") + '>Einheit</option>'
|
||||||
|
+ '<option value="leistung"' + (wert.format === "leistung" ? " selected" : "")
|
||||||
|
+ '>Leistung aus W</option>'
|
||||||
|
+ '<option value="leistung_kw"' + (wert.format === "leistung_kw" ? " selected" : "")
|
||||||
|
+ '>Leistung aus kW</option>'
|
||||||
|
+ "</select>"
|
||||||
+ '<label title="Vorzeichen umdrehen - für Zähler, die verkehrt herum'
|
+ '<label title="Vorzeichen umdrehen - für Zähler, die verkehrt herum'
|
||||||
+ ' eingebaut sind">'
|
+ ' eingebaut sind">'
|
||||||
+ '<input type="checkbox" class="form-check-input" data-feld="negativ"'
|
+ '<input type="checkbox" class="form-check-input" data-feld="negativ"'
|
||||||
@@ -594,6 +603,11 @@ function kachelWertZeile(wert, i, kachel) {
|
|||||||
+ "</div></div>";
|
+ "</div></div>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Skaliert der Wert selbst zwischen W und kW? Egal, aus welcher Einheit. */
|
||||||
|
function istLeistung(wert) {
|
||||||
|
return wert.format === "leistung" || wert.format === "leistung_kw";
|
||||||
|
}
|
||||||
|
|
||||||
/** Fremden Text sicher ins Markup bringen - Topics kommen aus der Datenbank. */
|
/** Fremden Text sicher ins Markup bringen - Topics kommen aus der Datenbank. */
|
||||||
function textSchuetzen(s) {
|
function textSchuetzen(s) {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
@@ -639,8 +653,9 @@ function kachelWertLesen(feldZeile) {
|
|||||||
const pfad = (hol("pfad") || "").trim();
|
const pfad = (hol("pfad") || "").trim();
|
||||||
if (pfad) wert.pfad = pfad;
|
if (pfad) wert.pfad = pfad;
|
||||||
}
|
}
|
||||||
if (hol("format")) {
|
const format = hol("format");
|
||||||
wert.format = "leistung";
|
if (format === "leistung" || format === "leistung_kw") {
|
||||||
|
wert.format = format;
|
||||||
} else {
|
} else {
|
||||||
const einheit = (hol("einheit") || "").trim();
|
const einheit = (hol("einheit") || "").trim();
|
||||||
if (einheit) wert.einheit = einheit;
|
if (einheit) wert.einheit = einheit;
|
||||||
@@ -1130,3 +1145,249 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
geraeteBinden();
|
geraeteBinden();
|
||||||
geraeteHolen();
|
geraeteHolen();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Fahrzeug: die Zugangsschlüssel der MyŠkoda-Anbindung
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
Sie laufen ab. Bis hierher hieß der Tausch: per SSH auf die NAS, skoda.conf
|
||||||
|
im Editor öffnen, nicht vertippen. Das gehört in die Einstellungen wie die
|
||||||
|
Preise und die Geräte.
|
||||||
|
|
||||||
|
Was hier nie ankommt, sind die Schlüssel selbst. Die Seite steht im
|
||||||
|
Heimnetz ohne Anmeldung offen, und ein solcher Schlüssel steuert das
|
||||||
|
Fahrzeug von überall auf der Welt - Laden, Klima, Standort. Zu sehen sind
|
||||||
|
nur die letzten vier Zeichen, gerade genug, um zwei auseinanderzuhalten.
|
||||||
|
Geschrieben wird deshalb auch nur in eine Richtung: ein neuer Wert geht
|
||||||
|
hinein, ein alter kommt nie heraus.
|
||||||
|
|
||||||
|
Der zweite und dritte Schlüssel beschleunigen nichts: das Kontingent der
|
||||||
|
API gilt gemessen fürs ganze Konto. Sie sind für den Fall da, dass einer
|
||||||
|
abläuft oder widerrufen wird - dann fragt der Abruf mit dem nächsten
|
||||||
|
weiter, statt stillzustehen. Deshalb steht unter den Feldern, was gerade
|
||||||
|
gilt, und nicht, was ein weiterer Schlüssel brächte.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
let skodaModell = null; // was ?action=skoda-keys liefert, ohne Werte
|
||||||
|
|
||||||
|
/* --- Zeichnen ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function zeichneSkoda() {
|
||||||
|
const ziel = document.getElementById("skodaListe");
|
||||||
|
if (!ziel || !skodaModell) return;
|
||||||
|
|
||||||
|
if (!skodaModell.da) {
|
||||||
|
ziel.innerHTML = '<div class="alert alert-warning mb-0">'
|
||||||
|
+ "Die Datei <code>" + textSchuetzen(skodaModell.datei) + "</code> gibt es nicht. "
|
||||||
|
+ "Sie gehört zum SolarManager und wird dort angelegt – ohne sie läuft auch "
|
||||||
|
+ "der Abruf nicht.</div>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeilen = skodaModell.felder.map(skodaZeile).join("");
|
||||||
|
ziel.innerHTML =
|
||||||
|
'<div class="skoda-felder">' + zeilen + "</div>"
|
||||||
|
+ (skodaModell.schreibbar ? "" :
|
||||||
|
'<div class="alert alert-warning mt-3 mb-0 py-2 small">'
|
||||||
|
+ "Der Webserver darf <code>" + textSchuetzen(skodaModell.datei)
|
||||||
|
+ "</code> nicht schreiben. Eintragen lässt sich hier nichts.</div>")
|
||||||
|
+ '<div class="d-flex flex-wrap align-items-center gap-3 mt-3">'
|
||||||
|
+ '<button type="button" class="btn btn-primary" id="skodaSpeichern"'
|
||||||
|
+ (skodaModell.schreibbar ? "" : " disabled") + ">Eintragen</button>"
|
||||||
|
+ '<span class="skoda-stand small text-body-secondary"></span>'
|
||||||
|
+ "</div>"
|
||||||
|
+ skodaBilanz();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Zeile: was der Schlüssel tut, ob er da ist, und ein Feld dafür. */
|
||||||
|
function skodaZeile(f) {
|
||||||
|
const kennung = "skodaFeld-" + f.feld;
|
||||||
|
const stand = f.gesetzt
|
||||||
|
? '<span class="badge text-bg-success">hinterlegt</span>'
|
||||||
|
+ ' <span class="text-body-secondary">endet auf ' + textSchuetzen(f.maske) + "</span>"
|
||||||
|
: '<span class="badge text-bg-secondary">'
|
||||||
|
+ (f.pflicht ? "fehlt" : "nicht hinterlegt") + "</span>";
|
||||||
|
|
||||||
|
// Entfernen nur, wo es etwas zu entfernen gibt und wo es ohne geht. Der
|
||||||
|
// erste Schlüssel lässt sich ersetzen, aber nicht abschaffen - ohne ihn
|
||||||
|
// steht der Abruf.
|
||||||
|
const weg = (f.gesetzt && !f.pflicht)
|
||||||
|
? '<button type="button" class="btn btn-outline-danger btn-sm skoda-weg"'
|
||||||
|
+ ' data-feld="' + textSchuetzen(f.feld) + '" title="Diesen Schlüssel entfernen">'
|
||||||
|
+ '<i class="bi bi-trash"></i></button>'
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return '<div class="border-top py-3 skoda-feld" data-feld="' + textSchuetzen(f.feld) + '">'
|
||||||
|
+ '<div class="d-flex flex-wrap align-items-baseline gap-2">'
|
||||||
|
+ '<label class="fw-semibold mb-0" for="' + kennung + '">'
|
||||||
|
+ textSchuetzen(f.titel) + "</label>"
|
||||||
|
+ stand
|
||||||
|
+ "</div>"
|
||||||
|
+ '<p class="text-body-secondary small mb-2">' + textSchuetzen(f.zweck) + "</p>"
|
||||||
|
+ '<div class="input-group input-group-sm">'
|
||||||
|
+ '<input type="password" class="form-control" id="' + kennung + '"'
|
||||||
|
+ ' autocomplete="off" spellcheck="false"'
|
||||||
|
+ ' placeholder="' + (f.gesetzt ? "neuer Schlüssel – leer lassen, um ihn zu behalten"
|
||||||
|
: "Schlüssel einfügen") + '">'
|
||||||
|
+ '<button type="button" class="btn btn-outline-secondary skoda-zeigen"'
|
||||||
|
+ ' title="Anzeigen"><i class="bi bi-eye"></i></button>'
|
||||||
|
+ weg
|
||||||
|
+ "</div>"
|
||||||
|
+ "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was die hinterlegten Schlüssel einbringen - die Zahlen dahinter. */
|
||||||
|
function skodaBilanz() {
|
||||||
|
const m = skodaModell;
|
||||||
|
const teile = [];
|
||||||
|
teile.push(m.abruf + (m.abruf === 1 ? " Schlüssel" : " Schlüssel im Wechsel")
|
||||||
|
+ " für den Abruf – beim Laden und während der Fahrt alle "
|
||||||
|
+ m.takt + " Sekunden");
|
||||||
|
teile.push(m.befehle + " Befehle je Stunde");
|
||||||
|
if (m.ablauf) {
|
||||||
|
teile.push("nächster Ablauf am " + datumLesbar(String(m.ablauf).slice(0, 10)));
|
||||||
|
}
|
||||||
|
return '<p class="text-body-secondary small mt-3 mb-0">'
|
||||||
|
+ textSchuetzen(teile.join(" · ")) + "<br>"
|
||||||
|
+ "Ein neuer Schlüssel gilt sofort; der Abruf liest die Datei von selbst neu."
|
||||||
|
+ "</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function skodaStand(text, klasse) {
|
||||||
|
const ziel = document.querySelector("#skodaListe .skoda-stand");
|
||||||
|
if (!ziel) return;
|
||||||
|
ziel.className = "skoda-stand small " + (klasse || "text-body-secondary");
|
||||||
|
ziel.textContent = text;
|
||||||
|
clearTimeout(skodaStand._uhr);
|
||||||
|
if (klasse !== "text-danger") {
|
||||||
|
skodaStand._uhr = setTimeout(function () {
|
||||||
|
if (ziel.textContent === text) ziel.textContent = "";
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Sprechen mit dem Endpunkt ------------------------------------------- */
|
||||||
|
|
||||||
|
async function skodaFrage(action, koerper) {
|
||||||
|
const antwort = await fetch("ajax/settings.php?action=" + action, koerper ? {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(koerper),
|
||||||
|
} : { cache: "no-store" });
|
||||||
|
const d = await antwort.json();
|
||||||
|
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function skodaHolen() {
|
||||||
|
const ziel = document.getElementById("skodaListe");
|
||||||
|
if (!ziel) return;
|
||||||
|
try {
|
||||||
|
skodaModell = await skodaFrage("skoda-keys");
|
||||||
|
zeichneSkoda();
|
||||||
|
} catch (e) {
|
||||||
|
ziel.innerHTML = '<div class="alert alert-danger mb-0">Schlüssel nicht abrufbar: '
|
||||||
|
+ textSchuetzen(e.message) + "</div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alles Eingetragene auf einmal senden.
|
||||||
|
*
|
||||||
|
* Leere Felder bedeuten "so lassen" - sonst löschte ein Speichern ohne
|
||||||
|
* Eingabe alles, was man nicht noch einmal eintippt.
|
||||||
|
*
|
||||||
|
* Das kann dauern: der Server probiert jeden neuen Schlüssel einmal an der
|
||||||
|
* API aus, bevor er ihn hinschreibt. Deshalb der Knopf mit Wartezustand.
|
||||||
|
*/
|
||||||
|
async function skodaSenden() {
|
||||||
|
const knopf = document.getElementById("skodaSpeichern");
|
||||||
|
const felder = {};
|
||||||
|
document.querySelectorAll("#skodaListe .skoda-feld").forEach(function (zeile) {
|
||||||
|
const feld = zeile.dataset.feld;
|
||||||
|
const wert = zeile.querySelector("input").value.trim();
|
||||||
|
if (wert) felder[feld] = wert;
|
||||||
|
});
|
||||||
|
if (!Object.keys(felder).length) {
|
||||||
|
skodaStand("Kein Schlüssel eingetragen.", "text-warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knopf) knopf.disabled = true;
|
||||||
|
skodaStand("Wird an der Škoda-API ausprobiert …");
|
||||||
|
try {
|
||||||
|
const d = await skodaFrage("skoda-keys-save", { felder: felder });
|
||||||
|
skodaModell = d.stand;
|
||||||
|
zeichneSkoda();
|
||||||
|
skodaStand((d.meldungen || []).join(" ") || "Eingetragen.", "text-success");
|
||||||
|
} catch (e) {
|
||||||
|
skodaStand(e.message, "text-danger");
|
||||||
|
if (knopf) knopf.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einen Schlüssel entfernen.
|
||||||
|
*
|
||||||
|
* Mit Rückfrage: er steht danach nirgends mehr, und ein neuer kommt nur aus
|
||||||
|
* der MyŠkoda-App.
|
||||||
|
*/
|
||||||
|
async function skodaLoeschen(feld) {
|
||||||
|
if (!window.confirm(feld + " entfernen?\n\nDer Schlüssel verschwindet aus der "
|
||||||
|
+ "Konfiguration. Zurückholen lässt er sich nur über die MyŠkoda-App.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
skodaStand("Wird entfernt …");
|
||||||
|
try {
|
||||||
|
const d = await skodaFrage("skoda-keys-save", { loeschen: [feld] });
|
||||||
|
skodaModell = d.stand;
|
||||||
|
zeichneSkoda();
|
||||||
|
skodaStand(feld + " entfernt.", "text-success");
|
||||||
|
} catch (e) {
|
||||||
|
skodaStand(e.message, "text-danger");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Bedienung ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
function skodaBinden() {
|
||||||
|
const liste = document.getElementById("skodaListe");
|
||||||
|
if (!liste) return;
|
||||||
|
|
||||||
|
liste.addEventListener("click", function (ereignis) {
|
||||||
|
const zeigen = ereignis.target.closest(".skoda-zeigen");
|
||||||
|
if (zeigen) {
|
||||||
|
// Ein eingefügter Schlüssel ist nicht zu merken, nur zu vergleichen -
|
||||||
|
// wer ihn nicht sehen kann, sucht einen Tippfehler im Dunkeln.
|
||||||
|
const feld = zeigen.closest(".input-group").querySelector("input");
|
||||||
|
feld.type = feld.type === "password" ? "text" : "password";
|
||||||
|
zeigen.querySelector("i").className =
|
||||||
|
feld.type === "password" ? "bi bi-eye" : "bi bi-eye-slash";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const weg = ereignis.target.closest(".skoda-weg");
|
||||||
|
if (weg) {
|
||||||
|
skodaLoeschen(weg.dataset.feld);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ereignis.target.closest("#skodaSpeichern")) {
|
||||||
|
skodaSenden();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter im Feld soll dasselbe tun wie der Knopf daneben.
|
||||||
|
liste.addEventListener("keydown", function (ereignis) {
|
||||||
|
if (ereignis.key === "Enter" && ereignis.target.matches(".skoda-feld input")) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
skodaSenden();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const neuLaden = document.getElementById("skodaNeuLaden");
|
||||||
|
if (neuLaden) neuLaden.addEventListener("click", skodaHolen);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
skodaBinden();
|
||||||
|
skodaHolen();
|
||||||
|
});
|
||||||
|
|||||||
+36
-1
@@ -649,7 +649,10 @@ function baueKurve() {
|
|||||||
options: {
|
options: {
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
animation: false,
|
animation: false,
|
||||||
interaction: { mode: "index", intersect: false },
|
// "nearest" statt "index": Wallbox-Verlauf und Fahrzeugmeldungen haben
|
||||||
|
// verschiedene Zeitpunkte, der n-te Wert der einen Reihe gehört nicht zum
|
||||||
|
// n-ten der anderen.
|
||||||
|
interaction: { mode: "nearest", axis: "x", intersect: false },
|
||||||
scales: {
|
scales: {
|
||||||
// 'timestack' ist die Zeitachse, die diese Seite mitbringt
|
// 'timestack' ist die Zeitachse, die diese Seite mitbringt
|
||||||
// (js/chartjs-scale-timestack.min.js). Chart.js' eingebautes 'time'
|
// (js/chartjs-scale-timestack.min.js). Chart.js' eingebautes 'time'
|
||||||
@@ -1151,6 +1154,38 @@ async function ladeKurve() {
|
|||||||
// Date-Objekten - die nimmt sie stillschweigend nicht an und zeigt
|
// Date-Objekten - die nimmt sie stillschweigend nicht an und zeigt
|
||||||
// stattdessen den heutigen Tag.
|
// stattdessen den heutigen Tag.
|
||||||
const zeit = (p) => new Date(String(p.datetime).replace(" ", "T")).getTime();
|
const zeit = (p) => new Date(String(p.datetime).replace(" ", "T")).getTime();
|
||||||
|
|
||||||
|
// Mit Wallbox-Verlauf (skoda_ladepunkte): die Leistung im Minutentakt,
|
||||||
|
// der Ladestand dazwischen nach der geflossenen Energie aufgefüllt, und
|
||||||
|
// die echten Meldungen des Fahrzeugs als Punkte darauf - damit sichtbar
|
||||||
|
// bleibt, was gemessen und was abgeleitet ist. Das Fahrzeug meldet beim
|
||||||
|
// Laden mitunter nur einmal in der Stunde; eine Linie durch diese Punkte
|
||||||
|
// allein erfände den Verlauf dazwischen.
|
||||||
|
if (d.verlauf && d.verlauf.length) {
|
||||||
|
const imBild = (m) => zeit(m) >= zeit(d.verlauf[0]) - 600000
|
||||||
|
&& zeit(m) <= zeit(d.verlauf[d.verlauf.length - 1]) + 600000;
|
||||||
|
const meldungen = (d.meldungen || []).filter(imBild);
|
||||||
|
kurveChart.data.datasets = [
|
||||||
|
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
|
||||||
|
borderWidth: 2, pointRadius: 0, fill: "origin", tension: 0,
|
||||||
|
data: d.verlauf.map(p => ({ x: zeit(p), y: +p.kw })) },
|
||||||
|
{ label: "Ladestand, aufgefüllt", yAxisID: "y1", borderColor: "#f0b429", borderWidth: 2,
|
||||||
|
borderDash: [5, 4], pointRadius: 0, tension: 0, spanGaps: false,
|
||||||
|
data: d.verlauf.map(p => ({ x: zeit(p), y: p.soc === null ? null : +p.soc })) },
|
||||||
|
{ label: "Ladestand laut Fahrzeug", yAxisID: "y1", showLine: false,
|
||||||
|
borderColor: "#f0b429", backgroundColor: "#f0b429", pointRadius: 4, pointHoverRadius: 6,
|
||||||
|
data: meldungen.map(m => ({ x: zeit(m), y: +m.soc })) },
|
||||||
|
{ label: "Leistung laut Fahrzeug", yAxisID: "y", showLine: false,
|
||||||
|
borderColor: "#7aa7c7", backgroundColor: "#7aa7c7", pointRadius: 3, pointStyle: "rectRot",
|
||||||
|
data: meldungen.filter(m => m.chg_kw !== null && m.chg_state === "CHARGING")
|
||||||
|
.map(m => ({ x: zeit(m), y: +m.chg_kw })) }
|
||||||
|
];
|
||||||
|
kurveChart.update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Wallbox-Verlauf - Ladungen von vor der Aufzeichnung - wie bisher
|
||||||
|
// aus den Zeilen der Fahrzeugtabelle.
|
||||||
kurveChart.data.datasets = [
|
kurveChart.data.datasets = [
|
||||||
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
|
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
|
||||||
borderWidth: 2, pointRadius: 0, fill: "origin", tension: .25,
|
borderWidth: 2, pointRadius: 0, fill: "origin", tension: .25,
|
||||||
|
|||||||
@@ -339,7 +339,14 @@ const solarSVG = {
|
|||||||
} else {
|
} else {
|
||||||
setDisplay("heatEG", "none");
|
setDisplay("heatEG", "none");
|
||||||
}
|
}
|
||||||
if (plugev != "no car") {
|
// solarManager/evPlug kommt als 1 oder 0, wie evPlugOG. Frueher kam
|
||||||
|
// hier der Text der Wallbox an ("no car", "charging", ...); Number()
|
||||||
|
// machte daraus NaN, und NaN != "no car" ist immer wahr - das
|
||||||
|
// Steckersymbol stand dauerhaft da. Den Text weiter zu verstehen
|
||||||
|
// kostet eine Zeile und haelt die Anzeige richtig, egal ob der
|
||||||
|
// Manager schon neu gestartet ist oder nicht.
|
||||||
|
const plugText = mqttData["solarManager/evPlug"];
|
||||||
|
if (isNaN(plugev) ? (plugText !== undefined && plugText !== "no car") : plugev > 0) {
|
||||||
setDisplay("evPlug", "");
|
setDisplay("evPlug", "");
|
||||||
} else {
|
} else {
|
||||||
setDisplay("evPlug", "none");
|
setDisplay("evPlug", "none");
|
||||||
|
|||||||
+13
-3
@@ -14,7 +14,9 @@
|
|||||||
* pfad optional: Schlüssel in einer JSON-Nachricht
|
* pfad optional: Schlüssel in einer JSON-Nachricht
|
||||||
* wechselrichter Liste von Namen unter solarManager/invertersN (statt topic)
|
* wechselrichter Liste von Namen unter solarManager/invertersN (statt topic)
|
||||||
* feld bei Wechselrichtern das Feld, meist p_AC
|
* feld bei Wechselrichtern das Feld, meist p_AC
|
||||||
* format "leistung" skaliert selbst zwischen W und kW
|
* format "leistung" skaliert selbst zwischen W und kW, die Quelle
|
||||||
|
* liefert Watt; "leistung_kw" dasselbe fuer Quellen, die
|
||||||
|
* schon in Kilowatt melden (die Wallboxen im SolarManager)
|
||||||
* einheit sonst die Einheit, die hinter der Zahl steht
|
* einheit sonst die Einheit, die hinter der Zahl steht
|
||||||
* stellen Nachkommastellen (0-3)
|
* stellen Nachkommastellen (0-3)
|
||||||
* negativ das Vorzeichen umdrehen - fuer Zaehler, die verkehrt
|
* negativ das Vorzeichen umdrehen - fuer Zaehler, die verkehrt
|
||||||
@@ -43,6 +45,14 @@ require_once(__DIR__ . "/costs.php"); // solarDb()
|
|||||||
/** Höchstens drei Werte je Kachel - mehr passt nicht auf 24 x 19 Einheiten. */
|
/** Höchstens drei Werte je Kachel - mehr passt nicht auf 24 x 19 Einheiten. */
|
||||||
const KACHEL_MAX = 3;
|
const KACHEL_MAX = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Formate, die selbst zwischen W und kW skalieren - je nachdem, in welcher
|
||||||
|
* Einheit die Quelle meldet. Die meisten Zähler schicken Watt; die Wallboxen
|
||||||
|
* im SolarManager (evPower, evPowerOG) schicken Kilowatt, und als Watt gelesen
|
||||||
|
* stand bei 1 kW Ladeleistung "1 W" auf der Kachel.
|
||||||
|
*/
|
||||||
|
const KACHEL_LEISTUNG = ["leistung", "leistung_kw"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vorschläge für das Symbol vor einer Zahl.
|
* Vorschläge für das Symbol vor einer Zahl.
|
||||||
*
|
*
|
||||||
@@ -326,8 +336,8 @@ function kachelWertPruefen($wert, $nummer)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Entweder das Format rechnet die Einheit selbst aus, oder sie steht da.
|
// Entweder das Format rechnet die Einheit selbst aus, oder sie steht da.
|
||||||
if (($wert["format"] ?? "") === "leistung") {
|
if (in_array($wert["format"] ?? "", KACHEL_LEISTUNG, true)) {
|
||||||
$sauber["format"] = "leistung";
|
$sauber["format"] = $wert["format"];
|
||||||
} else {
|
} else {
|
||||||
$einheit = trim((string)($wert["einheit"] ?? ""));
|
$einheit = trim((string)($wert["einheit"] ?? ""));
|
||||||
if (mb_strlen($einheit) > 8) {
|
if (mb_strlen($einheit) > 8) {
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
* restricted/kacheln.php): bis zu drei Messwerte, jeder mit
|
* restricted/kacheln.php): bis zu drei Messwerte, jeder mit
|
||||||
* topic (oder wechselrichter, siehe Carport), optional pfad
|
* topic (oder wechselrichter, siehe Carport), optional pfad
|
||||||
* (Schlüssel in einer JSON-Nachricht) und entweder format
|
* (Schlüssel in einer JSON-Nachricht) und entweder format
|
||||||
* ("leistung" skaliert selbst zwischen W und kW) oder dem Paar
|
* ("leistung" skaliert selbst zwischen W und kW, "leistung_kw"
|
||||||
|
* ebenso fuer Quellen, die schon Kilowatt melden) oder dem Paar
|
||||||
* einheit und stellen (Nachkommastellen). Fehlt der Eintrag, sind
|
* einheit und stellen (Nachkommastellen). Fehlt der Eintrag, sind
|
||||||
* es die drei Thermostatwerte - Soll, Ist, Feuchte - wie bisher.
|
* es die drei Thermostatwerte - Soll, Ist, Feuchte - wie bisher.
|
||||||
* Eine leere Liste heisst: kein Messwert, die Kachel trägt nur
|
* Eine leere Liste heisst: kein Messwert, die Kachel trägt nur
|
||||||
@@ -131,7 +132,7 @@ $rooms = [
|
|||||||
// die man sich vor dem Carport stellt: laedt gerade jemand?
|
// die man sich vor dem Carport stellt: laedt gerade jemand?
|
||||||
["floor" => "AG", "mqtt" => "Carport OG", "id" => "carport_og", "x" => 72, "y" => 154,
|
["floor" => "AG", "mqtt" => "Carport OG", "id" => "carport_og", "x" => 72, "y" => 154,
|
||||||
"werte" => [
|
"werte" => [
|
||||||
["topic" => "solarManager/evPowerOG", "format" => "leistung",
|
["topic" => "solarManager/evPowerOG", "format" => "leistung_kw",
|
||||||
"icon" => "ev-station"],
|
"icon" => "ev-station"],
|
||||||
]],
|
]],
|
||||||
// Veranda und Terrasse gibt es zweimal, je Wohnpartei eine. In der
|
// Veranda und Terrasse gibt es zweimal, je Wohnpartei eine. In der
|
||||||
@@ -159,7 +160,7 @@ $rooms = [
|
|||||||
// restricted/config.php. Ohne Kuerzel im Topic ist das die EG-Wallbox.
|
// restricted/config.php. Ohne Kuerzel im Topic ist das die EG-Wallbox.
|
||||||
["floor" => "AG", "mqtt" => "Garage", "id" => "garage", "x" => 131, "y" => 135,
|
["floor" => "AG", "mqtt" => "Garage", "id" => "garage", "x" => 131, "y" => 135,
|
||||||
"werte" => [
|
"werte" => [
|
||||||
["topic" => "solarManager/evPower", "format" => "leistung",
|
["topic" => "solarManager/evPower", "format" => "leistung_kw",
|
||||||
"icon" => "ev-station"],
|
"icon" => "ev-station"],
|
||||||
]],
|
]],
|
||||||
// Der Garten ist kein Bauteil wie die anderen, aber ein Ort: hier haengen
|
// Der Garten ist kein Bauteil wie die anderen, aber ein Ort: hier haengen
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
require_once(__DIR__ . "/costs.php");
|
require_once(__DIR__ . "/costs.php");
|
||||||
require_once(__DIR__ . "/kacheln.php");
|
require_once(__DIR__ . "/kacheln.php");
|
||||||
require_once(__DIR__ . "/reiter.php");
|
require_once(__DIR__ . "/reiter.php");
|
||||||
|
// Nur wegen SKODA_APP_LINK - der Weg zu den Schlüsseln gehört neben das Feld,
|
||||||
|
// in das sie eingetragen werden, und der Link soll nur an einer Stelle stehen.
|
||||||
|
require_once(__DIR__ . "/skodaKeys.php");
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Die Reiter. Reihenfolge ist Anzeigereihenfolge; der erste ist der offene,
|
* Die Reiter. Reihenfolge ist Anzeigereihenfolge; der erste ist der offene,
|
||||||
@@ -31,6 +34,7 @@ $reiter = [
|
|||||||
["id" => "preise", "titel" => "Preise", "symbol" => "bi-currency-euro"],
|
["id" => "preise", "titel" => "Preise", "symbol" => "bi-currency-euro"],
|
||||||
["id" => "kacheln", "titel" => "Home-Kacheln", "symbol" => "bi-grid-1x2"],
|
["id" => "kacheln", "titel" => "Home-Kacheln", "symbol" => "bi-grid-1x2"],
|
||||||
["id" => "geraete", "titel" => "Geräte", "symbol" => "bi-house-gear"],
|
["id" => "geraete", "titel" => "Geräte", "symbol" => "bi-house-gear"],
|
||||||
|
["id" => "fahrzeug", "titel" => "Fahrzeug", "symbol" => "bi-ev-front"],
|
||||||
];
|
];
|
||||||
// "merken": der offene Reiter steht in der Adresse und uebersteht ein
|
// "merken": der offene Reiter steht in der Adresse und uebersteht ein
|
||||||
// Neuladen - siehe reiterMerken() in js/solar/common.js.
|
// Neuladen - siehe reiterMerken() in js/solar/common.js.
|
||||||
@@ -192,6 +196,51 @@ try {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?= reiterFlaecheZu() ?><!-- /Geräte -->
|
<?= reiterFlaecheZu() ?><!-- /Geräte -->
|
||||||
|
|
||||||
|
<!-- =================================================== Fahrzeug ==== -->
|
||||||
|
<!-- Die Zugangsschlüssel der MyŠkoda-Anbindung. Sie laufen ab und
|
||||||
|
müssen dann getauscht werden - vorher hieß das: per SSH auf die NAS
|
||||||
|
und skoda.conf im Editor öffnen.
|
||||||
|
|
||||||
|
Die Zeilen zeichnet js/solar/settings.js aus dem, was
|
||||||
|
ajax/settings.php?action=skoda-keys liefert. Was dort NICHT
|
||||||
|
mitkommt, sind die Schlüssel selbst: die Seite steht im Heimnetz
|
||||||
|
ohne Anmeldung offen, und ein solcher Schlüssel steuert das
|
||||||
|
Fahrzeug von überall auf der Welt. Zu sehen ist nur, ob einer
|
||||||
|
hinterlegt ist und auf welche vier Zeichen er endet. -->
|
||||||
|
<?= reiterFlaecheAuf("fahrzeug", $reiterOpt) ?>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-xxl-8">
|
||||||
|
<div class="card mb-4" id="skodaKarte">
|
||||||
|
<div class="card-header pb-2 pt-2">
|
||||||
|
<h3 class="card-title"><i class="bi bi-key me-2"></i>Zugang zum Fahrzeug</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<button type="button" class="btn btn-tool" id="skodaNeuLaden" title="Neu laden">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-secondary small mb-3">
|
||||||
|
Die Schlüssel werden in der MyŠkoda-App erzeugt, gelten nur für die
|
||||||
|
dort ausgewählten Fahrzeuge und laufen nach einiger Zeit ab. Ein neuer
|
||||||
|
wird hier eingetragen und sofort einmal ausprobiert – was die API
|
||||||
|
ablehnt, landet gar nicht erst in der Konfiguration.
|
||||||
|
</p>
|
||||||
|
<p class="mb-3">
|
||||||
|
<a class="btn btn-sm btn-outline-primary"
|
||||||
|
href="<?= SKODA_APP_LINK ?>" target="_blank" rel="noopener">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-1"></i>Schlüssel in der MyŠkoda-App verwalten
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div id="skodaListe">
|
||||||
|
<p class="text-secondary small mb-0">Wird geladen …</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?= reiterFlaecheZu() ?><!-- /Fahrzeug -->
|
||||||
</div><!-- /tab-content -->
|
</div><!-- /tab-content -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,546 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Die Zugangsschlüssel der MyŠkoda-Anbindung.
|
||||||
|
*
|
||||||
|
* Eine Stelle für alles, was skoda.conf betrifft: wo die Datei liegt, wie sie
|
||||||
|
* gelesen wird, was davon die Oberfläche sehen darf und wie ein neuer
|
||||||
|
* Schlüssel hineinkommt. Vorher wusste ajax/skodaCmd.php das für sich allein;
|
||||||
|
* mit dem Reiter in den Einstellungen wären es zwei Fassungen geworden, die
|
||||||
|
* sich beim ersten Formatwechsel unterscheiden.
|
||||||
|
*
|
||||||
|
* Dieselbe Datei liest gatherSkodaData.py im SolarManager. Dass das Format
|
||||||
|
* dort ein zweites Mal steht, lässt sich nicht vermeiden - zwei Sprachen,
|
||||||
|
* zwei Repositorien. skoda.conf.example daneben ist die gemeinsame
|
||||||
|
* Beschreibung, und sie ist die Stelle, an der ein neues Feld zuerst
|
||||||
|
* auftauchen sollte.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Ein Schlüssel geht nie an den Browser zurück
|
||||||
|
*
|
||||||
|
* Die Seite steht im Heimnetz ohne Anmeldung offen (isLocal() in helper.php),
|
||||||
|
* und so ein Schlüssel steuert das Fahrzeug von überall auf der Welt - Laden,
|
||||||
|
* Klimatisierung, Standort. Wer ihn abliest, braucht das Heimnetz danach nicht
|
||||||
|
* mehr. Die Maske bekommt deshalb nur zu sehen, ob ein Schlüssel hinterlegt
|
||||||
|
* ist und auf welche vier Zeichen er endet; das genügt, um zwei
|
||||||
|
* auseinanderzuhalten, und taugt zu nichts sonst.
|
||||||
|
*
|
||||||
|
* Geschrieben wird nur in eine Richtung: ein neuer Wert kommt herein, der alte
|
||||||
|
* kommt nie heraus.
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Die Datei mit den Zugangsdaten. Liegt außerhalb des Webverzeichnisses; sie
|
||||||
|
* gehört zum SolarManager, der sie ebenfalls liest.
|
||||||
|
*
|
||||||
|
* Mit define() davor umzuhängen - ein Prüflauf arbeitet dann auf einer Kopie
|
||||||
|
* und nicht auf den echten Zugangsdaten. */
|
||||||
|
defined("SKODA_KONFIG") || define("SKODA_KONFIG", "/volume1/homes/wagner/SolarManager/skoda.conf");
|
||||||
|
|
||||||
|
/** Basis der öffentlichen API. Ein GET darauf prüft einen Schlüssel. */
|
||||||
|
const SKODA_API = "https://public.api.connect.skoda-auto.cz/api/v1/vehicles/";
|
||||||
|
|
||||||
|
const SKODA_KENNUNG = "SmartController-Skoda/1.0 (+https://nas.el-wa.org/smart)";
|
||||||
|
|
||||||
|
/** Wo die Schlüssel herkommen - der Link gehört in die Maske. */
|
||||||
|
const SKODA_APP_LINK = "https://go.skoda.eu/api-keys";
|
||||||
|
|
||||||
|
/** Befehle je Stunde.
|
||||||
|
*
|
||||||
|
* Das Kontingent der API - 20 je Stunde - gilt gemessen fürs ganze Konto und
|
||||||
|
* nicht je Schlüssel (die Rechnung steht bei _LIMIT in gatherSkodaData.py).
|
||||||
|
* Abruf und Steuerung laufen in getrennten Prozessen ohne gemeinsamen
|
||||||
|
* Zähler, also ist fest aufgeteilt: 14 für den Abruf, 4 für Befehle, 2
|
||||||
|
* Reserve. Ein eigener CMD_API_KEY ändert daran nichts mehr. */
|
||||||
|
const SKODA_BEFEHLE = 4;
|
||||||
|
|
||||||
|
/** Verzeichnisse für kleine Merkzettel zwischen Weboberfläche und Manager.
|
||||||
|
* Das erste, das der Webserver beschreiben darf, gewinnt. Dieselbe Liste in
|
||||||
|
* derselben Reihenfolge steht als _ANSTOSS_ORTE in gatherSkodaData.py. */
|
||||||
|
const SKODA_ARBEITSORTE = [
|
||||||
|
"/volume1/web/smart/tiles", // falls per synoacltool freigegeben
|
||||||
|
"/var/services/tmp/smart-tiles", // sonst
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Name der Anstoßdatei - siehe skodaAbrufAnstossen(). */
|
||||||
|
const SKODA_ANSTOSS_DATEI = ".skoda_abruf";
|
||||||
|
|
||||||
|
/** Name des Stundenzählers für Befehle - siehe budgetFrei() in skodaCmd.php. */
|
||||||
|
const SKODA_ZAEHLER_DATEI = ".befehle.json";
|
||||||
|
|
||||||
|
/** Abstand zweier Abrufe beim Laden und während der Fahrt. Dieselbe Zahl
|
||||||
|
* steht als _I_LADEN in gatherSkodaData.py; hier nur, um sie anzuzeigen.
|
||||||
|
* Weitere Schlüssel machen sie nicht kleiner - siehe SKODA_BEFEHLE. */
|
||||||
|
const SKODA_TAKT = 260;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Schlüssel, die skoda.conf kennen darf, in Anzeigereihenfolge.
|
||||||
|
*
|
||||||
|
* Als Funktion und nicht als Konstante, weil jeder Eintrag seinen Satz
|
||||||
|
* Erklärung mitbringt - die steht in der Maske und soll nicht dort noch
|
||||||
|
* einmal getippt werden.
|
||||||
|
*/
|
||||||
|
function skodaFelder()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
"feld" => "API_KEY",
|
||||||
|
"titel" => "Abruf",
|
||||||
|
"pflicht" => true,
|
||||||
|
"zweck" => "Holt den Fahrzeugzustand. Ohne ihn bleibt die "
|
||||||
|
. "Fahrzeugseite leer.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"feld" => "API_KEY2",
|
||||||
|
"titel" => "Abruf, zweiter Schlüssel",
|
||||||
|
"pflicht" => false,
|
||||||
|
"zweck" => "Freiwillig, und kein dichterer Abruf: das Kontingent "
|
||||||
|
. "gilt gemessen fürs ganze Konto. Was er bringt, ist "
|
||||||
|
. "Ausfallsicherheit – läuft einer ab oder wird er "
|
||||||
|
. "widerrufen, fragt der Abruf mit dem nächsten weiter.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"feld" => "API_KEY3",
|
||||||
|
"titel" => "Abruf, dritter Schlüssel",
|
||||||
|
"pflicht" => false,
|
||||||
|
"zweck" => "Wie der zweite. Mehr als drei liest die "
|
||||||
|
. "Konfiguration nicht.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"feld" => "CMD_API_KEY",
|
||||||
|
"titel" => "Befehle",
|
||||||
|
"pflicht" => false,
|
||||||
|
"zweck" => "Für Laden, Klimatisierung und Lüftung. Ohne ihn "
|
||||||
|
. "schickt die Weboberfläche ihre Befehle mit dem "
|
||||||
|
. "ersten Schlüssel. Ein eigener lässt sich einzeln "
|
||||||
|
. "widerrufen, ohne den Abruf mitzunehmen.",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- Lesen -- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* skoda.conf als Zuweisungen lesen. Format: NAME=wert, eine je Zeile.
|
||||||
|
*
|
||||||
|
* Liefert null, wenn die Datei fehlt oder nicht lesbar ist - das ist der
|
||||||
|
* normale Zustand einer Installation ohne Fahrzeug und kein Fehler.
|
||||||
|
*/
|
||||||
|
function skodaKonfigLesen()
|
||||||
|
{
|
||||||
|
if (!is_readable(SKODA_KONFIG)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$zeilen = file(SKODA_KONFIG, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
if ($zeilen === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$werte = [];
|
||||||
|
foreach ($zeilen 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), "\"'");
|
||||||
|
}
|
||||||
|
return $werte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welcher Schlüssel die Befehle schickt und mit welcher VIN.
|
||||||
|
*
|
||||||
|
* Der eigene, wenn es ihn gibt - sonst der des Abrufs. Liefert null, solange
|
||||||
|
* nichts hinterlegt ist.
|
||||||
|
*/
|
||||||
|
function skodaBefehlsSchluessel()
|
||||||
|
{
|
||||||
|
$werte = skodaKonfigLesen();
|
||||||
|
if (!$werte || empty($werte["API_KEY"]) || empty($werte["VIN"])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$eigener = !empty($werte["CMD_API_KEY"]);
|
||||||
|
return [
|
||||||
|
"vin" => $werte["VIN"],
|
||||||
|
"schluessel" => $eigener ? $werte["CMD_API_KEY"] : $werte["API_KEY"],
|
||||||
|
"eigener" => $eigener,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die letzten vier Zeichen, mehr darf die Maske nicht wissen. */
|
||||||
|
function skodaMaske($wert)
|
||||||
|
{
|
||||||
|
$wert = (string)$wert;
|
||||||
|
return strlen($wert) > 4 ? "…" . substr($wert, -4) : "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wann der erste Schlüssel abläuft, laut der letzten Antwort des Fahrzeugs.
|
||||||
|
*
|
||||||
|
* gatherSkodaData.py schreibt bei jedem Abruf mit, was der Header
|
||||||
|
* X-API-Key-Expires-At sagt - bei mehreren Schlüsseln das früheste Datum.
|
||||||
|
* Genauer geht es von hier aus nicht, ohne die API zu fragen, und dafür ist
|
||||||
|
* die Auskunft zu unwichtig.
|
||||||
|
*/
|
||||||
|
function skodaAblaufAusHistorie()
|
||||||
|
{
|
||||||
|
$datum = null;
|
||||||
|
try {
|
||||||
|
$mysql = new mysqli($GLOBALS["mysql_server"], $GLOBALS["mysql_solarUser"],
|
||||||
|
$GLOBALS["mysql_solarPass"], $GLOBALS["mysql_solarDB"]);
|
||||||
|
if ($mysql->connect_errno) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$res = $mysql->query("SELECT key_expires FROM skoda "
|
||||||
|
. "WHERE key_expires IS NOT NULL ORDER BY id DESC LIMIT 1;");
|
||||||
|
if ($res && ($zeile = $res->fetch_assoc())) {
|
||||||
|
$datum = $zeile["key_expires"];
|
||||||
|
}
|
||||||
|
$mysql->close();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return null; // ohne Historie eben ohne Datum
|
||||||
|
}
|
||||||
|
return $datum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Stand für die Maske: was hinterlegt ist, was daraus folgt.
|
||||||
|
*
|
||||||
|
* Ohne Schlüsselwerte - siehe den Kopfkommentar.
|
||||||
|
*/
|
||||||
|
function skodaStand()
|
||||||
|
{
|
||||||
|
$werte = skodaKonfigLesen();
|
||||||
|
$da = is_array($werte);
|
||||||
|
|
||||||
|
$felder = [];
|
||||||
|
$abruf = 0;
|
||||||
|
foreach (skodaFelder() as $f) {
|
||||||
|
$wert = $da && !empty($werte[$f["feld"]]) ? $werte[$f["feld"]] : "";
|
||||||
|
$f["gesetzt"] = $wert !== "";
|
||||||
|
$f["maske"] = $wert !== "" ? skodaMaske($wert) : "";
|
||||||
|
$felder[] = $f;
|
||||||
|
if ($wert !== "" && $f["feld"] !== "CMD_API_KEY") {
|
||||||
|
$abruf++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"datei" => SKODA_KONFIG,
|
||||||
|
"da" => $da,
|
||||||
|
"schreibbar" => $da && is_writable(SKODA_KONFIG),
|
||||||
|
"vin" => $da && !empty($werte["VIN"]) ? skodaMaske($werte["VIN"]) : "",
|
||||||
|
"felder" => $felder,
|
||||||
|
"abruf" => $abruf,
|
||||||
|
// Was gilt - die Zahlen, nach denen man sonst im Quelltext sucht.
|
||||||
|
"takt" => SKODA_TAKT,
|
||||||
|
"befehle" => SKODA_BEFEHLE,
|
||||||
|
"ablauf" => $da ? skodaAblaufAusHistorie() : null,
|
||||||
|
"link" => SKODA_APP_LINK,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Schreiben -- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft einen Schlüssel, indem einmal das Fahrzeug abgefragt wird.
|
||||||
|
*
|
||||||
|
* Lieber eine Anfrage jetzt als ein Tippfehler, der erst Minuten später als
|
||||||
|
* Zeile im Log auffällt. Sie kostet eine der 20 Anfragen, die der neue
|
||||||
|
* Schlüssel in seiner ersten Stunde hat - die er ohnehin frisch mitbringt.
|
||||||
|
*
|
||||||
|
* Zurück kommt, was die API sagt, und ihr Ablaufdatum: [ok, status, ablauf,
|
||||||
|
* text].
|
||||||
|
*/
|
||||||
|
function skodaSchluesselPruefen($schluessel, $vin)
|
||||||
|
{
|
||||||
|
$ch = curl_init(SKODA_API . rawurlencode($vin));
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HEADER => true,
|
||||||
|
CURLOPT_USERAGENT => SKODA_KENNUNG,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"X-API-Key: " . $schluessel,
|
||||||
|
"Accept: application/json",
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$inhalt = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
$kopfEnde = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($inhalt === false) {
|
||||||
|
return ["ok" => false, "status" => 0, "ablauf" => null,
|
||||||
|
"text" => "Die Škoda-API ist nicht erreichbar."];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ablauf = null;
|
||||||
|
foreach (explode("\n", substr($inhalt, 0, $kopfEnde)) as $zeile) {
|
||||||
|
if (stripos($zeile, "X-API-Key-Expires-At:") === 0) {
|
||||||
|
$ablauf = trim(substr($zeile, strlen("X-API-Key-Expires-At:")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($status) {
|
||||||
|
case 200:
|
||||||
|
return ["ok" => true, "status" => 200, "ablauf" => $ablauf, "text" => ""];
|
||||||
|
case 401:
|
||||||
|
return ["ok" => false, "status" => 401, "ablauf" => null,
|
||||||
|
"text" => "Die API kennt diesen Schlüssel nicht - abgelaufen "
|
||||||
|
. "oder vertippt."];
|
||||||
|
case 403:
|
||||||
|
return ["ok" => false, "status" => 403, "ablauf" => null,
|
||||||
|
"text" => "Der Schlüssel gilt nicht für dieses Fahrzeug. In "
|
||||||
|
. "der MyŠkoda-App muss es beim Anlegen ausgewählt sein."];
|
||||||
|
case 404:
|
||||||
|
return ["ok" => false, "status" => 404, "ablauf" => null,
|
||||||
|
"text" => "Fahrzeug nicht gefunden - stimmt die VIN in "
|
||||||
|
. SKODA_KONFIG . "?"];
|
||||||
|
case 429:
|
||||||
|
// Kein Urteil über den Schlüssel: er ist in Ordnung, nur gerade
|
||||||
|
// ausgeschöpft. Das darf den Tausch nicht verhindern.
|
||||||
|
return ["ok" => true, "status" => 429, "ablauf" => null,
|
||||||
|
"text" => "Kontingent gerade erschöpft - nicht geprüft, "
|
||||||
|
. "aber übernommen."];
|
||||||
|
default:
|
||||||
|
return ["ok" => true, "status" => $status, "ablauf" => $ablauf,
|
||||||
|
"text" => "Unerwartete Antwort (HTTP " . $status . ") - nicht "
|
||||||
|
. "geprüft, aber übernommen."];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Schlüssel, wie er in einer Zeile NAME=wert stehen darf.
|
||||||
|
*
|
||||||
|
* Entscheidend ist, dass kein Zeilenumbruch hineinkommt: mit einem ließen sich
|
||||||
|
* weitere Zuweisungen unterschieben, etwa eine fremde VIN. Sonst wird bewusst
|
||||||
|
* wenig verlangt - welche Form Škoda seinen Schlüsseln morgen gibt, steht
|
||||||
|
* nicht in unserer Hand.
|
||||||
|
*/
|
||||||
|
function skodaWertPruefen($feld, $wert)
|
||||||
|
{
|
||||||
|
$wert = trim((string)$wert);
|
||||||
|
if ($wert === "") {
|
||||||
|
throw new InvalidArgumentException($feld . ": kein Wert angegeben.");
|
||||||
|
}
|
||||||
|
if (preg_match('/\s/', $wert)) {
|
||||||
|
throw new InvalidArgumentException($feld . ": ein Schlüssel enthält keine "
|
||||||
|
. "Leerzeichen oder Zeilenumbrüche - versehentlich zu viel kopiert?");
|
||||||
|
}
|
||||||
|
if (!preg_match('/^[\x21-\x7e]{8,256}$/', $wert)) {
|
||||||
|
throw new InvalidArgumentException($feld . ": das sieht nicht nach einem "
|
||||||
|
. "Schlüssel aus (8 bis 256 Zeichen, keine Sonderzeichen).");
|
||||||
|
}
|
||||||
|
return $wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* skoda.conf neu schreiben.
|
||||||
|
*
|
||||||
|
* $aenderungen: Feld => neuer Wert, oder Feld => null zum Entfernen.
|
||||||
|
*
|
||||||
|
* Die Datei bleibt, wie sie ist: jede Zeile, die nicht gemeint ist, geht
|
||||||
|
* unverändert durch - Kommentare, VIN, DB_PASSWORD, Leerzeilen. Ein Feld, das
|
||||||
|
* noch nicht vorkommt, wird angehängt.
|
||||||
|
*
|
||||||
|
* Geschrieben wird daneben und dann umbenannt. Das Umbenennen ist der einzige
|
||||||
|
* Augenblick, in dem sich die Datei ändert; wer sie gerade liest - der Manager
|
||||||
|
* alle paar Minuten, skodaCmd.php bei jedem Befehl - sieht entweder den alten
|
||||||
|
* oder den neuen Stand, nie einen halben.
|
||||||
|
*/
|
||||||
|
function skodaKonfigSchreiben(array $aenderungen)
|
||||||
|
{
|
||||||
|
$roh = @file(SKODA_KONFIG);
|
||||||
|
if ($roh === false) {
|
||||||
|
throw new RuntimeException("Die Datei " . SKODA_KONFIG . " ist nicht lesbar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$offen = $aenderungen; // was noch nicht untergebracht ist
|
||||||
|
$neu = [];
|
||||||
|
foreach ($roh as $zeile) {
|
||||||
|
$blank = trim($zeile);
|
||||||
|
$name = "";
|
||||||
|
if ($blank !== "" && $blank[0] !== "#" && strpos($blank, "=") !== false) {
|
||||||
|
$name = strtoupper(trim(explode("=", $blank, 2)[0]));
|
||||||
|
}
|
||||||
|
if ($name === "" || !array_key_exists($name, $offen)) {
|
||||||
|
$neu[] = rtrim($zeile, "\r\n");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($offen[$name] !== null) {
|
||||||
|
$neu[] = $name . "=" . $offen[$name];
|
||||||
|
} // null: Zeile fällt weg
|
||||||
|
unset($offen[$name]);
|
||||||
|
}
|
||||||
|
foreach ($offen as $name => $wert) {
|
||||||
|
if ($wert !== null) {
|
||||||
|
$neu[] = $name . "=" . $wert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rechte der alten Datei übernehmen: der Manager läuft unter einem anderen
|
||||||
|
// Benutzer und muss sie weiter lesen können.
|
||||||
|
$rechte = @fileperms(SKODA_KONFIG);
|
||||||
|
$rechte = $rechte ? ($rechte & 0777) : 0666;
|
||||||
|
|
||||||
|
$vorlaeufig = SKODA_KONFIG . ".neu." . getmypid();
|
||||||
|
if (@file_put_contents($vorlaeufig, implode("\n", $neu) . "\n") === false) {
|
||||||
|
throw new RuntimeException("Konnte neben " . SKODA_KONFIG . " nicht "
|
||||||
|
. "schreiben - fehlen dem Webserver die Rechte auf das Verzeichnis?");
|
||||||
|
}
|
||||||
|
@chmod($vorlaeufig, $rechte);
|
||||||
|
if (!@rename($vorlaeufig, SKODA_KONFIG)) {
|
||||||
|
@unlink($vorlaeufig);
|
||||||
|
throw new RuntimeException("Konnte " . SKODA_KONFIG . " nicht ersetzen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neue Schlüssel prüfen und speichern.
|
||||||
|
*
|
||||||
|
* $neu: Feld => Wert. Leere Werte bedeuten "unverändert" und fallen hier
|
||||||
|
* schon weg - ein leeres Feld in der Maske soll nichts löschen.
|
||||||
|
* $loeschen: Felder, die verschwinden sollen.
|
||||||
|
*
|
||||||
|
* Erst prüfen, dann schreiben: ein Schlüssel, den die API ablehnt, kommt gar
|
||||||
|
* nicht erst in die Datei. Sonst stünde der Abruf still, bis es jemand im Log
|
||||||
|
* bemerkt.
|
||||||
|
*/
|
||||||
|
function skodaSchluesselSpeichern(array $neu, array $loeschen = [])
|
||||||
|
{
|
||||||
|
$stand = skodaStand();
|
||||||
|
if (!$stand["da"]) {
|
||||||
|
throw new RuntimeException("Die Datei " . SKODA_KONFIG . " gibt es nicht. "
|
||||||
|
. "Ohne sie läuft auch der Abruf nicht - sie gehört zum SolarManager.");
|
||||||
|
}
|
||||||
|
$erlaubt = array_column(skodaFelder(), "feld");
|
||||||
|
$werte = skodaKonfigLesen();
|
||||||
|
$vin = $werte["VIN"] ?? "";
|
||||||
|
if ($vin === "") {
|
||||||
|
throw new RuntimeException("In " . SKODA_KONFIG . " steht keine VIN - "
|
||||||
|
. "ohne sie lässt sich kein Schlüssel prüfen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$aenderungen = [];
|
||||||
|
foreach ($neu as $feld => $wert) {
|
||||||
|
$feld = strtoupper((string)$feld);
|
||||||
|
if (!in_array($feld, $erlaubt, true)) {
|
||||||
|
throw new InvalidArgumentException("Unbekanntes Feld: " . $feld);
|
||||||
|
}
|
||||||
|
if (trim((string)$wert) === "") {
|
||||||
|
continue; // leer heißt: so lassen
|
||||||
|
}
|
||||||
|
$aenderungen[$feld] = skodaWertPruefen($feld, $wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Derselbe Schlüssel zweimal bringt kein zweites Kontingent - die API
|
||||||
|
* zählt den Schlüssel, nicht die Zeile. gatherSkodaData.py wirft ein
|
||||||
|
* Duplikat beim Lesen weg; hier fiele es nur als "zwei Schlüssel" auf,
|
||||||
|
* die den Abruf dann doch nicht beschleunigen.
|
||||||
|
*/
|
||||||
|
foreach ($aenderungen as $feld => $wert) {
|
||||||
|
if ($wert === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($erlaubt as $anderes) {
|
||||||
|
if ($anderes === $feld) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$schon = array_key_exists($anderes, $aenderungen)
|
||||||
|
? $aenderungen[$anderes] : ($werte[$anderes] ?? "");
|
||||||
|
if ($schon !== null && $schon !== "" && hash_equals((string)$schon, $wert)) {
|
||||||
|
throw new InvalidArgumentException($feld . ": dieser Schlüssel steht "
|
||||||
|
. "schon als " . $anderes . " da. Ein zweites Mal derselbe bringt "
|
||||||
|
. "kein zweites Kontingent - in der MyŠkoda-App einen neuen anlegen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($loeschen as $feld) {
|
||||||
|
$feld = strtoupper((string)$feld);
|
||||||
|
if (!in_array($feld, $erlaubt, true)) {
|
||||||
|
throw new InvalidArgumentException("Unbekanntes Feld: " . $feld);
|
||||||
|
}
|
||||||
|
if ($feld === "API_KEY") {
|
||||||
|
throw new InvalidArgumentException("Ohne den ersten Schlüssel gibt es "
|
||||||
|
. "keinen Abruf mehr. Er lässt sich ersetzen, aber nicht entfernen.");
|
||||||
|
}
|
||||||
|
$aenderungen[$feld] = null;
|
||||||
|
}
|
||||||
|
if (!$aenderungen) {
|
||||||
|
throw new InvalidArgumentException("Nichts einzutragen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ab hier wird das Netz befragt, und das kann dauern. helper.php hat die
|
||||||
|
* Sitzung geöffnet und hält sie gesperrt, solange diese Anfrage läuft -
|
||||||
|
* ohne das hier stünde der ganze Browser so lange still, auch die Kacheln
|
||||||
|
* in einem anderen Reiter.
|
||||||
|
*/
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_write_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$meldungen = [];
|
||||||
|
foreach ($aenderungen as $feld => $wert) {
|
||||||
|
if ($wert === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$urteil = skodaSchluesselPruefen($wert, $vin);
|
||||||
|
if (!$urteil["ok"]) {
|
||||||
|
throw new InvalidArgumentException($feld . ": " . $urteil["text"]);
|
||||||
|
}
|
||||||
|
if ($urteil["text"] !== "") {
|
||||||
|
$meldungen[] = $feld . ": " . $urteil["text"];
|
||||||
|
} elseif ($urteil["ablauf"]) {
|
||||||
|
$meldungen[] = $feld . " angenommen, gültig bis "
|
||||||
|
. skodaDatumKurz($urteil["ablauf"]) . ".";
|
||||||
|
} else {
|
||||||
|
$meldungen[] = $feld . " angenommen.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skodaKonfigSchreiben($aenderungen);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der Manager liest skoda.conf von selbst neu, sobald sie sich geändert
|
||||||
|
* hat - aber erst, wenn der nächste Abruf ohnehin ansteht, und beim Parken
|
||||||
|
* sind das 20 Minuten. Derselbe Anstoß wie nach einem Befehl holt ihn auf
|
||||||
|
* eine halbe Minute heran; dann greift ein neuer Schlüssel sofort.
|
||||||
|
*/
|
||||||
|
skodaAbrufAnstossen();
|
||||||
|
|
||||||
|
return ["meldungen" => $meldungen, "stand" => skodaStand()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Beiwerk - */
|
||||||
|
|
||||||
|
/** Ein API-Datum kurz und deutsch. Unlesbares bleibt, wie es ist. */
|
||||||
|
function skodaDatumKurz($iso)
|
||||||
|
{
|
||||||
|
$zeit = strtotime((string)$iso);
|
||||||
|
return $zeit ? date("d.m.Y", $zeit) : (string)$iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dem Abruf im solarManager Bescheid geben, dass sich gleich etwas ändert.
|
||||||
|
*
|
||||||
|
* Berührt wird eine leere Datei; gatherSkodaData.py sieht bei jedem seiner
|
||||||
|
* Durchläufe auf ihre Änderungszeit und holt den nächsten Abruf auf
|
||||||
|
* _NACH_BEFEHL Sekunden danach vor. Ein stat alle drei Sekunden ist billiger
|
||||||
|
* als eine Verbindung, die dafür offen bleiben müsste, und es geht auch dann
|
||||||
|
* nichts verloren, wenn einer der beiden Prozesse gerade neu startet.
|
||||||
|
*
|
||||||
|
* Kein Fehler, wenn es nicht klappt: der Befehl ist beim Fahrzeug, nur die
|
||||||
|
* Anzeige zieht dann später nach.
|
||||||
|
*/
|
||||||
|
function skodaAbrufAnstossen()
|
||||||
|
{
|
||||||
|
foreach (SKODA_ARBEITSORTE as $ort) {
|
||||||
|
if (is_dir($ort) && is_writable($ort)) {
|
||||||
|
@touch($ort . "/" . SKODA_ANSTOSS_DATEI);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- Verlauf der Wallboxen waehrend einer Ladung.
|
||||||
|
--
|
||||||
|
-- Das Fahrzeug meldet seinen Stand beim Laden an der Wallbox mitunter nur
|
||||||
|
-- einmal in der Stunde (Tabelle skoda). Fuer die Summe einer Ladung genuegt
|
||||||
|
-- das - Energie vom Wallbox-Zaehler, Ladestand von der Zeile davor und danach
|
||||||
|
-- -, fuer den Verlauf nicht: am 13.09.2026 meldete es um 10:29 noch 5,7 kW und
|
||||||
|
-- um 11:22 dann 7,7 kW, waehrend die Wallbox seit 10:54 mit 11 kW lief.
|
||||||
|
--
|
||||||
|
-- Die Wallboxen dagegen stehen alle drei Sekunden zur Verfuegung. In EnergyFlow
|
||||||
|
-- landen sie als Fuenf-Minuten-Werte, aber EnergyFlow wird nach zwoelf Monaten
|
||||||
|
-- ausgeduennt (solarlog-rollup.sh thin) - eine Ladekurve vom letzten Sommer
|
||||||
|
-- liesse sich dann nicht mehr zeichnen. Deshalb haelt gatherSkodaData.py die
|
||||||
|
-- Werte hier fest, solange eine Wallbox Strom liefert, und diese Tabelle wird
|
||||||
|
-- nicht ausgeduennt. Bei 60 Sekunden Abstand sind das rund 180 Zeilen je
|
||||||
|
-- Ladung, im Jahr grob 25.000.
|
||||||
|
--
|
||||||
|
-- Der Gesamtzaehler steht in jeder Zeile mit. Zwischen zwei Meldungen des
|
||||||
|
-- Fahrzeugs laesst sich der Ladestand damit nach der geflossenen Energie
|
||||||
|
-- auffuellen, ohne Akkukapazitaet oder Wirkungsgrad zu kennen:
|
||||||
|
--
|
||||||
|
-- soc(t) = soc_a + (soc_b - soc_a) * (wh(t) - wh_a) / (wh_b - wh_a)
|
||||||
|
--
|
||||||
|
-- Beide Wallboxen werden festgehalten, auch wenn die Auswertung heute nur die
|
||||||
|
-- im Carport kennt: laedt das Fahrzeug einmal im Obergeschoss, soll der
|
||||||
|
-- Verlauf nicht fehlen.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `skoda_ladepunkte` (
|
||||||
|
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`datetime` datetime NOT NULL COMMENT 'Ortszeit',
|
||||||
|
`wallbox` enum('carport','og') NOT NULL COMMENT 'carport = Wattpilot, og = go-e',
|
||||||
|
`kw` decimal(6,3) NOT NULL COMMENT 'Ladeleistung laut Wallbox; 0 = Ende der Ladung',
|
||||||
|
`wh_total` bigint(20) unsigned DEFAULT NULL COMMENT 'Gesamtzaehler der Wallbox; NULL = nicht gelesen',
|
||||||
|
`pv_kw` decimal(7,3) DEFAULT NULL COMMENT 'PV-Leistung im selben Augenblick',
|
||||||
|
`grid_kw` decimal(7,3) DEFAULT NULL COMMENT 'Netz, positiv = Bezug',
|
||||||
|
`quelle` enum('manager','energyflow') NOT NULL DEFAULT 'manager'
|
||||||
|
COMMENT 'energyflow = nachgetragen aus Fuenf-Minuten-Werten, wh_total dann gerechnet',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `datetime` (`datetime`),
|
||||||
|
KEY `wallbox` (`wallbox`, `datetime`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||||
Reference in New Issue
Block a user