Compare commits
7
Commits
22ecc1b50f
...
060336c891
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
060336c891 | ||
|
|
cd398bc800 | ||
|
|
eb1aa8c760 | ||
|
|
07bca5104e | ||
|
|
1b58579c22 | ||
|
|
cca452806f | ||
|
|
0cb0d4c3ae |
@@ -44,6 +44,11 @@ logs/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Der Vorschaubild-Ordner der Synology. Sie legt ihn neben jedem Bild an,
|
||||||
|
# auch in assets/img - das sind ihre Miniaturen, kein Inhalt des Projekts.
|
||||||
|
@eaDir/
|
||||||
|
@SynoResource
|
||||||
|
|
||||||
# Temporäre Dateien
|
# Temporäre Dateien
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
|
|||||||
@@ -9,11 +9,9 @@
|
|||||||
* GET ?action=editor&id=N Editor fuer eine vorhandene Automatik
|
* GET ?action=editor&id=N Editor fuer eine vorhandene Automatik
|
||||||
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik
|
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik
|
||||||
* GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen"
|
* GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen"
|
||||||
* GET ?action=rooms Zuordnung Geraet -> Raum zum Bearbeiten
|
|
||||||
* POST ?action=save JSON-Rumpf, legt an oder ueberschreibt
|
* POST ?action=save JSON-Rumpf, legt an oder ueberschreibt
|
||||||
* POST ?action=delete {"id": N}
|
* POST ?action=delete {"id": N}
|
||||||
* POST ?action=toggle {"id": N} - pausieren / fortsetzen
|
* POST ?action=toggle {"id": N} - pausieren / fortsetzen
|
||||||
* POST ?action=saveRooms {"<actor_id>": "OG/Bad", ...}
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once("../helper.php");
|
require_once("../helper.php");
|
||||||
@@ -51,8 +49,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
|||||||
jsonAntwort(["ok" => true]);
|
jsonAntwort(["ok" => true]);
|
||||||
case "toggle":
|
case "toggle":
|
||||||
jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]);
|
jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]);
|
||||||
case "saveRooms":
|
|
||||||
jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]);
|
|
||||||
default:
|
default:
|
||||||
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||||
}
|
}
|
||||||
@@ -130,73 +126,6 @@ if ($action === "list") {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Geraete den Raeumen zuordnen ---------------------------------------
|
|
||||||
if ($action === "rooms") {
|
|
||||||
$labels = geraeteBeschriftungen();
|
|
||||||
$raeume = geraeteRaeume();
|
|
||||||
$vorschlag = raumVorschlaege();
|
|
||||||
$auswahl = raumListe();
|
|
||||||
|
|
||||||
$res = meshDb()->query("SELECT id, type FROM actors");
|
|
||||||
$geraete = [];
|
|
||||||
while ($row = $res->fetch_assoc()) {
|
|
||||||
$id = intval($row["id"]);
|
|
||||||
$geraete[] = [
|
|
||||||
"id" => $id,
|
|
||||||
"label" => $labels[$id] ?? "?",
|
|
||||||
"type" => $row["type"],
|
|
||||||
"room" => $raeume[$id] ?? null,
|
|
||||||
"tipp" => $vorschlag[$id] ?? null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
// Unzugeordnete nach oben: das ist die Arbeit, die noch zu tun ist.
|
|
||||||
usort($geraete, function ($a, $b) {
|
|
||||||
$offen = (int)!empty($b["room"]) - (int)!empty($a["room"]);
|
|
||||||
return $offen !== 0 ? $offen : strcoll($a["label"], $b["label"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
$offen = count(array_filter($geraete, function ($g) { return empty($g["room"]); }));
|
|
||||||
$tipps = count(array_filter($geraete, function ($g) {
|
|
||||||
return empty($g["room"]) && !empty($g["tipp"]);
|
|
||||||
}));
|
|
||||||
|
|
||||||
echo "<div class='p-2'>";
|
|
||||||
echo "<p class='small text-body-secondary'>Die Zuordnung ordnet die Geräteliste im Editor "
|
|
||||||
. "nach Räumen statt nach Geräteart. Sie wird von Hand vergeben und bleibt bei "
|
|
||||||
. "einem neuen Geräte-Suchlauf erhalten.</p>";
|
|
||||||
if ($tipps) {
|
|
||||||
echo "<div class='alert alert-secondary py-2 small d-flex justify-content-between align-items-center'>"
|
|
||||||
. "<span>" . $tipps . " Gerät(e) verraten ihren Raum im MQTT-Topic.</span>"
|
|
||||||
. "<button type='button' class='btn btn-outline-secondary btn-sm' id='btnRoomTipps'>"
|
|
||||||
. "Vorschläge übernehmen</button></div>";
|
|
||||||
}
|
|
||||||
echo "<div class='table-responsive' style='max-height:60vh;overflow-y:auto'>";
|
|
||||||
echo "<table class='table table-sm table-hover align-middle'><thead><tr>"
|
|
||||||
. "<th>Gerät</th><th class='d-none d-sm-table-cell'>Art</th><th>Raum</th>"
|
|
||||||
. "</tr></thead><tbody>";
|
|
||||||
foreach ($geraete as $g) {
|
|
||||||
echo "<tr" . (empty($g["room"]) ? " class='table-active'" : "") . ">";
|
|
||||||
echo "<td>" . htmlspecialchars($g["label"]) . "</td>";
|
|
||||||
echo "<td class='d-none d-sm-table-cell small text-body-secondary'>"
|
|
||||||
. htmlspecialchars($g["type"]) . "</td>";
|
|
||||||
echo "<td><select class='form-select form-select-sm' data-actor='" . $g["id"] . "'"
|
|
||||||
. ($g["tipp"] ? " data-tipp='" . htmlspecialchars($g["tipp"], ENT_QUOTES) . "'" : "")
|
|
||||||
. ">";
|
|
||||||
echo "<option value=''" . (empty($g["room"]) ? " selected" : "") . ">–</option>";
|
|
||||||
foreach ($auswahl as $raum) {
|
|
||||||
echo "<option value='" . htmlspecialchars($raum["key"], ENT_QUOTES) . "'"
|
|
||||||
. ($g["room"] === $raum["key"] ? " selected" : "") . ">"
|
|
||||||
. htmlspecialchars($raum["label"]) . "</option>";
|
|
||||||
}
|
|
||||||
echo "</select></td></tr>";
|
|
||||||
}
|
|
||||||
echo "</tbody></table></div>";
|
|
||||||
echo "<p class='small text-body-secondary mb-0'>" . $offen . " von " . count($geraete)
|
|
||||||
. " Geräten noch ohne Raum.</p>";
|
|
||||||
echo "</div>";
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Editor --------------------------------------------------------------
|
// --- Editor --------------------------------------------------------------
|
||||||
$id = intval($_GET["id"] ?? 0);
|
$id = intval($_GET["id"] ?? 0);
|
||||||
$auto = $id > 0 ? loadAutomation($id) : null;
|
$auto = $id > 0 ? loadAutomation($id) : null;
|
||||||
|
|||||||
+332
-174
@@ -1,73 +1,177 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
require_once("../helper.php");
|
* Jahresstatistik der Anlage - alle Jahre, alle Zeitraeume, als JSON.
|
||||||
require_once("../restricted/costs.php");
|
|
||||||
if(!isset($_GET["year"])){
|
|
||||||
$_GET["year"] = date("Y");
|
|
||||||
}else{
|
|
||||||
$_GET["year"] = intval($_GET["year"]);
|
|
||||||
}
|
|
||||||
if($_GET["year"] < 2018 || $_GET["year"] > date("Y")){
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
if(isset($_GET["type"])){
|
|
||||||
if(strtolower($_GET["type"]) =="lastyear"){
|
|
||||||
$_GET["year"] = date("Y")-1;
|
|
||||||
}elseif(strtolower($_GET["type"]) =="prelastyear"){
|
|
||||||
$_GET["year"] = date("Y")-2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Die Jahresstatistik als eine Abfrage - jetzt auf stats_daily.
|
|
||||||
*
|
*
|
||||||
* Gerechnet wird nicht mehr ueber die Rohdaten aus EnergyFlow, sondern ueber
|
* Frueher lieferte diese Datei fertiges HTML: je Aufruf ein Jahr, 18 Kacheln,
|
||||||
* die Tageswerte. Zwei Gruende:
|
* Einheit und Richtung in zwei Arrays, die ueber den Spaltenindex mit der
|
||||||
|
* Abfrage synchron gehalten werden mussten. Wer eine Kennzahl einfuegte,
|
||||||
|
* musste an drei Stellen zaehlen.
|
||||||
|
*
|
||||||
|
* Jetzt kommt alles auf einmal und beschreibt sich selbst:
|
||||||
|
*
|
||||||
|
* kennzahlen Metadaten je Kennzahl - Name, Einheit, Nachkommastellen,
|
||||||
|
* Richtung, Gruppe, Einrueckung. Wer eine hinzufuegt, schreibt
|
||||||
|
* sie EINMAL in KENNZAHLEN und liefert ihren Wert in
|
||||||
|
* kennzahlenAus(); Anzeige und Reihenfolge ergeben sich daraus.
|
||||||
|
* werte drei Saetze: ganze Jahre, gleicher Zeitraum (1. Januar bis
|
||||||
|
* zum heutigen Stichtag) und die Summe ueber alle Jahre.
|
||||||
|
* monate Monatswerte je Jahr fuer die Verlaufsbalken.
|
||||||
|
*
|
||||||
|
* Warum drei Saetze und nicht einer: ein laufendes Jahr gegen ein volles zu
|
||||||
|
* stellen vergleicht Aepfel mit Birnen. Der Satz "gleich" schneidet jedes Jahr
|
||||||
|
* am selben Kalendertag ab und macht das laufende Jahr damit beurteilbar.
|
||||||
|
*
|
||||||
|
* Gerechnet wird auf stats_daily, den Tageswerten:
|
||||||
*
|
*
|
||||||
* 1. Die Rohdaten werden nach zwoelf Monaten ausgeduennt bzw. ins
|
* 1. Die Rohdaten werden nach zwoelf Monaten ausgeduennt bzw. ins
|
||||||
* Stundenarchiv verschoben (solarlog-rollup.sh). Eine Kachel, die auf
|
* Stundenarchiv verschoben (solarlog-rollup.sh). Wer auf EnergyFlow
|
||||||
* EnergyFlow rechnet, zeigt fuer alte Jahre danach zu wenig an.
|
* rechnet, zeigt fuer alte Jahre zu wenig an.
|
||||||
* 2. Ein Jahr sind 365 Zeilen statt rund 105.000.
|
* 2. Ein Jahr sind 365 Zeilen statt rund 105.000.
|
||||||
*
|
*
|
||||||
* Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt
|
* Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt LEAD()
|
||||||
* LEAD() beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie
|
* beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie zwei
|
||||||
* zwei Tarife haben - die Verdichtung auf Tage verliert hier also nichts.
|
* Tarife haben - die Verdichtung auf Tage verliert hier also nichts.
|
||||||
*
|
*
|
||||||
* Was in stats_daily vorberechnet sein MUSS, weil es eine Bedingung je
|
* Was in stats_daily vorberechnet sein MUSS, weil es eine Bedingung je
|
||||||
* Messwert auswertet und sich aus Tagessummen nicht rekonstruieren laesst:
|
* Messwert auswertet und sich aus Tagessummen nicht rekonstruieren laesst:
|
||||||
*
|
*
|
||||||
* eigenverbrauch_kwh pv - Einspeisung - Batterieladung
|
* eigenverbrauch_kwh pv - Einspeisung - Batterieladung - Heizstab
|
||||||
* - Heizstab + Batterieentladung. Dazu addiert
|
* + Batterieentladung. Dazu addiert wird
|
||||||
* wird pv_fehlbetrag_kwh: bei Ausfall einzelner
|
* pv_fehlbetrag_kwh: bei Ausfall einzelner
|
||||||
* Wechselrichter fehlt deren Beitrag in pv, alle
|
* Wechselrichter fehlt deren Beitrag in pv, alle
|
||||||
* Senken sind dagegen gemessen - die Differenz
|
* Senken sind dagegen gemessen - die Differenz
|
||||||
* faellt sonst zu klein oder negativ aus.
|
* faellt sonst zu klein oder negativ aus.
|
||||||
* heizstab_solar_kwh Heizstab, anteilig nach autonomy
|
* heizstab_solar_kwh Heizstab, anteilig nach autonomy
|
||||||
* auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom
|
* auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom Geraet
|
||||||
* Geraet in feiner Aufloesung gerechneten
|
* in feiner Aufloesung gerechneten Nicht-Netz-
|
||||||
* Nicht-Netz-Anteil der Zufuhr
|
* Anteil der Zufuhr
|
||||||
*
|
*
|
||||||
* Einheiten sind in stats_daily bereits bereinigt: PL*_EV und PL*_EVog
|
* Die Abfragen liefern nur SUMMEN. Jede Verhaeltniszahl - Autarkie, Quoten,
|
||||||
* liefern Kilowatt, alle uebrigen Leistungsspalten Watt. In der alten
|
* effektiver Preis - rechnet kennzahlenAus() daraus, und zwar auf jeder Ebene
|
||||||
* Abfrage stand das als /12 gegen /12000 nebeneinander.
|
* neu. Das ist keine Formsache: Durchschnitte darf man nicht addieren, ein
|
||||||
|
* Jahresmittel ist nicht das Mittel der Monatsmittel, und die Autarkie ueber
|
||||||
|
* acht Jahre schon gar nicht die Summe von acht Prozentzahlen.
|
||||||
*
|
*
|
||||||
* Die Solaranteile teilen jetzt durch NULLIF(...): ohne Ladung im Jahr kam
|
* Der Grundpreis faellt unabhaengig vom Verbrauch an und steckt deshalb in
|
||||||
* vorher 0/0 heraus.
|
* keiner Summe, sondern kommt aus grundpreisImZeitraum() dazu.
|
||||||
*
|
*
|
||||||
* "Ersparnis Batterie" rechnet den Nutzen netto: was die entladene Energie
|
* Achtung beim Lesen der Ertragsgruppe: Eigenverbrauch, Einspeisung und
|
||||||
* aus dem Netz gekostet haette, abzueglich der Verguetung, die fuer den zum
|
* Heizung TEILEN den Ertrag auf, sie ergeben ihn zusammen. Der Beitrag der
|
||||||
* Laden verwendeten Strom entgangen ist. Brutto waere die Kachel zu
|
* Batterie und die Solarladung des Autos stecken dagegen IM EIGENVERBRAUCH -
|
||||||
* freundlich - der Speicher ist nicht umsonst zu fuellen.
|
* der Strom, der aus der Batterie kommt oder ins Auto geht, ist ja Strom, der
|
||||||
|
* im Haus geblieben ist. Sie sind nach Herkunft bzw. Verwendung
|
||||||
|
* herausgegriffen und duerfen nicht dazuaddiert werden. Die Heizung ist davon
|
||||||
|
* unberuehrt: heizstab_solar_kwh ist aus eigenverbrauch_kwh herausgerechnet
|
||||||
|
* und gegen den Gaspreis bewertet, nicht gegen den Strompreis.
|
||||||
*
|
*
|
||||||
* Achtung beim Lesen: "Verbrauchsersparnis" ist der GESAMTE Eigenverbrauch
|
* Beim Beitrag der Batterie ist "darin" der Groesse nach zu lesen: gerechnet
|
||||||
* zum Strompreis. "Ersparnis Batterie" und "Ersparnis Solarladung" sind
|
* ist er netto, also abzueglich der Verguetung, die fuer den zum Laden
|
||||||
* Teilmengen davon, nach Herkunft bzw. Verwendung herausgegriffen. Sie
|
* verwendeten Strom entgangen ist. Brutto waere die Zeile zu freundlich - der
|
||||||
* duerfen nicht addiert werden.
|
* Speicher ist nicht umsonst zu fuellen.
|
||||||
*
|
|
||||||
* Der Grundpreis fehlt weiterhin bewusst - er haengt an den Tagen des
|
|
||||||
* Jahres, nicht an einem Messwert, und kommt aus grundpreisImJahr() dazu.
|
|
||||||
*/
|
*/
|
||||||
function statistikAbfrage($jahr)
|
|
||||||
|
require_once("../helper.php");
|
||||||
|
require_once("../restricted/costs.php");
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if (!checkLogin()) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(["error" => "Nicht angemeldet."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Kennzahlen --
|
||||||
|
*
|
||||||
|
* Die Anzeige-Beschreibung. Reihenfolge ist Anzeigereihenfolge.
|
||||||
|
*
|
||||||
|
* einheit wie sie an der Zeile steht
|
||||||
|
* dez Nachkommastellen. Jahressummen brauchen keine Cent.
|
||||||
|
* invers weniger ist besser (Verbrauch, Kosten)
|
||||||
|
* punkte Prozentwert; Veraenderungen sind Prozentpunkte, nicht Prozent
|
||||||
|
* summe Zeile, unter der sich Teilbetraege aufreihen
|
||||||
|
* stufe/teil eingerueckt: "davon" teilt die Summe darueber auf
|
||||||
|
* teilmenge "darin": steckt bereits in der genannten Zeile und darf nicht
|
||||||
|
* dazuaddiert werden. Der Wert ist deren id, damit die Anzeige
|
||||||
|
* sagen kann, WORIN es steckt - die blosse Reihenfolge liesse
|
||||||
|
* offen, ob die vorangehende oder die uebergeordnete Zeile
|
||||||
|
* gemeint ist.
|
||||||
|
* klapp Zeile mit Teilzeilen, zunaechst zusammengeklappt
|
||||||
|
* unter gehoert in die Klappgruppe dieser Zeile. Das ist etwas
|
||||||
|
* anderes als teilmenge: unter sagt, WOMIT eine Zeile
|
||||||
|
* auf- und zugeht, teilmenge, WORIN ihr Wert steckt.
|
||||||
|
* braucht Spalte in stats_daily, ohne die es die Kennzahl nicht gibt
|
||||||
|
*/
|
||||||
|
const KENNZAHLEN = [
|
||||||
|
["gruppe" => "Strom vom Netz"],
|
||||||
|
["id" => "bezug", "name" => "Netzbezug", "einheit" => "kWh", "dez" => 0, "invers" => true],
|
||||||
|
["id" => "kosten", "name" => "Netzkosten (mit Grundpreis)", "einheit" => "€", "dez" => 0, "invers" => true],
|
||||||
|
["id" => "preis", "name" => "Effektiver Strompreis", "einheit" => "ct/kWh", "dez" => 1, "invers" => true],
|
||||||
|
|
||||||
|
["gruppe" => "Was die Anlage einbringt"],
|
||||||
|
["id" => "ertrag", "name" => "Ertrag der Anlage", "einheit" => "€", "dez" => 0, "summe" => true, "klapp" => true],
|
||||||
|
["id" => "eigen", "name" => "Eigenverbrauch", "einheit" => "€", "dez" => 0, "teil" => true, "unter" => "ertrag"],
|
||||||
|
["id" => "batterie", "name" => "Beitrag der Batterie", "einheit" => "€", "dez" => 0, "teilmenge" => "eigen", "unter" => "ertrag"],
|
||||||
|
["id" => "solarauto", "name" => "Solarladung des Autos", "einheit" => "€", "dez" => 0, "teilmenge" => "eigen", "unter" => "ertrag"],
|
||||||
|
["id" => "einsp", "name" => "Einspeisung", "einheit" => "€", "dez" => 0, "teil" => true, "unter" => "ertrag"],
|
||||||
|
["id" => "heizung", "name" => "Heizung über den Heizstab", "einheit" => "€", "dez" => 0, "teil" => true, "unter" => "ertrag"],
|
||||||
|
["id" => "autarkie", "name" => "Autarkie", "einheit" => "%", "dez" => 1, "punkte" => true],
|
||||||
|
["id" => "quote", "name" => "Eigenverbrauchsquote", "einheit" => "%", "dez" => 1, "punkte" => true,
|
||||||
|
"braucht" => "pv_kwh"],
|
||||||
|
|
||||||
|
["gruppe" => "Auto"],
|
||||||
|
["id" => "geladen", "name" => "Geladen", "einheit" => "kWh", "dez" => 0, "summe" => true, "klapp" => true],
|
||||||
|
["id" => "ladenEG", "name" => "Wallbox EG", "einheit" => "kWh", "dez" => 0, "teil" => true, "unter" => "geladen"],
|
||||||
|
["id" => "ladenOG", "name" => "Wallbox OG", "einheit" => "kWh", "dez" => 0, "teil" => true, "unter" => "geladen"],
|
||||||
|
["id" => "solaranteil","name" => "Solaranteil der Ladung", "einheit" => "%", "dez" => 1, "punkte" => true],
|
||||||
|
["id" => "benzin", "name" => "Benzin gespart", "einheit" => "L", "dez" => 0],
|
||||||
|
["id" => "benzingeld", "name" => "Ersparnis gegenüber Benzin", "einheit" => "€", "dez" => 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
/* Die drei Kennzahlen, die oben gross stehen. */
|
||||||
|
const LEITZAHLEN = ["ertrag", "kosten", "autarkie"];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Summen --
|
||||||
|
*
|
||||||
|
* Alle Grundgroessen in einem Ausdruck. Was hier steht, ist addierbar - genau
|
||||||
|
* das ist die Bedingung dafuer, dass Monat, Jahr, Zeitraum und Gesamtsumme
|
||||||
|
* aus derselben Abfrage entstehen koennen.
|
||||||
|
*/
|
||||||
|
function summenSQL($hatPv)
|
||||||
|
{
|
||||||
|
return "
|
||||||
|
SUM(d.netz_bezug_kwh) AS bezug_kwh,
|
||||||
|
SUM(d.netz_bezug_kwh * preis.cost) AS bezug_eur,
|
||||||
|
SUM(d.netz_einsp_kwh) AS einsp_kwh,
|
||||||
|
SUM(d.netz_einsp_kwh * preis.gain) AS einsp_eur,
|
||||||
|
SUM((d.eigenverbrauch_kwh + IFNULL(d.pv_fehlbetrag_kwh,0)) * preis.cost) AS eigen_eur,
|
||||||
|
" . ($hatPv ? "SUM(d.pv_kwh + IFNULL(d.pv_fehlbetrag_kwh,0))" : "NULL") . " AS pv_kwh,
|
||||||
|
SUM(d.heizstab_solar_kwh * gas.cost / NULLIF(gas.kwhPerLitre,0)) AS heiz_eur,
|
||||||
|
SUM(d.batt_entladen_kwh * preis.cost - d.batt_laden_kwh * preis.gain) AS batt_eur,
|
||||||
|
SUM(d.autarkie_avg * d.stunden_erfasst) AS autarkie_gew,
|
||||||
|
SUM(d.stunden_erfasst) AS stunden,
|
||||||
|
SUM(d.auto_eg_kwh) AS eg_kwh,
|
||||||
|
SUM(d.auto_og_kwh) AS og_kwh,
|
||||||
|
SUM(d.auto_eg_solar_kwh) AS eg_solar_kwh,
|
||||||
|
SUM(d.auto_og_solar_kwh) AS og_solar_kwh,
|
||||||
|
SUM(d.auto_eg_solar_kwh * preis.cost) AS eg_solar_eur,
|
||||||
|
SUM(d.auto_og_solar_kwh * preis.cost) AS og_solar_eur,
|
||||||
|
SUM((d.auto_eg_kwh + d.auto_og_kwh) * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS benzin_l,
|
||||||
|
SUM((d.auto_eg_kwh + d.auto_og_kwh) * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)
|
||||||
|
* sprit.cost) AS benzin_eur";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der gemeinsame Rumpf: Tageswerte, an jedem Tag der damals gueltige Tarif. */
|
||||||
|
function vonTarifen()
|
||||||
|
{
|
||||||
|
return "
|
||||||
|
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)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mitTarifen()
|
||||||
{
|
{
|
||||||
return "WITH
|
return "WITH
|
||||||
preis AS (SELECT active_date, cost, gain,
|
preis AS (SELECT active_date, cost, gain,
|
||||||
@@ -76,136 +180,190 @@ function statistikAbfrage($jahr)
|
|||||||
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gasCosts),
|
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gasCosts),
|
||||||
sprit AS (SELECT active_date, cost, lPer100km, kwhPer100km,
|
sprit AS (SELECT active_date, cost, lPer100km, kwhPer100km,
|
||||||
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM fuelCosts)
|
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM fuelCosts)
|
||||||
SELECT
|
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) . ";";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$Query = statistikAbfrage($_GET["year"]);
|
/* --------------------------------------------------------------- Rechnung --
|
||||||
$PrevQuery = statistikAbfrage($_GET["year"] - 1);
|
*
|
||||||
|
* Aus Summen werden Kennzahlen. Diese Funktion ist die einzige Stelle, an der
|
||||||
|
* eine Kennzahl definiert ist - sie laeuft fuer jeden Monat, jedes Jahr, jeden
|
||||||
|
* Zeitraum und die Gesamtsumme mit denselben Regeln.
|
||||||
|
*
|
||||||
|
* null heisst "nicht bestimmbar" und nicht "null": ohne Bezug gibt es keinen
|
||||||
|
* effektiven Preis, ohne Ladung keinen Solaranteil. Die Anzeige zeigt darauf
|
||||||
|
* einen Strich statt einer 0.
|
||||||
|
*/
|
||||||
|
function kennzahlenAus($s, $grundpreis)
|
||||||
|
{
|
||||||
|
$teile = function ($zaehler, $nenner, $faktor = 1) {
|
||||||
|
return ($nenner === null || $nenner <= 0 || $zaehler === null)
|
||||||
|
? null : $zaehler / $nenner * $faktor;
|
||||||
|
};
|
||||||
|
|
||||||
// Reihenfolge wie in der Abfrage, mit "Verg.+Einsp. Strom" an Stelle 4.
|
$bezug = floatval($s["bezug_kwh"]);
|
||||||
$units = Array("kWh","€","€","€","€","€","€","%",
|
$kosten = floatval($s["bezug_eur"]) + $grundpreis;
|
||||||
"kWh","%","€","L","€",
|
$geladen = floatval($s["eg_kwh"]) + floatval($s["og_kwh"]);
|
||||||
"kWh","%","€","L","€");
|
$solar = floatval($s["eg_solar_kwh"]) + floatval($s["og_solar_kwh"]);
|
||||||
// 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 [
|
||||||
return array_slice($array, 0, $offset, true) + $values + array_slice($array, $offset, NULL, true);
|
"bezug" => $bezug,
|
||||||
|
"kosten" => $kosten,
|
||||||
|
"preis" => $teile($kosten, $bezug, 100),
|
||||||
|
"ertrag" => floatval($s["eigen_eur"]) + floatval($s["einsp_eur"]) + floatval($s["heiz_eur"]),
|
||||||
|
"eigen" => floatval($s["eigen_eur"]),
|
||||||
|
"einsp" => floatval($s["einsp_eur"]),
|
||||||
|
"heizung" => floatval($s["heiz_eur"]),
|
||||||
|
"batterie" => floatval($s["batt_eur"]),
|
||||||
|
"solarauto" => floatval($s["eg_solar_eur"]) + floatval($s["og_solar_eur"]),
|
||||||
|
"autarkie" => $teile($s["autarkie_gew"], $s["stunden"]),
|
||||||
|
// Eigenverbrauchsquote: der Anteil der Erzeugung, der im Haus blieb.
|
||||||
|
"quote" => $s["pv_kwh"] === null ? null
|
||||||
|
: $teile(floatval($s["pv_kwh"]) - floatval($s["einsp_kwh"]),
|
||||||
|
floatval($s["pv_kwh"]), 100),
|
||||||
|
"geladen" => $geladen,
|
||||||
|
"ladenEG" => floatval($s["eg_kwh"]),
|
||||||
|
"ladenOG" => floatval($s["og_kwh"]),
|
||||||
|
"solaranteil" => $teile($solar, $geladen, 100),
|
||||||
|
"benzin" => floatval($s["benzin_l"]),
|
||||||
|
"benzingeld" => floatval($s["benzin_eur"]),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Zwei Summensaetze addieren - fuer Jahre aus Monaten und die Gesamtsumme. */
|
||||||
if (checkLogin()) {
|
function summenPlus($a, $b)
|
||||||
|
{
|
||||||
$html = "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
|
if ($a === null) return $b;
|
||||||
$mysql = solarDb();
|
foreach ($b as $k => $v) {
|
||||||
$Res = mysqli_query($mysql,$Query);
|
if ($v === null) continue;
|
||||||
$ResPrev = mysqli_query($mysql,$PrevQuery);
|
$a[$k] = ($a[$k] === null ? 0 : $a[$k]) + $v;
|
||||||
if(!$Res || !$ResPrev){
|
|
||||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
|
||||||
}
|
|
||||||
if ($Res->num_rows > 0) {
|
|
||||||
$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-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'>
|
|
||||||
<div class='info-box'>
|
|
||||||
<div class='info-box-content'>
|
|
||||||
<span class='info-box-text'>".$name."</span>
|
|
||||||
<span class='info-box-number'>".
|
|
||||||
number_format(floatval($value),2,",",".")
|
|
||||||
."<small>".$units[$i++]."</small>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- /.info-box-content -->
|
|
||||||
</div>
|
|
||||||
</div>";*/
|
|
||||||
$html .= "<div class='col'>
|
|
||||||
<div class='card stats border-0'>
|
|
||||||
<div class='card-body bg-dark bg-gradient rounded-top pb-1 pt-1'>
|
|
||||||
".$name."
|
|
||||||
</div>
|
|
||||||
<div class='card-footer text-center'>";
|
|
||||||
if($_GET["year"] != date("Y")){
|
|
||||||
if($LessIsBetter[$i]){
|
|
||||||
if($value == 0)
|
|
||||||
$dev=100;
|
|
||||||
else
|
|
||||||
$dev = $rowPrev[$name]/$value;
|
|
||||||
//$first = round($rowPrev[$name]*10/$rowPrev[$name]); //allow for 10% deviation for displaying no tendency
|
|
||||||
//$second = round($value); //allow for 10% deviation for displaying no tendency
|
|
||||||
$arrowGood = "down";
|
|
||||||
$arrowBad = "up";
|
|
||||||
}else{
|
|
||||||
if($rowPrev[$name] == 0)
|
|
||||||
$dev=100;
|
|
||||||
else
|
|
||||||
$dev = $value/$rowPrev[$name];
|
|
||||||
//$first = round($value); //allow for 10% deviation for displaying no tendency
|
|
||||||
//$second = round($rowPrev[$name]); //allow for 10% deviation for displaying no tendency
|
|
||||||
$arrowGood = "up";
|
|
||||||
$arrowBad = "down";
|
|
||||||
}
|
|
||||||
if($dev > 1.05){
|
|
||||||
$html .= "<span class='float-right text-success-emphasis'>
|
|
||||||
<i class='bi bi-arrow-".$arrowGood."' style='font-size: 0.9em;'></i> ";
|
|
||||||
}elseif($dev < 0.95){
|
|
||||||
$html .= "<span class='float-right text-danger-emphasis'>
|
|
||||||
<i class='bi bi-arrow-".$arrowBad."' style='font-size: 0.9em;'></i> ";
|
|
||||||
}else{
|
|
||||||
$html .= "<span class='float-right'>
|
|
||||||
<i class='bi bi-arrow-right' style='font-size: 0.9em;'></i> ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
$html .= "<span class='float-right'>";
|
|
||||||
}
|
|
||||||
$html .= number_format(floatval($value),2,",",".")
|
|
||||||
."<small> ".$units[$i]."</small></span></div>
|
|
||||||
<!-- /.info-box-content -->
|
|
||||||
</div>
|
|
||||||
</div>";
|
|
||||||
$i++;
|
|
||||||
}
|
}
|
||||||
}
|
return $a;
|
||||||
$html .= "</div>";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Abruf --*/
|
||||||
|
|
||||||
//header('Content-Type: application/json');
|
$db = solarDb();
|
||||||
echo $html;
|
|
||||||
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
|
// Gibt es die Erzeugung als Tageswert? Ohne sie entfaellt die
|
||||||
|
// Eigenverbrauchsquote - lieber eine Kennzahl weniger als eine Abfrage, die
|
||||||
|
// an einer fehlenden Spalte scheitert.
|
||||||
|
$hatPv = false;
|
||||||
|
if ($res = $db->query("SHOW COLUMNS FROM stats_daily LIKE 'pv_kwh'")) {
|
||||||
|
$hatPv = $res->num_rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$heute = new DateTimeImmutable("today");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Monatswerte. Daraus entstehen die Verlaufsbalken, durch Addition die ganzen
|
||||||
|
* Jahre und durch nochmalige Addition die Gesamtsumme - eine Abfrage fuer
|
||||||
|
* drei Ebenen.
|
||||||
|
*/
|
||||||
|
$monate = [];
|
||||||
|
$sql = mitTarifen() . "YEAR(d.tag) AS jahr, MONTH(d.tag) AS monat, " . summenSQL($hatPv)
|
||||||
|
. vonTarifen() . " GROUP BY 1, 2 ORDER BY 1, 2";
|
||||||
|
$res = $db->query($sql);
|
||||||
|
if (!$res) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["error" => "Statistik nicht abrufbar: " . $db->error]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
while ($z = $res->fetch_assoc()) {
|
||||||
|
$jahr = intval($z["jahr"]);
|
||||||
|
$monat = intval($z["monat"]);
|
||||||
|
unset($z["jahr"], $z["monat"]); // ab hier sind nur noch Summen drin
|
||||||
|
$monate[$jahr][$monat] = $z;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Gleicher Zeitraum: jedes Jahr vom 1. Januar bis zum heutigen Kalendertag.
|
||||||
|
* Verglichen wird nach Monat und Tag statt nach DAYOFYEAR - der 7. September
|
||||||
|
* ist in einem Schaltjahr der 251., sonst der 250. Tag, und ein Vergleich
|
||||||
|
* ueber die Tagesnummer waere in Schaltjahren um einen Tag verschoben.
|
||||||
|
*/
|
||||||
|
$gleichSummen = [];
|
||||||
|
$sql = mitTarifen() . "YEAR(d.tag) AS jahr, " . summenSQL($hatPv) . vonTarifen()
|
||||||
|
. " WHERE (MONTH(d.tag) < MONTH(CURDATE())
|
||||||
|
OR (MONTH(d.tag) = MONTH(CURDATE()) AND DAY(d.tag) <= DAY(CURDATE())))
|
||||||
|
GROUP BY 1 ORDER BY 1";
|
||||||
|
$res = $db->query($sql);
|
||||||
|
while ($res && $z = $res->fetch_assoc()) {
|
||||||
|
$jahr = intval($z["jahr"]);
|
||||||
|
unset($z["jahr"]);
|
||||||
|
$gleichSummen[$jahr] = $z;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jahre = array_keys($monate);
|
||||||
|
sort($jahre);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------- Antwort aufbauen --*/
|
||||||
|
|
||||||
|
$antwort = [
|
||||||
|
"stichtag" => $heute->format("Y-m-d"),
|
||||||
|
"jahre" => array_values($jahre),
|
||||||
|
"kennzahlen" => array_values(array_filter(KENNZAHLEN, function ($k) use ($hatPv) {
|
||||||
|
return !isset($k["braucht"]) || ($k["braucht"] === "pv_kwh" && $hatPv);
|
||||||
|
})),
|
||||||
|
"leitzahlen" => LEITZAHLEN,
|
||||||
|
"voll" => [],
|
||||||
|
"gleich" => [],
|
||||||
|
"monate" => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$gesamtSummen = null;
|
||||||
|
$gesamtGrund = 0.0;
|
||||||
|
|
||||||
|
foreach ($jahre as $jahr) {
|
||||||
|
$jahresSummen = null;
|
||||||
|
$grundJahr = grundpreisImJahr($jahr);
|
||||||
|
|
||||||
|
// Monatswerte. Der Grundpreis des Jahres verteilt sich nach Tagen auf die
|
||||||
|
// Monate - fuer den Verlauf genau genug, und eine Abfrage je Monat waere
|
||||||
|
// hundert Abfragen fuer eine Balkenhoehe.
|
||||||
|
$tageImJahr = intval((new DateTimeImmutable($jahr . "-12-31"))->format("z")) + 1;
|
||||||
|
for ($m = 1; $m <= 12; $m++) {
|
||||||
|
if (!isset($monate[$jahr][$m])) continue;
|
||||||
|
$tageImMonat = intval((new DateTimeImmutable(sprintf("%d-%02d-01", $jahr, $m)))->format("t"));
|
||||||
|
$werte = kennzahlenAus($monate[$jahr][$m], $grundJahr * $tageImMonat / $tageImJahr);
|
||||||
|
foreach ($werte as $id => $wert) {
|
||||||
|
$antwort["monate"][$jahr][$id][$m - 1] = $wert;
|
||||||
|
}
|
||||||
|
$jahresSummen = summenPlus($jahresSummen, $monate[$jahr][$m]);
|
||||||
|
}
|
||||||
|
// Monate ohne Daten bleiben als Luecke stehen, damit der Verlauf zwoelf
|
||||||
|
// Plaetze hat und der Januar links liegt.
|
||||||
|
foreach (array_keys($antwort["monate"][$jahr] ?? []) as $id) {
|
||||||
|
for ($m = 0; $m < 12; $m++) {
|
||||||
|
if (!array_key_exists($m, $antwort["monate"][$jahr][$id])) {
|
||||||
|
$antwort["monate"][$jahr][$id][$m] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($antwort["monate"][$jahr][$id]);
|
||||||
|
$antwort["monate"][$jahr][$id] = array_values($antwort["monate"][$jahr][$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($jahresSummen !== null) {
|
||||||
|
$antwort["voll"][$jahr] = kennzahlenAus($jahresSummen, $grundJahr);
|
||||||
|
$gesamtSummen = summenPlus($gesamtSummen, $jahresSummen);
|
||||||
|
$gesamtGrund += $grundJahr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($gleichSummen[$jahr])) {
|
||||||
|
/*
|
||||||
|
* Der Stichtag im jeweiligen Jahr. Der Tag wird auf die Laenge des
|
||||||
|
* Monats begrenzt: am 29. Februar gibt es in den anderen Jahren
|
||||||
|
* keinen 29. Februar, und ein ungueltiges Datum rutschte sonst
|
||||||
|
* stillschweigend in den Maerz.
|
||||||
|
*/
|
||||||
|
$tag = min(intval($heute->format("d")),
|
||||||
|
intval((new DateTimeImmutable($jahr . "-" . $heute->format("m") . "-01"))->format("t")));
|
||||||
|
$bis = sprintf("%d-%s-%02d", $jahr, $heute->format("m"), $tag);
|
||||||
|
$antwort["gleich"][$jahr] = kennzahlenAus(
|
||||||
|
$gleichSummen[$jahr], grundpreisImZeitraum($jahr . "-01-01", $bis));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Summe ueber alles - dieselbe Rechnung, nur ueber alle Jahre.
|
||||||
|
$antwort["gesamt"] = $gesamtSummen === null ? null : kennzahlenAus($gesamtSummen, $gesamtGrund);
|
||||||
|
|
||||||
|
echo json_encode($antwort, JSON_UNESCAPED_UNICODE);
|
||||||
|
|||||||
+71
-2
@@ -4,14 +4,22 @@
|
|||||||
*
|
*
|
||||||
* GET ?action=list&reihe=strom Zeitraeume einer Preistabelle
|
* GET ?action=list&reihe=strom Zeitraeume einer Preistabelle
|
||||||
* POST ?action=save {"reihe": "strom", "zeilen": [ ... ]}
|
* POST ?action=save {"reihe": "strom", "zeilen": [ ... ]}
|
||||||
|
* GET ?action=kacheln Anzeigewerte aller Home-Kacheln
|
||||||
|
* POST ?action=kacheln-save {"floor": "OG", "room": "bad",
|
||||||
|
* "werte": [ ... ] oder null}
|
||||||
|
* GET ?action=geraete Geraete mit Raum, Verwendung und Alter
|
||||||
|
* POST ?action=geraete-raeume {"rooms": {"12": "OG/Bad", ...}}
|
||||||
|
* POST ?action=geraet-loeschen {"id": 12}
|
||||||
*
|
*
|
||||||
* Ein reiner Verteiler: geprueft und geschrieben wird in
|
* Ein reiner Verteiler: geprueft und geschrieben wird in
|
||||||
* restricted/costs.php, damit Maske und Endpunkt dieselbe Wahrheit ueber
|
* restricted/costs.php bzw. restricted/kacheln.php, damit Maske und Endpunkt
|
||||||
* einen Zeitraum haben.
|
* dieselbe Wahrheit ueber einen Zeitraum oder eine Kachel haben.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once("../helper.php");
|
require_once("../helper.php");
|
||||||
require_once("../restricted/costs.php");
|
require_once("../restricted/costs.php");
|
||||||
|
require_once("../restricted/kacheln.php");
|
||||||
|
require_once("../restricted/automations.php");
|
||||||
|
|
||||||
if (!checkLogin()) {
|
if (!checkLogin()) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
@@ -43,6 +51,41 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
|||||||
"gespeichert" => $anzahl,
|
"gespeichert" => $anzahl,
|
||||||
"zeilen" => preiseLaden($body["reihe"]),
|
"zeilen" => preiseLaden($body["reihe"]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
case "kacheln-save":
|
||||||
|
/*
|
||||||
|
* Fehlt "werte" oder steht dort null, faellt die Kachel auf
|
||||||
|
* ihre Vorgabe zurueck - dann kommt diese zurueck, damit die
|
||||||
|
* Maske zeigt, was jetzt gilt, statt zu leeren Zeilen.
|
||||||
|
*/
|
||||||
|
$floor = $body["floor"] ?? "";
|
||||||
|
$room = $body["room"] ?? "";
|
||||||
|
$werte = array_key_exists("werte", $body) ? $body["werte"] : null;
|
||||||
|
$gespeichert = kachelWerteSpeichern($floor, $room, $werte);
|
||||||
|
|
||||||
|
$vorgabe = [];
|
||||||
|
foreach (allRooms() as $r) {
|
||||||
|
if ($r["floor"] === $floor && $r["id"] === $room) {
|
||||||
|
$vorgabe = kachelVorgabe($r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonAntwort([
|
||||||
|
"eigen" => $gespeichert !== null,
|
||||||
|
"werte" => $gespeichert !== null ? $gespeichert : $vorgabe,
|
||||||
|
]);
|
||||||
|
case "geraete-raeume":
|
||||||
|
jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]);
|
||||||
|
|
||||||
|
case "geraet-loeschen":
|
||||||
|
/*
|
||||||
|
* Loeschen ist endgueltig - abgelehnt wird es, wenn eine
|
||||||
|
* Automatik auf das Geraet zeigt (siehe geraetLoeschen()).
|
||||||
|
* Zurueck kommt die neue Liste, damit die Maske nicht raten
|
||||||
|
* muss, was jetzt gilt.
|
||||||
|
*/
|
||||||
|
$name = geraetLoeschen($body["id"] ?? 0);
|
||||||
|
jsonAntwort(["geloescht" => $name, "geraete" => geraeteListe()]);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||||
}
|
}
|
||||||
@@ -53,6 +96,32 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($action === "kacheln") {
|
||||||
|
try {
|
||||||
|
jsonAntwort([
|
||||||
|
"kacheln" => kachelListe(),
|
||||||
|
// Alle Symbolnamen - die üblichen zuerst und mit Beschriftung.
|
||||||
|
"symbole" => kachelIconNamen(),
|
||||||
|
// Auswahlliste je Geraet - dieselbe Quelle wie im Automatik-Editor.
|
||||||
|
"katalog" => kachelKatalog(),
|
||||||
|
// Fuer die Eingabe von Hand, wenn etwas nicht im Katalog steht.
|
||||||
|
"topics" => kachelTopicVorschlaege(),
|
||||||
|
"tabelleDa" => kachelTabelleDa(),
|
||||||
|
"max" => KACHEL_MAX,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
jsonAntwort(["error" => "Kacheln nicht abrufbar: " . $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === "geraete") {
|
||||||
|
try {
|
||||||
|
jsonAntwort(["geraete" => geraeteListe(), "raeume" => raumListe()]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
jsonAntwort(["error" => "Geräte nicht abrufbar: " . $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($action === "list") {
|
if ($action === "list") {
|
||||||
try {
|
try {
|
||||||
jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]);
|
jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* ?was=kurve Verlauf einer einzelnen Ladung (SoC und kW über Zeit)
|
* ?was=kurve Verlauf einer einzelnen Ladung (SoC und kW über Zeit)
|
||||||
* ?was=gesundheit kWh je 100 Ladestandsprozent, je Ladung, mit Trend
|
* ?was=gesundheit kWh je 100 Ladestandsprozent, je Ladung, mit Trend
|
||||||
* ?was=fahrten Fahrten zwischen zwei Parkständen, mit Koordinaten
|
* ?was=fahrten Fahrten zwischen zwei Parkständen, mit Koordinaten
|
||||||
|
* ?was=strecke gefahrene Kilometer über Woche, Monat und Jahr
|
||||||
*
|
*
|
||||||
* Die Fahrzeugdaten stehen in der Tabelle skoda, geschrieben von
|
* Die Fahrzeugdaten stehen in der Tabelle skoda, geschrieben von
|
||||||
* gatherSkodaData.py. Der Zeilentakt ist nicht fest: geschrieben wird, wenn
|
* gatherSkodaData.py. Der Zeilentakt ist nicht fest: geschrieben wird, wenn
|
||||||
@@ -358,6 +359,52 @@ case "fahrten":
|
|||||||
$antwort["kapazitaet"] = kapazitaetSchaetzen($mysql);
|
$antwort["kapazitaet"] = kapazitaetSchaetzen($mysql);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- strecke -- */
|
||||||
|
case "strecke":
|
||||||
|
/*
|
||||||
|
* Gefahrene Kilometer über mehrere Zeiträume, gerechnet aus dem
|
||||||
|
* Kilometerstand: der Zähler läuft nur vorwärts, also ist die Differenz
|
||||||
|
* zwischen dem ältesten Stand im Zeitraum und dem jüngsten überhaupt die
|
||||||
|
* gefahrene Strecke. Das ist belastbarer als eine Summe über die
|
||||||
|
* erkannten Fahrten - fällt eine Zeile zwischendurch aus, fehlt die
|
||||||
|
* Fahrt in der Liste, im Zählerstand aber nicht.
|
||||||
|
*
|
||||||
|
* CAST(... AS SIGNED) ist nötig: odo_km ist vorzeichenlos, und eine
|
||||||
|
* Differenz unter null bräche die Abfrage ab.
|
||||||
|
*/
|
||||||
|
$zeitraeume = ["woche" => 7, "monat" => 30, "jahr" => 365];
|
||||||
|
|
||||||
|
$spalten = [];
|
||||||
|
foreach ($zeitraeume as $name => $spanne) {
|
||||||
|
$spalten[] = "MIN(CASE WHEN datetime >= NOW() - INTERVAL $spanne DAY
|
||||||
|
THEN CAST(odo_km AS SIGNED) END) AS von_$name";
|
||||||
|
}
|
||||||
|
$sql = "SELECT MAX(CAST(odo_km AS SIGNED)) AS jetzt, MIN(datetime) AS seit, "
|
||||||
|
. implode(", ", $spalten) . " FROM skoda WHERE odo_km IS NOT NULL";
|
||||||
|
$res = $mysql->query($sql);
|
||||||
|
$z = $res ? $res->fetch_assoc() : null;
|
||||||
|
|
||||||
|
$jetzt = ($z && $z["jetzt"] !== null) ? intval($z["jetzt"]) : null;
|
||||||
|
$antwort["odo"] = $jetzt;
|
||||||
|
$antwort["seit"] = $z ? $z["seit"] : null;
|
||||||
|
$antwort["strecke"] = [];
|
||||||
|
|
||||||
|
foreach ($zeitraeume as $name => $spanne) {
|
||||||
|
$von = ($z && $z["von_$name"] !== null) ? intval($z["von_$name"]) : null;
|
||||||
|
$antwort["strecke"][$name] = [
|
||||||
|
"tage" => $spanne,
|
||||||
|
"km" => ($von === null || $jetzt === null) ? null : $jetzt - $von,
|
||||||
|
/*
|
||||||
|
* Reicht die Aufzeichnung überhaupt so weit zurück? Ein Jahreswert
|
||||||
|
* aus drei Monaten Daten ist kein Jahreswert - die Anzeige muss
|
||||||
|
* das sagen dürfen, statt eine zu kleine Zahl als Jahr auszugeben.
|
||||||
|
*/
|
||||||
|
"vollstaendig" => ($z && $z["seit"] !== null)
|
||||||
|
&& strtotime($z["seit"]) <= time() - $spanne * 86400
|
||||||
|
];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
$antwort = ["error" => "Unbekannter Abschnitt."];
|
$antwort = ["error" => "Unbekannter Abschnitt."];
|
||||||
|
|||||||
+1285
-111
File diff suppressed because it is too large
Load Diff
@@ -100,13 +100,13 @@ if (isset($_GET["addUser"])) {
|
|||||||
*/
|
*/
|
||||||
$pages = [
|
$pages = [
|
||||||
"solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false,
|
"solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/solarMQTT.js"]],
|
"scripts" => ["js/solar/jahresstatistik.js", "js/solar/solarMQTT.js"]],
|
||||||
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false,
|
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
|
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
|
||||||
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false,
|
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/heatMQTT.js"]],
|
"scripts" => ["js/solar/heatMQTT.js"]],
|
||||||
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false,
|
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/historyMQTT.js"]],
|
"scripts" => ["js/solar/jahresstatistik.js", "js/solar/historyMQTT.js"]],
|
||||||
"skoda" => ["template" => "skoda.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => true,
|
"skoda" => ["template" => "skoda.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => true,
|
||||||
"scripts" => ["js/solar/skodaMQTT.js"]],
|
"scripts" => ["js/solar/skodaMQTT.js"]],
|
||||||
"weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true, "leaflet" => false,
|
"weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true, "leaflet" => false,
|
||||||
|
|||||||
@@ -800,58 +800,6 @@ function refreshAutomations(floor) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Zuordnung Geraet -> Raum. Eigenes Modal, weil es kein Teil einer einzelnen
|
|
||||||
* Automatik ist, sondern einmal fuer alle gilt.
|
|
||||||
*/
|
|
||||||
function openRoomModal() {
|
|
||||||
document.getElementById("modal-title").innerHTML = "Geräte den Räumen zuordnen";
|
|
||||||
const alt = document.getElementById("modalSaveBtn");
|
|
||||||
const btn = alt.cloneNode(true);
|
|
||||||
alt.replaceWith(btn);
|
|
||||||
// Das Raum-Modal versteckt ihn; hier wird er gebraucht.
|
|
||||||
btn.hidden = false;
|
|
||||||
btn.addEventListener("click", speichereRaeume);
|
|
||||||
|
|
||||||
const dialog = document.querySelector("#modalEV .modal-dialog");
|
|
||||||
dialog.classList.add("modal-lg");
|
|
||||||
document.getElementById("modalEV").addEventListener("hidden.bs.modal",
|
|
||||||
() => dialog.classList.remove("modal-lg"), { once: true });
|
|
||||||
|
|
||||||
const koerper = document.getElementById("modal-body");
|
|
||||||
koerper.innerHTML = loadingHTML("Wird geladen...");
|
|
||||||
modalEV.show();
|
|
||||||
|
|
||||||
fetch("./ajax/AutoAction.php?action=rooms", {
|
|
||||||
method: "GET", headers: { "Requested-With-Ajax": "ajax" }
|
|
||||||
})
|
|
||||||
.then(a => a.text())
|
|
||||||
.then(html => {
|
|
||||||
koerper.innerHTML = html;
|
|
||||||
const tipps = document.getElementById("btnRoomTipps");
|
|
||||||
if (tipps) {
|
|
||||||
tipps.addEventListener("click", () => {
|
|
||||||
koerper.querySelectorAll("select[data-tipp]").forEach(s => {
|
|
||||||
if (!s.value) s.value = s.dataset.tipp;
|
|
||||||
});
|
|
||||||
tipps.disabled = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(fehler => { koerper.innerHTML = "Konnte die Zuordnung nicht laden: " + fehler.message; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function speichereRaeume() {
|
|
||||||
const rooms = {};
|
|
||||||
document.querySelectorAll("#modal-body select[data-actor]").forEach(s => {
|
|
||||||
rooms[s.dataset.actor] = s.value;
|
|
||||||
});
|
|
||||||
automationRequest("saveRooms", { rooms: rooms }).then(antwort => {
|
|
||||||
if (antwort.error) alert(antwort.error);
|
|
||||||
else modalEV.hide();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAutomation(id) {
|
function toggleAutomation(id) {
|
||||||
automationRequest("toggle", { id: id }).then(antwort => {
|
automationRequest("toggle", { id: id }).then(antwort => {
|
||||||
if (antwort.error) alert(antwort.error);
|
if (antwort.error) alert(antwort.error);
|
||||||
|
|||||||
+33
-13
@@ -73,19 +73,6 @@ async function getData(chart, url, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vorgerendertes HTML-Schnipsel in ein Element laden. */
|
|
||||||
async function getStats(elem_id, url) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Response status: ${response.status}`);
|
|
||||||
}
|
|
||||||
document.getElementById(elem_id).innerHTML = await response.text();
|
|
||||||
} catch (error) {
|
|
||||||
console.log("getStats " + url + ": " + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formular des Modals per POST abschicken.
|
* Formular des Modals per POST abschicken.
|
||||||
* Der Start/Stop-Knopf der Ladesteuerung schickt nur sich selbst.
|
* Der Start/Stop-Knopf der Ladesteuerung schickt nur sich selbst.
|
||||||
@@ -228,3 +215,36 @@ function updateWeatherCards(value) {
|
|||||||
document.getElementById("windGust").innerHTML = value("maxgust");
|
document.getElementById("windGust").innerHTML = value("maxgust");
|
||||||
document.getElementById("ambPress").innerHTML = Math.round(value("qff") * 10) / 10;
|
document.getElementById("ambPress").innerHTML = Math.round(value("qff") * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offenen Reiter in der Adresse merken.
|
||||||
|
*
|
||||||
|
* Gilt fuer jede Leiste, die restricted/reiter.php mit "merken" gebaut hat -
|
||||||
|
* erkennbar an data-reiter-merken. Ohne das landet man nach jedem Neuladen
|
||||||
|
* wieder im ersten Reiter, obwohl man gerade woanders gearbeitet hat.
|
||||||
|
*
|
||||||
|
* In der Adresse steht nur das Kuerzel des Reiters (#kacheln), nicht der Name
|
||||||
|
* der Leiste: es ist eine Adresse, die man auch von Hand tippt. Zwei merkende
|
||||||
|
* Leisten auf einer Seite waeren damit nicht zu unterscheiden - dann muesste
|
||||||
|
* hier der Name davor, und die Adresse waere haesslicher als noetig.
|
||||||
|
*/
|
||||||
|
function reiterMerken() {
|
||||||
|
if (typeof bootstrap === "undefined") return;
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-reiter-merken]").forEach(function (leiste) {
|
||||||
|
const knoepfe = leiste.querySelectorAll("[data-reiter]");
|
||||||
|
const gewuenscht = location.hash.replace("#", "");
|
||||||
|
|
||||||
|
knoepfe.forEach(function (knopf) {
|
||||||
|
knopf.addEventListener("shown.bs.tab", function () {
|
||||||
|
history.replaceState(null, "", "#" + knopf.dataset.reiter);
|
||||||
|
});
|
||||||
|
if (gewuenscht && knopf.dataset.reiter === gewuenscht) {
|
||||||
|
new bootstrap.Tab(knopf).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", reiterMerken);
|
||||||
|
|||||||
@@ -145,8 +145,7 @@ Chart.defaults.plugins.legend.labels.generateLabels = legendLabels;
|
|||||||
getData(consChartYear, 'ajax/energyHistory.php?series=cons&range=year');
|
getData(consChartYear, 'ajax/energyHistory.php?series=cons&range=year');
|
||||||
getData(prodChartDecade, 'ajax/energyHistory.php?series=prod&range=decade');
|
getData(prodChartDecade, 'ajax/energyHistory.php?series=prod&range=decade');
|
||||||
getData(consChartDecade, 'ajax/energyHistory.php?series=cons&range=decade');
|
getData(consChartDecade, 'ajax/energyHistory.php?series=cons&range=decade');
|
||||||
getStats("Stats-Year","ajax/getStats.php?type=ThisYear");
|
baueJahresstatistik(document.getElementById("jahresvergleich"),
|
||||||
getStats("Stats-Lastyear","ajax/getStats.php?type=LastYear");
|
{ spalten: 3, werkzeuge: true, fussnoten: true });
|
||||||
getStats("Stats-Prelastyear","ajax/getStats.php?type=PreLastYear");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-13
@@ -122,6 +122,12 @@ const homeSVG = {
|
|||||||
* Wie er dasteht, sagt entweder "format" oder das Paar aus "einheit" und
|
* Wie er dasteht, sagt entweder "format" oder das Paar aus "einheit" und
|
||||||
* "stellen": "stellen" schneidet Nachkommastellen ab, ohne Angabe bleibt
|
* "stellen": "stellen" schneidet Nachkommastellen ab, ohne Angabe bleibt
|
||||||
* der Text so, wie das Geraet ihn meldet.
|
* der Text so, wie das Geraet ihn meldet.
|
||||||
|
*
|
||||||
|
* "negativ" dreht das Vorzeichen. Ein Zaehler zaehlt in der Richtung, in
|
||||||
|
* der er eingebaut ist - einer der beiden meldet den Bezug deshalb mit
|
||||||
|
* Minus. Das gehoert an die Anzeige und nicht an den Zaehler: die
|
||||||
|
* Automatiken und die Statistik lesen dieselbe Nachricht und rechnen mit
|
||||||
|
* dem Vorzeichen, das dort steht.
|
||||||
*/
|
*/
|
||||||
kachelWert: function (mqttData, wert) {
|
kachelWert: function (mqttData, wert) {
|
||||||
const roh = wert.wechselrichter
|
const roh = wert.wechselrichter
|
||||||
@@ -138,6 +144,13 @@ const homeSVG = {
|
|||||||
return null; // noch keine vollstaendige Nachricht
|
return null; // noch keine vollstaendige Nachricht
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (wert.negativ) {
|
||||||
|
const zahl = Number(text);
|
||||||
|
if (text === "" || isNaN(zahl)) return null;
|
||||||
|
// "+ 0" macht aus -0 wieder 0: "-0 W" hat noch niemanden erfreut.
|
||||||
|
text = String(-zahl + 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Leistungen skalieren mit: unter tausend Watt in W, darueber in kW.
|
// Leistungen skalieren mit: unter tausend Watt in W, darueber in kW.
|
||||||
// powerToString() aus common.js macht genau das und wird auf der
|
// powerToString() aus common.js macht genau das und wird auf der
|
||||||
// Solarseite fuer dieselben Zahlen benutzt - eine eigene Regel hier
|
// Solarseite fuer dieselben Zahlen benutzt - eine eigene Regel hier
|
||||||
@@ -203,19 +216,6 @@ function removeClass(el, classNameToRemove){
|
|||||||
// Die Etagen stehen nicht mehr im Code: was es gibt, sagen die Reiter, die
|
// Die Etagen stehen nicht mehr im Code: was es gibt, sagen die Reiter, die
|
||||||
// home.php aus der Raumtabelle gebaut hat. Vorher standen OG, EG und UG hier
|
// home.php aus der Raumtabelle gebaut hat. Vorher standen OG, EG und UG hier
|
||||||
// sechsmal, und eine vierte Etage haette sechs neue Zeilen gebraucht.
|
// sechsmal, und eine vierte Etage haette sechs neue Zeilen gebraucht.
|
||||||
function switchTab(newtab){
|
|
||||||
const newContent = document.getElementById(newtab);
|
|
||||||
const newTabBtn = document.getElementById(newtab+"-tab");
|
|
||||||
document.querySelectorAll("#actions-tab .nav-link").forEach(function(btn){
|
|
||||||
removeClass(btn,"active");
|
|
||||||
});
|
|
||||||
document.querySelectorAll("#actions-tabContent .tab-pane").forEach(function(pane){
|
|
||||||
removeClass(pane,"active show");
|
|
||||||
});
|
|
||||||
addClass(newTabBtn,"active");
|
|
||||||
addClass(newContent,"active show");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function switchFloor(floor){
|
function switchFloor(floor){
|
||||||
if(currentFloor == floor)
|
if(currentFloor == floor)
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
/*
|
||||||
|
* Jahresvergleich der Anlage - ein Baustein, zwei Seiten.
|
||||||
|
*
|
||||||
|
* Auf der Historienseite steht er mit allem: drei Jahresspalten, umschaltbarem
|
||||||
|
* Zeitraum, verschiebbarem Jahrfenster. Auf der Solarseite genügt das laufende
|
||||||
|
* Jahr. Beide bauen dieselbe Tabelle aus derselben Antwort - was sich
|
||||||
|
* unterscheidet, sind Einstellungen, nicht zwei Fassungen desselben Codes.
|
||||||
|
*
|
||||||
|
* baueJahresstatistik(wurzel, { spalten: 3, werkzeuge: true, fussnoten: true })
|
||||||
|
*
|
||||||
|
* Alles Inhaltliche kommt von ajax/getStats.php, und zwar auch die Beschreibung
|
||||||
|
* der Zeilen: welche Kennzahlen es gibt, wie sie heißen, welche Einheit sie
|
||||||
|
* tragen, ob weniger besser ist und wie sie zusammenhängen. Hier steht nur, wie
|
||||||
|
* das aussieht. Eine neue Kennzahl braucht deshalb keine Zeile in dieser Datei.
|
||||||
|
*
|
||||||
|
* Der Abruf liefert alle Jahre auf einmal. Das Verschieben des Fensters und das
|
||||||
|
* Umschalten des Zeitraums brauchen darum keine weitere Anfrage - und die
|
||||||
|
* Antwort wird geteilt, falls eine Seite den Baustein zweimal zeigt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MONATSNAMEN = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
||||||
|
|
||||||
|
/* Ein Abruf für alle Bausteine der Seite. */
|
||||||
|
let statistikAbruf = null;
|
||||||
|
function holeJahresstatistik() {
|
||||||
|
if (!statistikAbruf) {
|
||||||
|
statistikAbruf = fetch("ajax/getStats.php", { cache: "no-store" })
|
||||||
|
.then(function (antwort) {
|
||||||
|
if (!antwort.ok) throw new Error("HTTP " + antwort.status);
|
||||||
|
return antwort.json();
|
||||||
|
})
|
||||||
|
.then(function (daten) {
|
||||||
|
if (daten.error) throw new Error(daten.error);
|
||||||
|
return daten;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return statistikAbruf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Format */
|
||||||
|
|
||||||
|
const statZahl = (wert, dez) => (wert === null || wert === undefined || !isFinite(wert))
|
||||||
|
? "–" : Number(wert).toLocaleString("de-DE",
|
||||||
|
{ minimumFractionDigits: dez, maximumFractionDigits: dez });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Veränderung zweier Werte.
|
||||||
|
*
|
||||||
|
* Prozentwerte vergleichen sich in Prozentpunkten: "die Autarkie stieg um 5 %"
|
||||||
|
* ließe offen, ob 70 auf 75 oder auf 73,5 gemeint ist. Bei allem anderen ist
|
||||||
|
* die relative Veränderung die Aussage. Was unter einem Prozent bzw. drei
|
||||||
|
* Zehntel Prozentpunkten liegt, gilt als unverändert - sonst zeigt die Tabelle
|
||||||
|
* Bewegung, wo keine ist.
|
||||||
|
*/
|
||||||
|
function statDelta(jetzt, vorher, kz) {
|
||||||
|
if (jetzt === null || jetzt === undefined || !vorher) return null;
|
||||||
|
const roh = kz.punkte ? jetzt - vorher : (jetzt - vorher) / Math.abs(vorher) * 100;
|
||||||
|
const klein = Math.abs(roh) < (kz.punkte ? 0.3 : 1);
|
||||||
|
const besser = kz.invers ? roh < 0 : roh > 0;
|
||||||
|
return {
|
||||||
|
text: (roh >= 0 ? "+" : "−") + statZahl(Math.abs(roh), 1) + (kz.punkte ? " Pp" : " %"),
|
||||||
|
art: klein ? "gleich" : (besser ? "gut" : "schlecht"),
|
||||||
|
zeichen: klein ? "→" : (roh > 0 ? "↑" : "↓")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- Baustein */
|
||||||
|
|
||||||
|
function baueJahresstatistik(wurzel, einstellungen) {
|
||||||
|
if (!wurzel) return null;
|
||||||
|
|
||||||
|
const opt = Object.assign({
|
||||||
|
spalten: 3, // Jahresspalten nebeneinander
|
||||||
|
werkzeuge: true, // Zeitraum, Jahrfenster, Verlauf
|
||||||
|
fussnoten: true,
|
||||||
|
zeitraum: "gleich", // "gleich" (bis zum Stichtag) oder "voll"
|
||||||
|
verlauf: true
|
||||||
|
}, einstellungen || {});
|
||||||
|
|
||||||
|
let daten = null;
|
||||||
|
let zeitraum = opt.zeitraum;
|
||||||
|
let verlauf = opt.verlauf;
|
||||||
|
let fenster = 0; // 0 = die jüngsten Jahre
|
||||||
|
const offen = {}; // aufgeklappte Summenzeilen
|
||||||
|
|
||||||
|
/* --- Gerüst ------------------------------------------------------------ */
|
||||||
|
wurzel.classList.add("jahresstatistik");
|
||||||
|
wurzel.innerHTML =
|
||||||
|
'<div class="leitzahlen"></div>'
|
||||||
|
+ (opt.werkzeuge ? '<div class="werkzeuge">'
|
||||||
|
+ '<span class="umschalter" data-rolle="zeitraum" role="group" aria-label="Zeitraum des Vergleichs">'
|
||||||
|
+ '<button type="button" data-zeitraum="gleich">Gleicher Zeitraum</button>'
|
||||||
|
+ '<button type="button" data-zeitraum="voll">Ganze Jahre</button></span>'
|
||||||
|
+ '<span class="jahrwahl">'
|
||||||
|
+ '<button type="button" data-schritt="-1" aria-label="Frühere Jahre zeigen">'
|
||||||
|
+ '<i class="bi bi-chevron-left"></i></button>'
|
||||||
|
+ '<span class="fenster">–</span>'
|
||||||
|
+ '<button type="button" data-schritt="1" aria-label="Spätere Jahre zeigen">'
|
||||||
|
+ '<i class="bi bi-chevron-right"></i></button></span>'
|
||||||
|
+ '<label class="kasten"><input type="checkbox" data-rolle="verlauf" checked>'
|
||||||
|
+ "Monatsverlauf zeigen</label></div>" : "")
|
||||||
|
+ '<div class="tafel-scroll"><table class="tafel">'
|
||||||
|
+ "<thead><tr></tr></thead>"
|
||||||
|
+ '<tbody><tr><td class="leer">Statistik wird geladen …</td></tr></tbody>'
|
||||||
|
+ "</table></div>"
|
||||||
|
+ (opt.fussnoten ? '<div class="fussnoten"><p data-rolle="zeitraumtext"></p>'
|
||||||
|
+ "<p><b>Davon</b> teilt den Ertrag auf: Eigenverbrauch, Einspeisung und Heizung "
|
||||||
|
+ "ergeben ihn zusammen. <b>Darin</b> greift aus einer dieser Zeilen etwas heraus – "
|
||||||
|
+ "der Beitrag der Batterie und die Solarladung des Autos stecken beide im "
|
||||||
|
+ "Eigenverbrauch, denn Strom aus der Batterie und Strom ins Auto ist Strom, der im "
|
||||||
|
+ "Haus geblieben ist. Sie dürfen deshalb nicht dazugezählt werden. Der Beitrag der "
|
||||||
|
+ "Batterie ist dabei netto gerechnet: abzüglich der Vergütung, die für den zum "
|
||||||
|
+ "Laden verwendeten Strom entgangen ist.</p>"
|
||||||
|
+ "<p>Verhältniszahlen – Autarkie, Quoten, effektiver Strompreis – sind für jeden "
|
||||||
|
+ "Zeitraum neu aus seinen Summen gerechnet und nicht über Jahre gemittelt. Der "
|
||||||
|
+ "effektive Strompreis ist Netzkosten samt Grundpreis geteilt durch den "
|
||||||
|
+ "Netzbezug.</p></div>" : "");
|
||||||
|
|
||||||
|
const teil = (auswahl) => wurzel.querySelector(auswahl);
|
||||||
|
const kopf = teil("thead tr");
|
||||||
|
const koerper = teil("tbody");
|
||||||
|
|
||||||
|
/* --- Auswahl ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** Die Jahre ab dem Fensteranfang, jüngstes zuerst. */
|
||||||
|
function jahreAbFenster() {
|
||||||
|
const alle = daten.jahre.slice().sort((a, b) => b - a);
|
||||||
|
const start = Math.max(0, Math.min(fenster, Math.max(0, alle.length - opt.spalten)));
|
||||||
|
return alle.slice(start);
|
||||||
|
}
|
||||||
|
function spaltenJahre() { return jahreAbFenster().slice(0, opt.spalten); }
|
||||||
|
|
||||||
|
function werteVon(jahr) {
|
||||||
|
const satz = daten[zeitraum] || {};
|
||||||
|
return satz[jahr] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welche beiden Jahre die Δ-Spalte vergleicht. Bei ganzen Jahren bleibt das
|
||||||
|
* laufende außen vor: es ist noch nicht fertig, ein Vergleich mit einem
|
||||||
|
* vollen Jahr wäre keiner. Das Paar hängt am Fenster, nicht an den
|
||||||
|
* sichtbaren Spalten - so vergleicht auch eine einspaltige Ansicht.
|
||||||
|
*/
|
||||||
|
function vergleichsPaar() {
|
||||||
|
const laufend = new Date().getFullYear();
|
||||||
|
const jahre = jahreAbFenster();
|
||||||
|
const brauchbar = zeitraum === "gleich" ? jahre : jahre.filter(j => j !== laufend);
|
||||||
|
return brauchbar.length >= 2 ? [brauchbar[0], brauchbar[1]] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das Jahr, dessen Monatsverlauf die letzte Spalte zeigt. */
|
||||||
|
function verlaufsJahr() {
|
||||||
|
const paar = vergleichsPaar();
|
||||||
|
return paar ? paar[0] : (spaltenJahre()[0] || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Stichtag als "7. September" - so, wie man ihn ausspricht. */
|
||||||
|
function stichtagText() {
|
||||||
|
const d = new Date(daten.stichtag + "T00:00:00");
|
||||||
|
return d.toLocaleDateString("de-DE", { day: "numeric", month: "long" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Monatsbalken ------------------------------------------------------ */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Zwölf Balken, eine Grundlinie, sonst nichts: der Verlauf sagt "wann", die
|
||||||
|
* Zahlen daneben sagen "wieviel". Der letzte Monat mit Wert ist hervorgehoben,
|
||||||
|
* damit erkennbar bleibt, wo das Jahr steht.
|
||||||
|
*/
|
||||||
|
function sparkline(kz, jahr) {
|
||||||
|
const reihe = (daten.monate[jahr] || {})[kz.id];
|
||||||
|
if (!reihe) return "";
|
||||||
|
const zahlen = reihe.map(w => (w === null || !isFinite(w)) ? null : Number(w));
|
||||||
|
const gueltig = zahlen.filter(w => w !== null);
|
||||||
|
if (!gueltig.length) return "";
|
||||||
|
|
||||||
|
const hoch = Math.max.apply(null, gueltig.map(Math.abs)) || 1;
|
||||||
|
const letzter = zahlen.reduce((idx, w, i) => (w === null ? idx : i), -1);
|
||||||
|
const b = 7, luecke = 2, H = 24;
|
||||||
|
const breite = zahlen.length * b + (zahlen.length - 1) * luecke;
|
||||||
|
|
||||||
|
let s = '<svg class="spark" width="' + breite + '" height="' + H + '" viewBox="0 0 '
|
||||||
|
+ breite + " " + H + '" role="img" aria-label="Monatsverlauf ' + jahr + '">'
|
||||||
|
+ '<line class="basis" x1="0" y1="' + (H - .5) + '" x2="' + breite
|
||||||
|
+ '" y2="' + (H - .5) + '"/>';
|
||||||
|
zahlen.forEach(function (w, i) {
|
||||||
|
if (w === null) return;
|
||||||
|
const x = i * (b + luecke);
|
||||||
|
const h = Math.max(1.5, Math.abs(w) / hoch * (H - 4));
|
||||||
|
s += '<rect class="balken' + (i === letzter ? " jetzt" : "") + '" x="' + x + '" y="'
|
||||||
|
+ (H - h - 1) + '" width="' + b + '" height="' + h + '" rx="1.5"'
|
||||||
|
+ ' data-platz="' + i + '"'
|
||||||
|
+ ' data-monat="' + MONATSNAMEN[i] + " " + jahr + '"'
|
||||||
|
+ ' data-wert="' + statZahl(w, kz.dez) + " " + kz.einheit + '"></rect>';
|
||||||
|
});
|
||||||
|
return s + "</svg>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Zeichnen ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
function zeichneKopf() {
|
||||||
|
const gleich = zeitraum === "gleich";
|
||||||
|
const laufend = new Date().getFullYear();
|
||||||
|
const paar = vergleichsPaar();
|
||||||
|
|
||||||
|
let h = '<th scope="col">Kennzahl</th>';
|
||||||
|
spaltenJahre().forEach(function (jahr, i) {
|
||||||
|
const unten = gleich ? "bis " + stichtagText()
|
||||||
|
: (jahr === laufend ? "läuft noch" : "ganzes Jahr");
|
||||||
|
h += '<th scope="col"' + (i === 0 ? ' class="jetzt"' : "") + ">" + jahr
|
||||||
|
+ '<span class="zeitraum">' + unten + "</span></th>";
|
||||||
|
});
|
||||||
|
h += '<th scope="col">Δ<span class="zeitraum">'
|
||||||
|
+ (paar ? paar[0] + " zu " + paar[1] : "–") + "</span></th>"
|
||||||
|
+ '<th scope="col" class="gesamt">Gesamt<span class="zeitraum">seit '
|
||||||
|
+ daten.jahre[0] + "</span></th>";
|
||||||
|
if (verlauf) {
|
||||||
|
h += '<th scope="col" class="verlauf">Monatsverlauf<span class="zeitraum">'
|
||||||
|
+ verlaufsJahr() + "</span></th>";
|
||||||
|
}
|
||||||
|
kopf.innerHTML = h;
|
||||||
|
|
||||||
|
const text = teil('[data-rolle="zeitraumtext"]');
|
||||||
|
if (text) {
|
||||||
|
text.innerHTML = gleich
|
||||||
|
? "<b>Gleicher Zeitraum:</b> jedes Jahr vom 1. Januar bis zum " + stichtagText()
|
||||||
|
+ " – so vergleicht sich das laufende Jahr mit dem, was zum selben Stichtag im "
|
||||||
|
+ "Vorjahr zusammengekommen war."
|
||||||
|
: "<b>Ganze Jahre:</b> abgeschlossene Jahre vollständig; das laufende Jahr steht "
|
||||||
|
+ "so weit, wie es ist, und trägt deshalb keine Tendenz.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeichneZeilen() {
|
||||||
|
const jahre = spaltenJahre();
|
||||||
|
const paar = vergleichsPaar();
|
||||||
|
const jahrDesVerlaufs = verlaufsJahr();
|
||||||
|
const spalten = jahre.length + 3 + (verlauf ? 1 : 0);
|
||||||
|
let h = "";
|
||||||
|
|
||||||
|
daten.kennzahlen.forEach(function (kz) {
|
||||||
|
if (kz.gruppe) {
|
||||||
|
h += '<tr class="gruppe"><td colspan="' + spalten + '">' + kz.gruppe + "</td></tr>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const versteckt = kz.unter && !offen[kz.unter];
|
||||||
|
const klassen = [kz.summe ? "summe" : "", kz.teilmenge ? "teilmenge" : "",
|
||||||
|
versteckt ? "versteckt" : ""].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Beschriftung. Die Einheit steht einmal an der Zeile statt in jeder
|
||||||
|
* Spalte - sie ändert sich ja nicht von Jahr zu Jahr. Sie sitzt INNERHALB
|
||||||
|
* der Einrückung, damit sie beim Umbruch dem Namen folgt und nicht
|
||||||
|
* allein am linken Zellenrand landet.
|
||||||
|
*/
|
||||||
|
const text = kz.name + '<span class="einheit-klein">' + kz.einheit + "</span>";
|
||||||
|
let name;
|
||||||
|
if (kz.klapp) {
|
||||||
|
/*
|
||||||
|
* Zusammengefasste Zeilen stehen zunaechst zu - die Tabelle soll mit
|
||||||
|
* zehn Zeilen aufmachen und nicht mit siebzehn. Damit das Zugeklappte
|
||||||
|
* nicht verschwindet, sagt die Zeile, wieviel darin steckt.
|
||||||
|
*/
|
||||||
|
const posten = daten.kennzahlen.filter(k => k.unter === kz.id).length;
|
||||||
|
name = '<button type="button" class="klapp" data-klapp="' + kz.id + '" aria-expanded="'
|
||||||
|
+ (offen[kz.id] ? "true" : "false") + '"><span class="pfeil">›</span>'
|
||||||
|
// Der Name als eigener Baustein: bei knapper Breite rutscht dann
|
||||||
|
// die Anzahl in die naechste Zeile, statt den Namen zu zerreissen.
|
||||||
|
+ "<span>" + text + "</span>"
|
||||||
|
+ (offen[kz.id] || !posten ? ""
|
||||||
|
: '<span class="posten">' + posten + " Posten</span>") + "</button>";
|
||||||
|
} else if (kz.teilmenge) {
|
||||||
|
// "darin" allein liesse offen, worin - deshalb steht der Name der
|
||||||
|
// uebergeordneten Zeile daneben. Wer die Tabelle ueberfliegt, soll
|
||||||
|
// nicht raten muessen, ob die Zeile direkt darueber gemeint ist.
|
||||||
|
const traeger = daten.kennzahlen.find(k => k.id === kz.teilmenge);
|
||||||
|
name = '<span class="stufe-2"><span class="darin">'
|
||||||
|
+ (traeger ? "im " + traeger.name : "darin") + "</span>" + text + "</span>";
|
||||||
|
} else if (kz.teil) {
|
||||||
|
name = '<span class="stufe-1">' + text + "</span>";
|
||||||
|
} else {
|
||||||
|
name = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
h += '<tr class="' + klassen + '"><td class="name">' + name + "</td>";
|
||||||
|
|
||||||
|
jahre.forEach(function (jahr, i) {
|
||||||
|
const w = werteVon(jahr);
|
||||||
|
h += '<td class="' + (i === 0 ? "wert-jetzt" : "wert-alt") + '">'
|
||||||
|
+ statZahl(w ? w[kz.id] : null, kz.dez) + "</td>";
|
||||||
|
});
|
||||||
|
|
||||||
|
const d = paar ? statDelta((werteVon(paar[0]) || {})[kz.id],
|
||||||
|
(werteVon(paar[1]) || {})[kz.id], kz) : null;
|
||||||
|
h += "<td>" + (d ? '<span class="delta ' + d.art + '">' + d.zeichen + " " + d.text
|
||||||
|
+ "</span>" : '<span class="leer">–</span>') + "</td>";
|
||||||
|
|
||||||
|
// Die Summe über alles. Verhältniszahlen sind darin über den ganzen
|
||||||
|
// Zeitraum gerechnet, nicht über die Jahre gemittelt.
|
||||||
|
h += '<td class="gesamt">'
|
||||||
|
+ statZahl(daten.gesamt ? daten.gesamt[kz.id] : null, kz.dez) + "</td>";
|
||||||
|
|
||||||
|
if (verlauf) h += '<td class="verlauf">' + sparkline(kz, jahrDesVerlaufs) + "</td>";
|
||||||
|
h += "</tr>";
|
||||||
|
});
|
||||||
|
|
||||||
|
koerper.innerHTML = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeichneLeitzahlen() {
|
||||||
|
const paar = vergleichsPaar();
|
||||||
|
const jahr = paar ? paar[0] : spaltenJahre()[0];
|
||||||
|
const gegen = paar ? paar[1] : null;
|
||||||
|
const werte = werteVon(jahr) || {};
|
||||||
|
const vorher = gegen ? (werteVon(gegen) || {}) : {};
|
||||||
|
|
||||||
|
teil(".leitzahlen").innerHTML = daten.leitzahlen.map(function (id, i) {
|
||||||
|
const kz = daten.kennzahlen.find(k => k.id === id);
|
||||||
|
if (!kz) return "";
|
||||||
|
const d = statDelta(werte[id], vorher[id], kz);
|
||||||
|
return '<div class="leitzahl' + (i === 0 ? " betont" : "") + '">'
|
||||||
|
+ '<div class="name">' + kz.name + "</div>"
|
||||||
|
+ '<div class="zahl">' + statZahl(werte[id], kz.dez)
|
||||||
|
+ '<span class="einheit">' + kz.einheit + "</span></div>"
|
||||||
|
+ '<div class="fuss">'
|
||||||
|
+ (d ? '<span class="delta ' + d.art + '">' + d.zeichen + " " + d.text + "</span>" : "")
|
||||||
|
+ "<span>" + jahr + (zeitraum === "gleich" ? " bis " + stichtagText() : "")
|
||||||
|
+ (gegen ? " gegen " + gegen : "") + "</span></div></div>";
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeichneJahrwahl() {
|
||||||
|
const wahl = teil(".jahrwahl");
|
||||||
|
if (!wahl) return;
|
||||||
|
const jahre = spaltenJahre();
|
||||||
|
wahl.querySelector(".fenster").textContent =
|
||||||
|
jahre.length > 1 ? jahre[jahre.length - 1] + "–" + jahre[0] : String(jahre[0] || "–");
|
||||||
|
// Links liegt die Vergangenheit: dort ist Schluss beim ersten
|
||||||
|
// aufgezeichneten Jahr, rechts beim laufenden.
|
||||||
|
wahl.querySelector('[data-schritt="-1"]').disabled =
|
||||||
|
fenster >= daten.jahre.length - opt.spalten;
|
||||||
|
wahl.querySelector('[data-schritt="1"]').disabled = fenster <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeichne() {
|
||||||
|
if (!daten || !daten.jahre.length) return;
|
||||||
|
zeichneLeitzahlen();
|
||||||
|
zeichneKopf();
|
||||||
|
zeichneZeilen();
|
||||||
|
zeichneJahrwahl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Bedienung --------------------------------------------------------- */
|
||||||
|
|
||||||
|
wurzel.querySelectorAll("[data-zeitraum]").forEach(function (b) {
|
||||||
|
b.setAttribute("aria-pressed", String(b.dataset.zeitraum === zeitraum));
|
||||||
|
b.addEventListener("click", function () {
|
||||||
|
zeitraum = b.dataset.zeitraum;
|
||||||
|
wurzel.querySelectorAll("[data-zeitraum]").forEach(function (x) {
|
||||||
|
x.setAttribute("aria-pressed", String(x === b));
|
||||||
|
});
|
||||||
|
zeichne();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wurzel.querySelectorAll("[data-schritt]").forEach(function (b) {
|
||||||
|
b.addEventListener("click", function () {
|
||||||
|
const grenze = Math.max(0, daten.jahre.length - opt.spalten);
|
||||||
|
fenster = Math.max(0, Math.min(grenze, fenster - Number(b.dataset.schritt)));
|
||||||
|
zeichne();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const schalter = teil('[data-rolle="verlauf"]');
|
||||||
|
if (schalter) {
|
||||||
|
schalter.checked = verlauf;
|
||||||
|
schalter.addEventListener("change", function (e) {
|
||||||
|
verlauf = e.target.checked;
|
||||||
|
zeichneKopf();
|
||||||
|
zeichneZeilen();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
koerper.addEventListener("click", function (e) {
|
||||||
|
const knopf = e.target.closest("[data-klapp]");
|
||||||
|
if (!knopf) return;
|
||||||
|
offen[knopf.dataset.klapp] = !offen[knopf.dataset.klapp];
|
||||||
|
zeichneZeilen();
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sprechblase am Monatsverlauf.
|
||||||
|
*
|
||||||
|
* Nicht je Balken, sondern über die ganze Reihe: ein Balken ist sieben Pixel
|
||||||
|
* breit, eine Fingerkuppe deckt rund vierzig - einzeln zu treffen wären sie
|
||||||
|
* nicht, und ein hoher Balken deckte seine eigene Trefferfläche zu. Deshalb
|
||||||
|
* entscheidet die Stelle, an der Finger oder Zeiger die Reihe berühren,
|
||||||
|
* welcher Monat gemeint ist; Wischen fährt sie ab. Damit ist jeder Monat
|
||||||
|
* erreichbar, unabhängig von seiner Höhe.
|
||||||
|
*/
|
||||||
|
const tipp = document.createElement("div");
|
||||||
|
tipp.className = "spark-tipp";
|
||||||
|
tipp.setAttribute("role", "status");
|
||||||
|
document.body.appendChild(tipp);
|
||||||
|
|
||||||
|
/** Der Balken, über dem die Stelle x liegt - auch wenn sie daneben trifft. */
|
||||||
|
function balkenBei(svg, x) {
|
||||||
|
const r = svg.getBoundingClientRect();
|
||||||
|
if (!r.width) return null;
|
||||||
|
const platz = Math.max(0, Math.min(MONATSNAMEN.length - 1,
|
||||||
|
Math.floor((x - r.left) / r.width * MONATSNAMEN.length)));
|
||||||
|
// Monate ohne Wert haben keinen Balken - dort gibt es nichts zu zeigen.
|
||||||
|
return svg.querySelector('rect.balken[data-platz="' + platz + '"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeTipp(balken) {
|
||||||
|
koerper.querySelectorAll("rect.balken.aktiv").forEach(b => b.classList.remove("aktiv"));
|
||||||
|
balken.classList.add("aktiv");
|
||||||
|
tipp.textContent = balken.dataset.monat + " · " + balken.dataset.wert;
|
||||||
|
const r = balken.getBoundingClientRect();
|
||||||
|
tipp.style.left = Math.max(8, Math.min(window.innerWidth - 180, r.left - 60)) + "px";
|
||||||
|
tipp.style.top = Math.max(8, r.top - 34) + "px";
|
||||||
|
tipp.classList.add("an");
|
||||||
|
}
|
||||||
|
|
||||||
|
function verbergeTipp() {
|
||||||
|
koerper.querySelectorAll("rect.balken.aktiv").forEach(b => b.classList.remove("aktiv"));
|
||||||
|
tipp.classList.remove("an");
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Gemeint ist die ganze Zelle, nicht die Zeichnung darin: die Balkenreihe
|
||||||
|
* ist 24 Pixel hoch, die Zelle gut dreissig - wer knapp darueber oder
|
||||||
|
* darunter tippt, meint trotzdem diesen Monat.
|
||||||
|
*/
|
||||||
|
function zeigeAnStelle(e) {
|
||||||
|
const zelle = e.target.closest("td.verlauf");
|
||||||
|
const svg = zelle && zelle.querySelector("svg.spark");
|
||||||
|
if (!svg) { verbergeTipp(); return; }
|
||||||
|
const balken = balkenBei(svg, e.clientX);
|
||||||
|
if (balken) zeigeTipp(balken); else verbergeTipp();
|
||||||
|
}
|
||||||
|
|
||||||
|
koerper.addEventListener("pointerdown", zeigeAnStelle);
|
||||||
|
koerper.addEventListener("pointermove", zeigeAnStelle);
|
||||||
|
koerper.addEventListener("pointerleave", verbergeTipp);
|
||||||
|
|
||||||
|
/* --- Laden ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
holeJahresstatistik().then(function (antwort) {
|
||||||
|
daten = antwort;
|
||||||
|
if (!daten.jahre.length) {
|
||||||
|
koerper.innerHTML = '<tr><td class="leer">Noch keine ausgewerteten Tage.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zeichne();
|
||||||
|
}).catch(function (e) {
|
||||||
|
koerper.innerHTML = '<tr><td class="leer">Statistik nicht abrufbar.</td></tr>';
|
||||||
|
console.log("Jahresstatistik: " + e.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { zeichne: zeichne };
|
||||||
|
}
|
||||||
@@ -252,3 +252,881 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
preisKarteBinden(karte);
|
preisKarteBinden(karte);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Reiter "Home-Kacheln": welche Messwerte auf den Kacheln stehen.
|
||||||
|
|
||||||
|
Dieselbe Arbeitsteilung wie bei den Preisen: das Modell ist die Wahrheit,
|
||||||
|
jede Eingabe schreibt hinein, gezeichnet wird daraus neu. Anders als dort
|
||||||
|
kommt es nicht mit der Seite, sondern per Abruf - die Liste haengt an den
|
||||||
|
Raeumen und aendert sich seltener als ein Preis, aber sie ist laenger.
|
||||||
|
|
||||||
|
Eine Kachel ist entweder "Standard" (keine Zeile in der Datenbank, es gilt
|
||||||
|
restricted/rooms.php) oder "selbst gesetzt". Der Unterschied steht in der
|
||||||
|
Zeile, denn er entscheidet, was beim naechsten Umbau von rooms.php
|
||||||
|
passiert: Standardkacheln ziehen mit, selbst gesetzte nicht.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
let kachelModell = null; // { kacheln, symbole, topics, max, tabelleDa }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der Katalog ist die Liste der Messwerte, die der Geraetesuchlauf kennt -
|
||||||
|
* dieselbe, aus der der Automatik-Editor seine Bedingungen baut. Ein Wert
|
||||||
|
* daraus bringt Topic, JSON-Pfad und Einheit mit; von Hand einzutippen ist
|
||||||
|
* dann nichts mehr.
|
||||||
|
*
|
||||||
|
* Gespeichert wird trotzdem nur Topic und Pfad. Der Katalog ist eine
|
||||||
|
* Eingabehilfe und keine zweite Wahrheit: verschwindet ein Geraet aus dem
|
||||||
|
* Suchlauf, zeigt die Kachel weiter, was sie zeigt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Der Katalogeintrag zu einem Wert - oder null, wenn er dort nicht steht. */
|
||||||
|
function katalogTreffer(wert) {
|
||||||
|
if (!kachelModell || !kachelModell.katalog || wert.wechselrichter) return null;
|
||||||
|
const pfad = wert.pfad || "";
|
||||||
|
for (let gi = 0; gi < kachelModell.katalog.length; gi++) {
|
||||||
|
const werte = kachelModell.katalog[gi].werte;
|
||||||
|
for (let wi = 0; wi < werte.length; wi++) {
|
||||||
|
if (werte[wi].topic === wert.topic && (werte[wi].pfad || "") === pfad) {
|
||||||
|
return { gi: gi, wi: wi, geraet: kachelModell.katalog[gi], wert: werte[wi] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welche Eingabeart eine Zeile zeigt.
|
||||||
|
*
|
||||||
|
* Beim ersten Zeichnen geraten: steht der Wert im Katalog, ist es eine
|
||||||
|
* Auswahl. Sobald der Benutzer die Art umstellt, gilt seine Wahl (_quelle) -
|
||||||
|
* sonst spraenge die Zeile beim naechsten Neuzeichnen zurueck.
|
||||||
|
*/
|
||||||
|
function kachelQuelle(wert) {
|
||||||
|
if (wert._quelle) return wert._quelle;
|
||||||
|
if (wert.wechselrichter) return "wr";
|
||||||
|
return katalogTreffer(wert) ? "sensor" : "topic";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurzform eines Wertes fuer die Zeile: das Wesentliche, nicht alles.
|
||||||
|
*
|
||||||
|
* Der Geraetename steht nur dabei, wenn das Geraet in einem anderen Raum haengt
|
||||||
|
* als die Kachel - im eigenen Raum wiederholte er nur, wo man ohnehin steht,
|
||||||
|
* und aus drei Kuerzeln wuerden drei Zeilen.
|
||||||
|
*/
|
||||||
|
function kachelWertKurz(wert, kachel) {
|
||||||
|
const einheit = wert.format === "leistung" ? "W/kW" : (wert.einheit || "");
|
||||||
|
const treffer = wert.wechselrichter ? null : katalogTreffer(wert);
|
||||||
|
let text;
|
||||||
|
|
||||||
|
if (wert.wechselrichter) {
|
||||||
|
text = "Wechselrichter (" + wert.wechselrichter.length + ")";
|
||||||
|
} else if (treffer) {
|
||||||
|
// Das Geraet steht nur dabei, wenn es ein fremdes ist - im eigenen Raum
|
||||||
|
// waere es auf jeder Zeile dasselbe Wort.
|
||||||
|
const fremd = !kachel || treffer.geraet.raum !== kachel.name;
|
||||||
|
text = (fremd ? treffer.geraet.label + " · " : "") + treffer.wert.name
|
||||||
|
+ (einheit ? " " + einheit : "");
|
||||||
|
} else {
|
||||||
|
const topic = wert.topic || "";
|
||||||
|
const teile = topic.split("/");
|
||||||
|
text = teile[teile.length - 1] || topic;
|
||||||
|
if (wert.pfad) text += " · " + wert.pfad;
|
||||||
|
if (einheit) text += " " + einheit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Minus steht vorne, wie es auf der Kachel auch stuende - und zwar bei
|
||||||
|
// jeder Herkunft, deshalb erst hier und nicht in den drei Zweigen.
|
||||||
|
return wert.negativ ? "−" + text : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Kachel im Modell finden. */
|
||||||
|
function kachelSuchen(floor, room) {
|
||||||
|
return (kachelModell.kacheln || []).find(k => k.floor === floor && k.room === room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Zeichnen ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function zeichneKacheln() {
|
||||||
|
const ziel = document.getElementById("kachelListe");
|
||||||
|
if (!ziel || !kachelModell) return;
|
||||||
|
|
||||||
|
if (!kachelModell.tabelleDa) {
|
||||||
|
ziel.innerHTML = '<div class="alert alert-warning mb-0">Die Tabelle '
|
||||||
|
+ '<code>kachelWerte</code> fehlt noch. Sie steht in '
|
||||||
|
+ '<code>solarLog_kacheln.sql</code> – bis dahin zeigen alle Kacheln '
|
||||||
|
+ 'ihre Vorgabe, und Speichern ist nicht möglich.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorschlagsliste einmal fuer alle Topic-Felder.
|
||||||
|
let html = '<datalist id="kachelTopics">'
|
||||||
|
+ (kachelModell.topics || []).map(t => '<option value="' + textSchuetzen(t) + '">').join("")
|
||||||
|
+ "</datalist>";
|
||||||
|
|
||||||
|
let etage = null;
|
||||||
|
(kachelModell.kacheln || []).forEach(function (k) {
|
||||||
|
if (k.floor !== etage) {
|
||||||
|
if (etage !== null) html += "</div>";
|
||||||
|
etage = k.floor;
|
||||||
|
html += '<div class="kachel-etage"><div class="kachel-etage-name">'
|
||||||
|
+ textSchuetzen(k.floorLabel) + "</div>";
|
||||||
|
}
|
||||||
|
html += kachelZeile(k);
|
||||||
|
});
|
||||||
|
if (etage !== null) html += "</div>";
|
||||||
|
|
||||||
|
ziel.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Raumzeile: Name, Kurzfassung der Werte, darunter der Editor. */
|
||||||
|
function kachelZeile(k) {
|
||||||
|
const werte = k._entwurf || k.werte;
|
||||||
|
// Das Symbol steht auch in der Kurzfassung: es ist das, was auf der Kachel
|
||||||
|
// vor der Zahl steht, und damit der schnellste Weg zu erkennen, welcher der
|
||||||
|
// drei Werte gemeint ist.
|
||||||
|
const kurz = werte.length
|
||||||
|
? werte.map(w => '<span class="kachel-chip">'
|
||||||
|
+ (w.icon && kachelSymbolBekannt(w.icon)
|
||||||
|
? '<i class="bi bi-' + textSchuetzen(w.icon) + '"></i>' : "")
|
||||||
|
+ textSchuetzen(kachelWertKurz(w, k)) + "</span>").join("")
|
||||||
|
: '<span class="kachel-chip leer">ohne Messwert</span>';
|
||||||
|
|
||||||
|
let html = '<div class="kachel-zeile' + (k._offen ? " offen" : "") + '"'
|
||||||
|
+ ' data-floor="' + k.floor + '" data-room="' + k.room + '">'
|
||||||
|
+ '<button type="button" class="kachel-kopf">'
|
||||||
|
+ '<i class="bi bi-chevron-right pfeil"></i>'
|
||||||
|
+ '<span class="name">' + textSchuetzen(k.name) + "</span>"
|
||||||
|
+ '<span class="werte">' + kurz + "</span>"
|
||||||
|
+ (k.eigen ? '<span class="badge text-bg-secondary">selbst gesetzt</span>' : "")
|
||||||
|
+ "</button>";
|
||||||
|
|
||||||
|
if (k._offen) {
|
||||||
|
html += '<div class="kachel-editor">'
|
||||||
|
+ werte.map((w, i) => kachelWertZeile(w, i, k)).join("");
|
||||||
|
if (werte.length < (kachelModell.max || 3)) {
|
||||||
|
html += '<button type="button" class="btn btn-sm btn-outline-secondary kachel-plus">'
|
||||||
|
+ '<i class="bi bi-plus-lg me-1"></i>Wert hinzufügen</button>';
|
||||||
|
}
|
||||||
|
html += '<div class="kachel-fuss">'
|
||||||
|
+ '<button type="button" class="btn btn-primary btn-sm kachel-speichern">Speichern</button>'
|
||||||
|
+ '<button type="button" class="btn btn-outline-secondary btn-sm kachel-standard"'
|
||||||
|
+ (k.eigen ? "" : " disabled") + ">Standard</button>"
|
||||||
|
+ '<span class="kachel-hinweis small"></span>'
|
||||||
|
+ "</div></div>";
|
||||||
|
}
|
||||||
|
return html + "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Symbole ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Feld für das Symbol: eine Vorschau und ein Name.
|
||||||
|
*
|
||||||
|
* Statt einer Auswahlliste mit einer Handvoll Symbole steht hier der Name des
|
||||||
|
* Bootstrap-Icons - damit sind alle gut zweitausend möglich, ohne dass für
|
||||||
|
* jedes neue eines im Code nachgetragen werden müsste. Die üblichen stehen in
|
||||||
|
* der Vorschlagsliste obenan, mit deutscher Beschriftung; wer ein anderes
|
||||||
|
* will, tippt und bekommt den Rest angeboten (icons.getbootstrap.com).
|
||||||
|
*
|
||||||
|
* Die Vorschau ist die eigentliche Hilfe: ein Name wie "moisture" sagt nichts,
|
||||||
|
* das Symbol daneben schon.
|
||||||
|
*/
|
||||||
|
function kachelSymbolFeld(icon) {
|
||||||
|
return '<span class="kw-symbol-schau">' + kachelSymbolVorschau(icon) + "</span>"
|
||||||
|
+ '<input type="text" class="form-control form-control-sm" data-feld="icon"'
|
||||||
|
+ ' list="kachelIcons" placeholder="Symbol" autocomplete="off"'
|
||||||
|
+ ' value="' + textSchuetzen(icon) + '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Inhalt der Vorschau: das Symbol, ein Strich, oder ein Fragezeichen. */
|
||||||
|
function kachelSymbolVorschau(icon) {
|
||||||
|
if (!icon) return '<i class="bi bi-dash" title="ohne Symbol"></i>';
|
||||||
|
if (!kachelSymbolBekannt(icon)) {
|
||||||
|
return '<i class="bi bi-question-circle unbekannt" title="Dieses Symbol gibt es nicht"></i>';
|
||||||
|
}
|
||||||
|
return '<i class="bi bi-' + textSchuetzen(icon) + '" title="' + textSchuetzen(icon) + '"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function kachelSymbolBekannt(icon) {
|
||||||
|
if (!kachelSymbolBekannt._satz) {
|
||||||
|
kachelSymbolBekannt._satz = new Set((kachelModell.symbole || []).map(s => s.name));
|
||||||
|
}
|
||||||
|
return kachelSymbolBekannt._satz.has(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Vorschlagsliste steht einmal im Dokument, nicht in jeder Zeile.
|
||||||
|
*
|
||||||
|
* Zweitausend Einträge sind rund siebzig Kilobyte Markup; bei jedem
|
||||||
|
* Neuzeichnen einer Kachel neu zu bauen waere Verschwendung. Sie haengt
|
||||||
|
* deshalb am Ende des Dokuments und ueberlebt das Neuzeichnen der Liste.
|
||||||
|
*/
|
||||||
|
function kachelIconListeSicherstellen() {
|
||||||
|
if (document.getElementById("kachelIcons")) return;
|
||||||
|
const liste = document.createElement("datalist");
|
||||||
|
liste.id = "kachelIcons";
|
||||||
|
liste.innerHTML = (kachelModell.symbole || []).map(s => '<option value="' + s.name + '"'
|
||||||
|
+ (s.titel ? ' label="' + textSchuetzen(s.titel) + '"' : "") + ">").join("");
|
||||||
|
document.body.appendChild(liste);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Wertdefinition als Formularzeile.
|
||||||
|
*
|
||||||
|
* Die Eingabeart entscheidet, welche Felder sichtbar sind: eine Auswahl aus
|
||||||
|
* dem Katalog braucht kein Topic, ein Wechselrichter keinen JSON-Pfad. Alles
|
||||||
|
* gleichzeitig anzubieten waere eine Einladung, es auch auszufuellen -
|
||||||
|
* gelesen wird immer nur eines davon.
|
||||||
|
*/
|
||||||
|
function kachelWertZeile(wert, i, kachel) {
|
||||||
|
const quelle = kachelQuelle(wert);
|
||||||
|
const katalog = (kachelModell && kachelModell.katalog) || [];
|
||||||
|
|
||||||
|
// Eingabeart. "Messwert" steht nur zur Wahl, wenn es einen Katalog gibt -
|
||||||
|
// ohne Mesh-Datenbank waere es eine leere Liste.
|
||||||
|
let felder = '<div class="kw-quelle">'
|
||||||
|
+ '<select class="form-select form-select-sm" data-feld="quelle">'
|
||||||
|
+ (katalog.length ? '<option value="sensor"' + (quelle === "sensor" ? " selected" : "")
|
||||||
|
+ ">Messwert</option>" : "")
|
||||||
|
+ '<option value="topic"' + (quelle === "topic" ? " selected" : "") + ">Topic</option>"
|
||||||
|
+ '<option value="wr"' + (quelle === "wr" ? " selected" : "") + ">Wechselrichter</option>"
|
||||||
|
+ "</select></div>";
|
||||||
|
|
||||||
|
if (quelle === "sensor") {
|
||||||
|
const treffer = katalogTreffer(wert);
|
||||||
|
// Ohne Treffer beim ersten Geraet des eigenen Raums beginnen, sonst beim
|
||||||
|
// ersten ueberhaupt - ein neuer Wert soll nicht im Nachbargeschoss starten.
|
||||||
|
const ersterEigener = katalog.findIndex(
|
||||||
|
(g) => kachel && g.floorKey === kachel.floor && g.raumKey === kachel.name);
|
||||||
|
const gi = treffer ? treffer.gi : Math.max(0, ersterEigener);
|
||||||
|
const wi = treffer ? treffer.wi : -1;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Geraete des eigenen Raums stehen oben in einer eigenen Gruppe - das
|
||||||
|
* ist fast immer das Gesuchte. Der Rest folgt nach Etagen, denn manche
|
||||||
|
* Kachel zeigt bewusst etwas Fremdes: der Carport die Wallbox, die
|
||||||
|
* Technik den Netzanschluss. Ganz wegzulassen waere deshalb zu streng.
|
||||||
|
*
|
||||||
|
* Der Wert einer Option bleibt der Platz im vollstaendigen Katalog, nicht
|
||||||
|
* in der gefilterten Liste - sonst zeigte die Auswahl nach dem Umsortieren
|
||||||
|
* auf ein anderes Geraet.
|
||||||
|
*/
|
||||||
|
const eigen = (g) => kachel && g.floorKey === kachel.floor && g.raumKey === kachel.name;
|
||||||
|
const option = (g, index) => '<option value="' + index + '"'
|
||||||
|
+ (index === gi ? " selected" : "") + ">"
|
||||||
|
+ textSchuetzen(g.label + (g.raum && !eigen(g) ? " – " + g.raum : "")) + "</option>";
|
||||||
|
|
||||||
|
let geraete = "";
|
||||||
|
const eigene = katalog.map((g, i2) => [g, i2]).filter(([g]) => eigen(g));
|
||||||
|
if (eigene.length) {
|
||||||
|
geraete += '<optgroup label="Dieser Raum">'
|
||||||
|
+ eigene.map(([g, i2]) => option(g, i2)).join("") + "</optgroup>";
|
||||||
|
}
|
||||||
|
let etage = null;
|
||||||
|
katalog.forEach(function (g, index) {
|
||||||
|
if (eigen(g)) return;
|
||||||
|
if (g.floor !== etage) {
|
||||||
|
if (etage !== null) geraete += "</optgroup>";
|
||||||
|
etage = g.floor;
|
||||||
|
geraete += '<optgroup label="' + textSchuetzen(etage || "ohne Raum") + '">';
|
||||||
|
}
|
||||||
|
geraete += option(g, index);
|
||||||
|
});
|
||||||
|
if (etage !== null) geraete += "</optgroup>";
|
||||||
|
|
||||||
|
const messwerte = (katalog[gi] || { werte: [] }).werte.map(function (w, index) {
|
||||||
|
// Der aktuelle Wert steht dabei: er beantwortet, ob man den richtigen
|
||||||
|
// Messwert erwischt hat, ohne die Kachel erst speichern zu muessen.
|
||||||
|
const jetzt = (w.wert === null || w.wert === undefined || w.wert === "")
|
||||||
|
? "" : " · " + w.wert + (w.einheit ? " " + w.einheit : "");
|
||||||
|
return '<option value="' + index + '"' + (index === wi ? " selected" : "") + ">"
|
||||||
|
+ textSchuetzen(w.name + jetzt) + "</option>";
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
felder += '<div class="kw-topic"><select class="form-select form-select-sm"'
|
||||||
|
+ ' data-feld="geraet">' + geraete + "</select></div>"
|
||||||
|
+ '<div class="kw-pfad"><select class="form-select form-select-sm" data-feld="messwert">'
|
||||||
|
+ (wi < 0 ? '<option value="-1" selected>– wählen –</option>' : "")
|
||||||
|
+ messwerte + "</select></div>";
|
||||||
|
} else if (quelle === "wr") {
|
||||||
|
felder += '<div class="kw-topic"><input type="text" class="form-control form-control-sm"'
|
||||||
|
+ ' data-feld="wechselrichter" placeholder="Namen, mit Komma getrennt"'
|
||||||
|
+ ' value="' + textSchuetzen((wert.wechselrichter || []).join(", ")) + '"></div>'
|
||||||
|
+ '<div class="kw-pfad"><input type="text" class="form-control form-control-sm"'
|
||||||
|
+ ' data-feld="feld" placeholder="Feld" value="' + textSchuetzen(wert.feld || "p_AC") + '"></div>';
|
||||||
|
} else {
|
||||||
|
felder += '<div class="kw-topic"><input type="text" class="form-control form-control-sm"'
|
||||||
|
+ ' data-feld="topic" list="kachelTopics" placeholder="MQTT-Topic"'
|
||||||
|
+ ' value="' + textSchuetzen(wert.topic || "") + '"></div>'
|
||||||
|
+ '<div class="kw-pfad"><input type="text" class="form-control form-control-sm"'
|
||||||
|
+ ' data-feld="pfad" placeholder="JSON-Pfad" value="' + textSchuetzen(wert.pfad || "") + '"></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<div class="kachel-wert" data-i="' + i + '">' + felder
|
||||||
|
|
||||||
|
+ '<div class="kw-einheit"><input type="text" class="form-control form-control-sm"'
|
||||||
|
+ ' data-feld="einheit" placeholder="Einheit" maxlength="8"'
|
||||||
|
+ (wert.format === "leistung" ? " disabled" : "")
|
||||||
|
+ ' value="' + textSchuetzen(wert.einheit || "") + '"></div>'
|
||||||
|
|
||||||
|
+ '<div class="kw-stellen"><input type="number" class="form-control form-control-sm"'
|
||||||
|
+ ' data-feld="stellen" min="0" max="3" placeholder="0"'
|
||||||
|
+ (wert.format === "leistung" ? " disabled" : "")
|
||||||
|
+ ' value="' + (wert.stellen === undefined ? "" : wert.stellen) + '"></div>'
|
||||||
|
|
||||||
|
+ '<div class="kw-symbol">' + kachelSymbolFeld(wert.icon || "") + "</div>"
|
||||||
|
|
||||||
|
+ '<div class="kw-schalter">'
|
||||||
|
+ '<label title="Leistung: skaliert selbst zwischen W und kW">'
|
||||||
|
+ '<input type="checkbox" class="form-check-input" data-feld="format"'
|
||||||
|
+ (wert.format === "leistung" ? " checked" : "") + "> kW</label>"
|
||||||
|
+ '<label title="Vorzeichen umdrehen - für Zähler, die verkehrt herum'
|
||||||
|
+ ' eingebaut sind">'
|
||||||
|
+ '<input type="checkbox" class="form-check-input" data-feld="negativ"'
|
||||||
|
+ (wert.negativ ? " checked" : "") + "> negativ</label>"
|
||||||
|
+ '<label title="Die Zahl steht gross auf der Kachel">'
|
||||||
|
+ '<input type="checkbox" class="form-check-input" data-feld="gross"'
|
||||||
|
+ (wert.gross ? " checked" : "") + "> groß</label>"
|
||||||
|
+ '<button type="button" class="btn btn-sm btn-outline-danger kachel-weg"'
|
||||||
|
+ ' title="Wert entfernen"><i class="bi bi-trash"></i></button>'
|
||||||
|
+ "</div></div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fremden Text sicher ins Markup bringen - Topics kommen aus der Datenbank. */
|
||||||
|
function textSchuetzen(s) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = String(s === undefined || s === null ? "" : s);
|
||||||
|
return d.innerHTML.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Bedienung ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
function kachelHinweis(zeile, text, klasse) {
|
||||||
|
const feld = zeile.querySelector(".kachel-hinweis");
|
||||||
|
if (feld) {
|
||||||
|
feld.textContent = text;
|
||||||
|
feld.className = "kachel-hinweis small " + (klasse || "text-secondary");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aus den Feldern einer Zeile die Wertdefinition zusammensetzen. */
|
||||||
|
function kachelWertLesen(feldZeile) {
|
||||||
|
const hol = (name) => {
|
||||||
|
const el = feldZeile.querySelector('[data-feld="' + name + '"]');
|
||||||
|
if (!el) return undefined;
|
||||||
|
return el.type === "checkbox" ? el.checked : el.value;
|
||||||
|
};
|
||||||
|
const wert = {};
|
||||||
|
const quelle = hol("quelle") || "topic";
|
||||||
|
wert._quelle = quelle; // die Wahl des Benutzers, nicht gespeichert
|
||||||
|
|
||||||
|
if (quelle === "wr") {
|
||||||
|
wert.wechselrichter = String(hol("wechselrichter") || "")
|
||||||
|
.split(",").map(t => t.trim()).filter(Boolean);
|
||||||
|
wert.feld = hol("feld") || "p_AC";
|
||||||
|
} else if (quelle === "sensor") {
|
||||||
|
// Topic und Pfad kommen aus dem Katalog, nicht aus einem Textfeld.
|
||||||
|
const g = ((kachelModell.katalog || [])[parseInt(hol("geraet"), 10)] || { werte: [] });
|
||||||
|
const w = g.werte[parseInt(hol("messwert"), 10)];
|
||||||
|
if (w) {
|
||||||
|
wert.topic = w.topic;
|
||||||
|
if (w.pfad) wert.pfad = w.pfad;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wert.topic = (hol("topic") || "").trim();
|
||||||
|
const pfad = (hol("pfad") || "").trim();
|
||||||
|
if (pfad) wert.pfad = pfad;
|
||||||
|
}
|
||||||
|
if (hol("format")) {
|
||||||
|
wert.format = "leistung";
|
||||||
|
} else {
|
||||||
|
const einheit = (hol("einheit") || "").trim();
|
||||||
|
if (einheit) wert.einheit = einheit;
|
||||||
|
const stellen = hol("stellen");
|
||||||
|
if (stellen !== "" && stellen !== undefined) wert.stellen = parseInt(stellen, 10);
|
||||||
|
}
|
||||||
|
const icon = (hol("icon") || "").trim();
|
||||||
|
if (icon) wert.icon = icon;
|
||||||
|
if (hol("negativ")) wert.negativ = true;
|
||||||
|
if (hol("gross")) wert.gross = true;
|
||||||
|
return wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Den Entwurf einer Kachel aus allen sichtbaren Feldern nachziehen. */
|
||||||
|
function kachelEntwurfLesen(zeile, k) {
|
||||||
|
k._entwurf = Array.from(zeile.querySelectorAll(".kachel-wert")).map(kachelWertLesen);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kachelSenden(zeile, k, werte) {
|
||||||
|
kachelHinweis(zeile, "Wird gespeichert …", "text-secondary");
|
||||||
|
try {
|
||||||
|
const antwort = await fetch("ajax/settings.php?action=kacheln-save", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ floor: k.floor, room: k.room, werte: werte }),
|
||||||
|
});
|
||||||
|
const d = await antwort.json();
|
||||||
|
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||||
|
k.werte = d.werte;
|
||||||
|
k.eigen = d.eigen;
|
||||||
|
delete k._entwurf;
|
||||||
|
zeichneKacheln();
|
||||||
|
// Nach dem Neuzeichnen ist die Zeile eine andere - der Hinweis gehoert
|
||||||
|
// an die neue.
|
||||||
|
const neu = document.querySelector('.kachel-zeile[data-floor="' + k.floor
|
||||||
|
+ '"][data-room="' + k.room + '"]');
|
||||||
|
if (neu && k._offen) kachelHinweis(neu, "Gespeichert.", "text-success");
|
||||||
|
} catch (e) {
|
||||||
|
kachelHinweis(zeile, e.message, "text-danger");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kachelnHolen() {
|
||||||
|
const ziel = document.getElementById("kachelListe");
|
||||||
|
if (!ziel) return;
|
||||||
|
try {
|
||||||
|
const antwort = await fetch("ajax/settings.php?action=kacheln", { cache: "no-store" });
|
||||||
|
const d = await antwort.json();
|
||||||
|
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||||
|
kachelModell = d;
|
||||||
|
delete kachelSymbolBekannt._satz; // neue Antwort, neue Namen
|
||||||
|
kachelIconListeSicherstellen();
|
||||||
|
zeichneKacheln();
|
||||||
|
} catch (e) {
|
||||||
|
ziel.innerHTML = '<div class="alert alert-danger mb-0">Kacheln nicht abrufbar: '
|
||||||
|
+ textSchuetzen(e.message) + "</div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function kachelnBinden() {
|
||||||
|
const liste = document.getElementById("kachelListe");
|
||||||
|
if (!liste) return;
|
||||||
|
|
||||||
|
liste.addEventListener("click", function (ereignis) {
|
||||||
|
const zeile = ereignis.target.closest(".kachel-zeile");
|
||||||
|
if (!zeile || !kachelModell) return;
|
||||||
|
const k = kachelSuchen(zeile.dataset.floor, zeile.dataset.room);
|
||||||
|
if (!k) return;
|
||||||
|
|
||||||
|
if (ereignis.target.closest(".kachel-kopf")) {
|
||||||
|
// Nur eine Kachel gleichzeitig offen: zwei Editoren nebeneinander
|
||||||
|
// fuellen den Bildschirm, und die Zeile darunter waere nicht mehr zu
|
||||||
|
// sehen. Ein Wechsel verwirft ungespeicherte Aenderungen nicht - der
|
||||||
|
// Entwurf bleibt am Modell haengen.
|
||||||
|
const wechsel = !k._offen;
|
||||||
|
kachelModell.kacheln.forEach(a => { a._offen = false; });
|
||||||
|
k._offen = wechsel;
|
||||||
|
if (wechsel && !k._entwurf) k._entwurf = JSON.parse(JSON.stringify(k.werte));
|
||||||
|
zeichneKacheln();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ereignis.target.closest(".kachel-plus")) {
|
||||||
|
kachelEntwurfLesen(zeile, k);
|
||||||
|
k._entwurf.push((kachelModell.katalog || []).length
|
||||||
|
? { _quelle: "sensor", einheit: "" } // erst waehlen, dann feilen
|
||||||
|
: { topic: "", einheit: "" });
|
||||||
|
zeichneKacheln();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ereignis.target.closest(".kachel-weg")) {
|
||||||
|
kachelEntwurfLesen(zeile, k);
|
||||||
|
k._entwurf.splice(parseInt(ereignis.target.closest(".kachel-wert").dataset.i, 10), 1);
|
||||||
|
zeichneKacheln();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ereignis.target.closest(".kachel-speichern")) {
|
||||||
|
kachelEntwurfLesen(zeile, k);
|
||||||
|
kachelSenden(zeile, k, k._entwurf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ereignis.target.closest(".kachel-standard")) {
|
||||||
|
kachelSenden(zeile, k, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Die Quelle und das Leistungsformat blenden Felder um - dafuer muss neu
|
||||||
|
// gezeichnet werden. Alles andere schreibt nur in den Entwurf.
|
||||||
|
liste.addEventListener("change", function (ereignis) {
|
||||||
|
const feld = ereignis.target.closest("[data-feld]");
|
||||||
|
const zeile = ereignis.target.closest(".kachel-zeile");
|
||||||
|
if (!feld || !zeile || !kachelModell) return;
|
||||||
|
const k = kachelSuchen(zeile.dataset.floor, zeile.dataset.room);
|
||||||
|
if (!k) return;
|
||||||
|
const name = feld.dataset.feld;
|
||||||
|
|
||||||
|
kachelEntwurfLesen(zeile, k);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ein neu gewaehlter Messwert bringt seine Einheit mit - sonst stuende an
|
||||||
|
* einer Temperatur weiter "W". Beim Wechsel des Geraets faellt die alte
|
||||||
|
* Auswahl weg, dann ist erst einmal keiner gewaehlt.
|
||||||
|
*/
|
||||||
|
if (name === "geraet" || name === "messwert") {
|
||||||
|
const i = parseInt(feld.closest(".kachel-wert").dataset.i, 10);
|
||||||
|
const wert = k._entwurf[i];
|
||||||
|
const treffer = wert && katalogTreffer(wert);
|
||||||
|
if (treffer) {
|
||||||
|
wert.einheit = treffer.wert.einheit;
|
||||||
|
} else if (name === "geraet") {
|
||||||
|
delete wert.topic;
|
||||||
|
delete wert.pfad;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diese drei blenden Felder um; alles andere schreibt nur in den Entwurf.
|
||||||
|
if (name === "quelle" || name === "format" || name === "geraet" || name === "messwert") {
|
||||||
|
zeichneKacheln();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Vorschau folgt dem Tippen. Neu gezeichnet wird dafuer nicht - das
|
||||||
|
* naehme dem Feld nach jedem Buchstaben den Cursor.
|
||||||
|
*/
|
||||||
|
liste.addEventListener("input", function (ereignis) {
|
||||||
|
const feld = ereignis.target.closest('[data-feld="icon"]');
|
||||||
|
if (!feld) return;
|
||||||
|
const schau = feld.parentElement.querySelector(".kw-symbol-schau");
|
||||||
|
if (schau) schau.innerHTML = kachelSymbolVorschau(feld.value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
const neuLaden = document.getElementById("kachelNeuLaden");
|
||||||
|
if (neuLaden) neuLaden.addEventListener("click", kachelnHolen);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// Das Merken des offenen Reiters macht reiterMerken() in common.js - fuer
|
||||||
|
// jede Leiste der Seite, nicht nur fuer diese.
|
||||||
|
kachelnBinden();
|
||||||
|
kachelnHolen();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Reiter "Geräte": Raumzuordnung, Karteileichen.
|
||||||
|
|
||||||
|
Die Zuordnung war ein Modal auf der Startseite - erreichbar über ein
|
||||||
|
Zahnradsymbol neben den Automatismen, also genau dort, wo man sie nicht
|
||||||
|
sucht. Sie ist eine Einstellung und steht deshalb jetzt hier, zusammen mit
|
||||||
|
dem, was ohne sie fehlte: seit wann ein Gerät nichts mehr meldet, in
|
||||||
|
welchen Automatiken es steckt und der Knopf, mit dem ein abgebautes Gerät
|
||||||
|
verschwindet.
|
||||||
|
|
||||||
|
Letzterer ist nötig, weil der Suchlauf nichts löscht - er legt an und
|
||||||
|
aktualisiert, damit die Automatiken ihre Zustands-Ids behalten. Ein Gerät,
|
||||||
|
das aus dem Haus fliegt, bleibt also stehen, bis es hier jemand entfernt.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
let geraeteModell = null; // { geraete, raeume }
|
||||||
|
const geraeteSieb = { text: "", nurOffen: false };
|
||||||
|
|
||||||
|
/* --- Ableitungen --------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie alt die jüngste Meldung eines Geräts ist, in Worten.
|
||||||
|
*
|
||||||
|
* "alt" heißt: lange genug her, um aufzufallen. Ein Thermostat meldet
|
||||||
|
* stündlich - drei Tage Stille sind schon viel; eine Jalousie meldet nur,
|
||||||
|
* wenn sie fährt. Deshalb faellt nur auf, was über einen Monat still ist.
|
||||||
|
*
|
||||||
|
* Geräte ganz ohne Messwerte - die Somfy-Screens etwa, die nur Befehle
|
||||||
|
* entgegennehmen - haben nichts zu melden. Bei ihnen steht ein Strich und
|
||||||
|
* keine Warnung: sonst stünde die halbe Spalte in Alarmfarbe, ohne dass
|
||||||
|
* irgendetwas fehlt.
|
||||||
|
*/
|
||||||
|
function geraetAlter(g) {
|
||||||
|
const zeitpunkt = g.gesehen;
|
||||||
|
if (!g.messwerte) return { text: "–", alt: false };
|
||||||
|
if (!zeitpunkt) return { text: "nie", alt: true };
|
||||||
|
const d = new Date(String(zeitpunkt).replace(" ", "T"));
|
||||||
|
if (isNaN(d.getTime())) return { text: "–", alt: false };
|
||||||
|
const tage = Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||||
|
if (tage <= 0) return { text: "heute", alt: false };
|
||||||
|
if (tage === 1) return { text: "gestern", alt: false };
|
||||||
|
if (tage < 31) return { text: "vor " + tage + " Tagen", alt: tage > 30 };
|
||||||
|
const monate = Math.round(tage / 30);
|
||||||
|
return { text: "vor " + monate + " Monaten", alt: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Gerät im Modell finden. */
|
||||||
|
function geraetSuchen(id) {
|
||||||
|
return (geraeteModell.geraete || []).find(g => g.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was das Sieb übrig lässt. */
|
||||||
|
function geraeteGefiltert() {
|
||||||
|
const suche = geraeteSieb.text.trim().toLowerCase();
|
||||||
|
return (geraeteModell.geraete || []).filter(function (g) {
|
||||||
|
if (geraeteSieb.nurOffen && g.room) return false;
|
||||||
|
if (!suche) return true;
|
||||||
|
return (g.label + " " + g.type + " " + (g.roomLabel || "")).toLowerCase().indexOf(suche) >= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Zeichnen ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function zeichneGeraete() {
|
||||||
|
const ziel = document.getElementById("geraeteListe");
|
||||||
|
if (!ziel || !geraeteModell) return;
|
||||||
|
|
||||||
|
const alle = geraeteModell.geraete || [];
|
||||||
|
const sichtbar = geraeteGefiltert();
|
||||||
|
const ohneRaum = alle.filter(g => !g.room).length;
|
||||||
|
const tipps = alle.filter(g => !g.room && g.tipp).length;
|
||||||
|
|
||||||
|
let html = '<p class="small text-body-secondary">Die Zuordnung ordnet die Geräteliste '
|
||||||
|
+ 'im Automatik-Editor und die Auswahl der Kachelwerte nach Räumen. Ein neuer '
|
||||||
|
+ 'Suchlauf legt Geräte an und frischt sie auf, löscht aber keine – '
|
||||||
|
+ 'abgebaute Geräte verschwinden nur hier.</p>';
|
||||||
|
|
||||||
|
// Sieb: bei über sechzig Geräten ist Scrollen keine Suche.
|
||||||
|
html += '<div class="geraete-sieb">'
|
||||||
|
+ '<input type="search" class="form-control form-control-sm" id="geraeteSuche"'
|
||||||
|
+ ' placeholder="Gerät, Art oder Raum suchen" value="' + textSchuetzen(geraeteSieb.text) + '">'
|
||||||
|
+ '<div class="form-check form-switch mb-0">'
|
||||||
|
+ '<input class="form-check-input" type="checkbox" id="geraeteNurOffen"'
|
||||||
|
+ (geraeteSieb.nurOffen ? " checked" : "") + ">"
|
||||||
|
+ '<label class="form-check-label small" for="geraeteNurOffen">nur ohne Raum</label>'
|
||||||
|
+ "</div></div>";
|
||||||
|
|
||||||
|
if (tipps) {
|
||||||
|
// Viele Geräte tragen ihren Raum im Topic (Raumtemp/OG/Bad/Temp[degC]).
|
||||||
|
// Das von Hand nachzuklicken ist stumpfe Arbeit für den Rechner.
|
||||||
|
html += '<div class="alert alert-secondary py-2 small d-flex justify-content-between'
|
||||||
|
+ ' align-items-center gap-2 mb-2"><span>' + tipps + " Gerät(e) ohne Raum verraten ihn"
|
||||||
|
+ ' im MQTT-Topic.</span><button type="button" class="btn btn-outline-secondary'
|
||||||
|
+ ' btn-sm text-nowrap" id="geraeteTipps">Vorschläge übernehmen</button></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="geraete-tafel">'
|
||||||
|
+ '<table class="table table-sm table-hover align-middle mb-0"><thead><tr>'
|
||||||
|
+ "<th>Gerät</th>"
|
||||||
|
+ '<th class="d-none d-md-table-cell">Art</th>'
|
||||||
|
+ "<th>Raum</th>"
|
||||||
|
+ '<th class="d-none d-sm-table-cell">Gemeldet</th>'
|
||||||
|
+ '<th class="d-none d-lg-table-cell">Automatiken</th>'
|
||||||
|
+ '<th class="text-end"></th>'
|
||||||
|
+ "</tr></thead><tbody>";
|
||||||
|
|
||||||
|
html += sichtbar.length
|
||||||
|
? sichtbar.map(g => geraetZeile(g)).join("")
|
||||||
|
: '<tr><td colspan="6" class="text-body-secondary py-3">Nichts gefunden.</td></tr>';
|
||||||
|
|
||||||
|
html += "</tbody></table></div>"
|
||||||
|
+ '<div class="geraete-fuss small"><span class="text-body-secondary">'
|
||||||
|
+ alle.length + " Geräte, " + ohneRaum + " ohne Raum"
|
||||||
|
+ (sichtbar.length !== alle.length ? " · " + sichtbar.length + " angezeigt" : "")
|
||||||
|
+ '</span><span class="geraete-stand"></span></div>';
|
||||||
|
|
||||||
|
ziel.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Zeile der Tafel. */
|
||||||
|
function geraetZeile(g) {
|
||||||
|
const alter = geraetAlter(g);
|
||||||
|
const benutzt = g.benutzt || [];
|
||||||
|
|
||||||
|
// Die Raumauswahl nach Etagen gruppiert - dieselbe Reihenfolge wie überall,
|
||||||
|
// sie kommt schon sortiert aus raumListe().
|
||||||
|
let auswahl = '<option value=""' + (g.room ? "" : " selected") + ">–</option>";
|
||||||
|
let etage = null;
|
||||||
|
(geraeteModell.raeume || []).forEach(function (rm) {
|
||||||
|
if (rm.floor !== etage) {
|
||||||
|
if (etage !== null) auswahl += "</optgroup>";
|
||||||
|
etage = rm.floor;
|
||||||
|
auswahl += '<optgroup label="' + textSchuetzen(rm.floor) + '">';
|
||||||
|
}
|
||||||
|
auswahl += '<option value="' + textSchuetzen(rm.key) + '"'
|
||||||
|
+ (g.room === rm.key ? " selected" : "") + ">"
|
||||||
|
+ textSchuetzen(rm.room) + "</option>";
|
||||||
|
});
|
||||||
|
if (etage !== null) auswahl += "</optgroup>";
|
||||||
|
|
||||||
|
// Der Papierkorb steht sechzigmal untereinander - in Rot waere die Tafel
|
||||||
|
// eine Warnleuchte. Er faerbt sich erst, wenn man ihn anfasst.
|
||||||
|
const weg = benutzt.length
|
||||||
|
? '<button type="button" class="btn btn-sm geraet-weg" disabled'
|
||||||
|
+ ' title="Steckt in einer Automatik"><i class="bi bi-trash3"></i></button>'
|
||||||
|
: '<button type="button" class="btn btn-sm geraet-weg"'
|
||||||
|
+ ' title="Gerät entfernen"><i class="bi bi-trash3"></i></button>';
|
||||||
|
|
||||||
|
return '<tr data-id="' + g.id + '"' + (g.room ? "" : ' class="ohne-raum"') + ">"
|
||||||
|
+ '<td class="name">' + textSchuetzen(g.label)
|
||||||
|
/*
|
||||||
|
* Unter der Beschriftung steht genau eine Zusatzzeile - schmal wird die
|
||||||
|
* Art dort gezeigt, weil ihre Spalte weg ist; breit die Anzahl der
|
||||||
|
* Messwerte und Kommandos, die vor dem Loeschen interessiert. Nie beides:
|
||||||
|
* auf dem Handy braeuchte das drei Zeilen und erschluege den Namen.
|
||||||
|
*/
|
||||||
|
+ '<span class="neben">'
|
||||||
|
+ '<span class="d-md-none">' + textSchuetzen(g.type) + "</span>"
|
||||||
|
+ '<span class="d-none d-md-inline">' + g.messwerte + " Messwerte · "
|
||||||
|
+ g.kommandos + " Kommandos</span></span></td>"
|
||||||
|
+ '<td class="d-none d-md-table-cell small text-body-secondary">'
|
||||||
|
+ textSchuetzen(g.type) + "</td>"
|
||||||
|
+ '<td class="raum"><select class="form-select form-select-sm" data-actor="' + g.id + '"'
|
||||||
|
+ (g.tipp ? ' data-tipp="' + textSchuetzen(g.tipp) + '"' : "") + ">"
|
||||||
|
+ auswahl + "</select></td>"
|
||||||
|
+ '<td class="d-none d-sm-table-cell small alter' + (alter.alt ? " lange" : "") + '">'
|
||||||
|
+ alter.text + "</td>"
|
||||||
|
+ '<td class="d-none d-lg-table-cell small">'
|
||||||
|
+ (benutzt.length
|
||||||
|
? '<span class="badge text-bg-secondary" title="' + textSchuetzen(benutzt.join(", ")) + '">'
|
||||||
|
+ benutzt.length + "</span>"
|
||||||
|
: '<span class="text-body-secondary">–</span>')
|
||||||
|
+ "</td>"
|
||||||
|
+ '<td class="text-end">' + weg + "</td></tr>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kurze Rückmeldung im Fuß der Tafel; sie verblasst von selbst. */
|
||||||
|
function geraeteStand(text, klasse) {
|
||||||
|
const ziel = document.querySelector("#geraeteListe .geraete-stand");
|
||||||
|
if (!ziel) return;
|
||||||
|
ziel.className = "geraete-stand " + (klasse || "text-body-secondary");
|
||||||
|
ziel.textContent = text;
|
||||||
|
clearTimeout(geraeteStand._uhr);
|
||||||
|
if (klasse !== "text-danger") {
|
||||||
|
geraeteStand._uhr = setTimeout(() => { if (ziel.textContent === text) ziel.textContent = ""; }, 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Sprechen mit dem Endpunkt ------------------------------------------- */
|
||||||
|
|
||||||
|
async function geraeteFrage(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine oder mehrere Zuordnungen speichern.
|
||||||
|
*
|
||||||
|
* Gespeichert wird sofort beim Umstellen und ohne die Tafel neu zu zeichnen:
|
||||||
|
* wer sich durch sechzig Geräte arbeitet, will nicht nach jedem Klick seinen
|
||||||
|
* Platz in der Liste wiederfinden. Nur die Zeilenfarbe wandert mit.
|
||||||
|
*/
|
||||||
|
async function geraeteRaeumeSenden(zuordnung) {
|
||||||
|
const ids = Object.keys(zuordnung);
|
||||||
|
try {
|
||||||
|
await geraeteFrage("geraete-raeume", { rooms: zuordnung });
|
||||||
|
ids.forEach(function (id) {
|
||||||
|
const g = geraetSuchen(parseInt(id, 10));
|
||||||
|
if (g) {
|
||||||
|
g.room = zuordnung[id] || null;
|
||||||
|
const raum = (geraeteModell.raeume || []).find(r => r.key === g.room);
|
||||||
|
g.roomLabel = raum ? raum.label : null;
|
||||||
|
}
|
||||||
|
const zeile = document.querySelector('#geraeteListe tr[data-id="' + id + '"]');
|
||||||
|
if (zeile) zeile.classList.toggle("ohne-raum", !zuordnung[id]);
|
||||||
|
});
|
||||||
|
geraeteStand(ids.length > 1 ? ids.length + " Zuordnungen gespeichert." : "Gespeichert.",
|
||||||
|
"text-success");
|
||||||
|
} catch (e) {
|
||||||
|
geraeteStand(e.message, "text-danger");
|
||||||
|
zeichneGeraete(); // Anzeige zurück auf das, was wirklich gilt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Gerät entfernen - endgültig, deshalb mit Nachfrage. */
|
||||||
|
async function geraetLoeschenFragen(id) {
|
||||||
|
const g = geraetSuchen(id);
|
||||||
|
if (!g) return;
|
||||||
|
if (!confirm("Gerät \"" + g.label + "\" mit " + g.messwerte + " Messwerten und "
|
||||||
|
+ g.kommandos + " Kommandos wirklich löschen?\n\n"
|
||||||
|
+ "Ein neuer Suchlauf legt es wieder an, wenn es noch antwortet.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const d = await geraeteFrage("geraet-loeschen", { id: id });
|
||||||
|
geraeteModell.geraete = d.geraete;
|
||||||
|
zeichneGeraete();
|
||||||
|
geraeteStand("„" + d.geloescht + "“ entfernt.", "text-success");
|
||||||
|
} catch (e) {
|
||||||
|
geraeteStand(e.message, "text-danger");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function geraeteHolen() {
|
||||||
|
const ziel = document.getElementById("geraeteListe");
|
||||||
|
if (!ziel) return;
|
||||||
|
try {
|
||||||
|
geraeteModell = await geraeteFrage("geraete");
|
||||||
|
zeichneGeraete();
|
||||||
|
} catch (e) {
|
||||||
|
ziel.innerHTML = '<div class="alert alert-danger mb-0">Geräte nicht abrufbar: '
|
||||||
|
+ textSchuetzen(e.message) + "</div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Bedienung ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
function geraeteBinden() {
|
||||||
|
const liste = document.getElementById("geraeteListe");
|
||||||
|
if (!liste) return;
|
||||||
|
|
||||||
|
liste.addEventListener("change", function (ereignis) {
|
||||||
|
if (ereignis.target.id === "geraeteNurOffen") {
|
||||||
|
geraeteSieb.nurOffen = ereignis.target.checked;
|
||||||
|
zeichneGeraete();
|
||||||
|
// Der Schalter ist nach dem Neuzeichnen ein anderes Element - ohne das
|
||||||
|
// hier verlöre die Tastatur nach dem Umlegen ihren Platz.
|
||||||
|
const schalter = document.getElementById("geraeteNurOffen");
|
||||||
|
if (schalter) schalter.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wahl = ereignis.target.closest("select[data-actor]");
|
||||||
|
if (wahl) {
|
||||||
|
const zuordnung = {};
|
||||||
|
zuordnung[wahl.dataset.actor] = wahl.value;
|
||||||
|
geraeteRaeumeSenden(zuordnung);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
liste.addEventListener("input", function (ereignis) {
|
||||||
|
if (ereignis.target.id !== "geraeteSuche") return;
|
||||||
|
geraeteSieb.text = ereignis.target.value;
|
||||||
|
zeichneGeraete();
|
||||||
|
// Nach dem Neuzeichnen ist das Feld ein anderes - der Cursor gehört zurück.
|
||||||
|
const feld = document.getElementById("geraeteSuche");
|
||||||
|
if (feld) {
|
||||||
|
feld.focus();
|
||||||
|
feld.setSelectionRange(feld.value.length, feld.value.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
liste.addEventListener("click", function (ereignis) {
|
||||||
|
if (ereignis.target.closest("#geraeteTipps")) {
|
||||||
|
// Alle offenen Vorschläge auf einmal - eine Anfrage, ein Neuzeichnen.
|
||||||
|
const zuordnung = {};
|
||||||
|
liste.querySelectorAll("select[data-tipp]").forEach(function (s) {
|
||||||
|
if (!s.value) {
|
||||||
|
s.value = s.dataset.tipp;
|
||||||
|
zuordnung[s.dataset.actor] = s.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (Object.keys(zuordnung).length) {
|
||||||
|
geraeteRaeumeSenden(zuordnung).then(zeichneGeraete);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const weg = ereignis.target.closest(".geraet-weg");
|
||||||
|
if (weg) {
|
||||||
|
geraetLoeschenFragen(parseInt(weg.closest("tr").dataset.id, 10));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const neuLaden = document.getElementById("geraeteNeuLaden");
|
||||||
|
if (neuLaden) neuLaden.addEventListener("click", geraeteHolen);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
geraeteBinden();
|
||||||
|
geraeteHolen();
|
||||||
|
});
|
||||||
|
|||||||
+383
-79
@@ -15,6 +15,11 @@
|
|||||||
* Abrufen liegen im Ruhezustand bis zu einer Stunde. Die Anzeige nennt deshalb
|
* Abrufen liegen im Ruhezustand bis zu einer Stunde. Die Anzeige nennt deshalb
|
||||||
* immer auch das Alter des Standes, statt einen alten Wert als aktuell
|
* immer auch das Alter des Standes, statt einen alten Wert als aktuell
|
||||||
* auszugeben.
|
* auszugeben.
|
||||||
|
*
|
||||||
|
* Der Ladestand hat keine eigene Anzeige mehr: er steckt in der Akkuplatte der
|
||||||
|
* Fahrzeuggrafik - Füllhöhe, Zielmarke, Prozent und Reichweite setzt
|
||||||
|
* zeichneAkku(). Was sich nicht zeichnen lässt, steht in den Kennzahlen
|
||||||
|
* daneben (zeichneKennzahlen).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
var mqttData = {};
|
var mqttData = {};
|
||||||
@@ -86,6 +91,70 @@ function dauer(minuten) {
|
|||||||
return Math.floor(m / 60) + " h " + (m % 60) + " min";
|
return Math.floor(m / 60) + " h " + (m % 60) + " min";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- Hinweise */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Erklärungen zu einer Zahl - woher sie stammt, was sie nicht abdeckt.
|
||||||
|
*
|
||||||
|
* Im title stehen sie am Zeiger gut, auf einem Berührungsgerät aber nirgends:
|
||||||
|
* Überfahren gibt es dort nicht, und der title erscheint nie. Das übliche
|
||||||
|
* Mittel dagegen ist, aus der Erklärung etwas Anfassbares zu machen - einen
|
||||||
|
* Auslöser, der Fokus annehmen kann, und ein Fenster, das beim nächsten
|
||||||
|
* Tippen daneben oder mit Esc wieder verschwindet. Genau das leistet ein
|
||||||
|
* Popover mit den Auslösern "focus hover": tippen öffnet, danebentippen
|
||||||
|
* schliesst, für Maus und Tastatur bleibt es beim Überfahren und Tabben.
|
||||||
|
*
|
||||||
|
* Damit das gefunden wird, trägt der Auslöser sichtbar ein Zeichen - die
|
||||||
|
* Klasse hat-hinweis setzt es. Eine Erklärung, die niemand vermutet, ist so
|
||||||
|
* gut wie keine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Erklärung an ein Element hängen; ohne Text wird sie wieder entfernt. */
|
||||||
|
function setzeHinweis(el, text) {
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// Ohne Bootstrap bleibt der title. Weniger gut, aber nicht nichts.
|
||||||
|
if (typeof bootstrap === "undefined" || !bootstrap.Popover) {
|
||||||
|
if (text) el.title = text; else el.removeAttribute("title");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alt = bootstrap.Popover.getInstance(el);
|
||||||
|
if (alt) alt.dispose();
|
||||||
|
el.removeAttribute("title"); // sonst zeigt der Browser beides
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
el.classList.remove("hat-hinweis");
|
||||||
|
["tabindex", "role", "aria-label"].forEach(a => el.removeAttribute(a));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.classList.add("hat-hinweis");
|
||||||
|
el.setAttribute("tabindex", "0"); // ohne Fokus kein Antippen auf iOS
|
||||||
|
el.setAttribute("role", "button");
|
||||||
|
el.setAttribute("aria-label", "Erklärung anzeigen");
|
||||||
|
new bootstrap.Popover(el, {
|
||||||
|
content: text,
|
||||||
|
trigger: "focus hover",
|
||||||
|
placement: "top",
|
||||||
|
container: "body", // sonst schneidet die Kachel ab
|
||||||
|
customClass: "skoda-erklaerung"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erklärungen unterhalb eines Knotens auflösen, bevor er neu gebaut wird.
|
||||||
|
* Ein offenes Fenster gehört zu seinem Auslöser - verschwindet der aus dem
|
||||||
|
* Dokument, bliebe es sonst am Bildschirm stehen.
|
||||||
|
*/
|
||||||
|
function loeseHinweise(wurzel) {
|
||||||
|
if (!wurzel || typeof bootstrap === "undefined" || !bootstrap.Popover) return;
|
||||||
|
wurzel.querySelectorAll(".hat-hinweis").forEach(function (el) {
|
||||||
|
const p = bootstrap.Popover.getInstance(el);
|
||||||
|
if (p) p.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function hole(was, extra = "") {
|
async function hole(was, extra = "") {
|
||||||
const antwort = await fetch("ajax/skoda.php?was=" + was + extra, { cache: "no-store" });
|
const antwort = await fetch("ajax/skoda.php?was=" + was + extra, { cache: "no-store" });
|
||||||
if (!antwort.ok) throw new Error("HTTP " + antwort.status);
|
if (!antwort.ok) throw new Error("HTTP " + antwort.status);
|
||||||
@@ -115,7 +184,6 @@ const ZUSTANDSWORT = {
|
|||||||
const TEILE = [
|
const TEILE = [
|
||||||
{ id: "teil-doors", feld: "doors", name: "Türen", zu: "CLOSED" },
|
{ id: "teil-doors", feld: "doors", name: "Türen", zu: "CLOSED" },
|
||||||
{ id: "teil-windows", feld: "windows", name: "Fenster", zu: "CLOSED" },
|
{ id: "teil-windows", feld: "windows", name: "Fenster", zu: "CLOSED" },
|
||||||
{ id: "teil-sunroof", feld: "sunroof", name: "Panoramadach", zu: "CLOSED" },
|
|
||||||
{ id: "teil-trunk", feld: "trunk", name: "Heckklappe", zu: "CLOSED" },
|
{ id: "teil-trunk", feld: "trunk", name: "Heckklappe", zu: "CLOSED" },
|
||||||
{ id: "teil-bonnet", feld: "bonnet", name: "Motorhaube", zu: "CLOSED" },
|
{ id: "teil-bonnet", feld: "bonnet", name: "Motorhaube", zu: "CLOSED" },
|
||||||
{ id: "teil-lights", feld: "lights", name: "Licht", zu: "OFF" }
|
{ id: "teil-lights", feld: "lights", name: "Licht", zu: "OFF" }
|
||||||
@@ -201,6 +269,17 @@ function zeichneTeile(stand) {
|
|||||||
|
|
||||||
/* -------------------------------------------------------------- Live-Block */
|
/* -------------------------------------------------------------- Live-Block */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der Live-Block ist die Fahrzeugkachel: die Draufsicht mit dem Ladestand in
|
||||||
|
* der Akkuplatte und die Kennzahlen daneben.
|
||||||
|
*
|
||||||
|
* Er arbeitet in drei Schritten. liveWerte() sammelt aus MQTT und Tabelle
|
||||||
|
* einen Satz fertiger Werte, jede zeichne*-Funktion zeigt genau einen Bereich
|
||||||
|
* davon, und zeichneLive() ruft sie der Reihe nach auf. Keine der
|
||||||
|
* Zeichenfunktionen holt sich selbst etwas - sie bekommen alles übergeben und
|
||||||
|
* bleiben dadurch einzeln prüfbar und für sich verwendbar.
|
||||||
|
*/
|
||||||
|
|
||||||
let livePlanung = null;
|
let livePlanung = null;
|
||||||
function planeLiveAnzeige() {
|
function planeLiveAnzeige() {
|
||||||
if (livePlanung) return; // höchstens fünfmal je Sekunde
|
if (livePlanung) return; // höchstens fünfmal je Sekunde
|
||||||
@@ -209,108 +288,289 @@ function planeLiveAnzeige() {
|
|||||||
|
|
||||||
let letzterStand = null; // letzte Zeile aus der Tabelle
|
let letzterStand = null; // letzte Zeile aus der Tabelle
|
||||||
|
|
||||||
function zeichneLive() {
|
/*
|
||||||
|
* Maße der Akkuplatte in Einheiten der viewBox. Sie stehen genau einmal hier
|
||||||
|
* und einmal im Markup (restricted/skoda.php); wer die Platte verschiebt,
|
||||||
|
* ändert beides.
|
||||||
|
*/
|
||||||
|
const AKKU = { oben: 108, hoehe: 270 };
|
||||||
|
|
||||||
|
/* Zustand der Platte. Die Klasse sitzt am <svg>, nicht an der Gruppe: der
|
||||||
|
Farbverlauf steht in <defs> und muss dieselbe Farbe erben. */
|
||||||
|
const AKKU_KLASSEN = ["akku-laedt", "akku-niedrig", "akku-unbekannt"];
|
||||||
|
|
||||||
|
/* Ladestand, unter dem die Platte rot wird. Reichweitenangst beginnt nicht
|
||||||
|
bei null, sondern dort, wo eine Fahrt zu planen ist. */
|
||||||
|
const AKKU_NIEDRIG = 15;
|
||||||
|
|
||||||
|
const LADEART = { AC: "Wechselstrom", DC: "Gleichstrom" };
|
||||||
|
|
||||||
|
const KLIMA = {
|
||||||
|
OFF: "aus", COOLING: "kühlt", HEATING: "heizt",
|
||||||
|
HEATING_AUXILIARY: "Zuheizer", VENTILATION: "lüftet",
|
||||||
|
COMPLETED: "fertig", UNKNOWN: "unbekannt", UNSUPPORTED: "–"
|
||||||
|
};
|
||||||
|
/* Zustände, in denen die Klimatisierung tatsächlich arbeitet - nur dann hebt
|
||||||
|
sich ihre Kachel hervor. */
|
||||||
|
const KLIMA_LAEUFT = ["COOLING", "HEATING", "HEATING_AUXILIARY", "VENTILATION"];
|
||||||
|
|
||||||
|
/* Das Zeichen der Klimakachel sagt, was gerade geschieht, statt nur "Klima" zu
|
||||||
|
bebildern: Schneeflocke fürs Kühlen, Sonne fürs Heizen, Wind fürs Lüften.
|
||||||
|
Der Lüfter steht für alles Übrige - aus, fertig, unbekannt. */
|
||||||
|
const KLIMA_ZEICHEN = {
|
||||||
|
COOLING: "bi-snow",
|
||||||
|
HEATING: "bi-thermometer-sun",
|
||||||
|
HEATING_AUXILIARY: "bi-fire",
|
||||||
|
VENTILATION: "bi-wind",
|
||||||
|
COMPLETED: "bi-check2-circle"
|
||||||
|
};
|
||||||
|
const KLIMA_ZEICHEN_SONST = "bi-fan";
|
||||||
|
|
||||||
|
const LADEZUSTAND = {
|
||||||
|
CHARGING: ["lädt", "text-bg-success"],
|
||||||
|
CONSERVING: ["hält Ladung", "text-bg-secondary"],
|
||||||
|
READY_FOR_CHARGING: ["bereit", "text-bg-info"],
|
||||||
|
CONNECT_CABLE: ["nicht angesteckt", "text-bg-secondary"],
|
||||||
|
CHARGING_INTERRUPTED: ["unterbrochen", "text-bg-warning"],
|
||||||
|
DISCHARGING: ["entlädt", "text-bg-warning"]
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------- kleine Werkzeuge */
|
||||||
|
|
||||||
|
/** Zahl aus einem Tabellenfeld; leer, null und "" ergeben null statt 0. */
|
||||||
|
function feldZahl(wert) {
|
||||||
|
if (wert === null || wert === undefined || wert === "") return null;
|
||||||
|
const z = Number(wert);
|
||||||
|
return Number.isFinite(z) ? z : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text eines Elements setzen, sofern die Seite es überhaupt hat. */
|
||||||
|
function setzeText(id, text) {
|
||||||
|
const el = $(id);
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Klasse an einem Element setzen oder entfernen. */
|
||||||
|
function schalteKlasse(id, klasse, an) {
|
||||||
|
const el = $(id);
|
||||||
|
if (el) el.classList.toggle(klasse, !!an);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Genau eine Klasse aus einer Gruppe setzen, die übrigen entfernen. */
|
||||||
|
function setzeAusGruppe(el, gruppe, aktiv) {
|
||||||
|
if (!el) return;
|
||||||
|
gruppe.forEach(k => el.classList.toggle(k, k === aktiv));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "bis 14:20 Uhr" - der Zeitpunkt hinter der gemeldeten Restladezeit. */
|
||||||
|
function fertigUm(minuten) {
|
||||||
|
const m = Number(minuten);
|
||||||
|
if (!Number.isFinite(m) || m <= 0) return "";
|
||||||
|
const ende = new Date(Date.now() + m * 60000);
|
||||||
|
return "bis " + ende.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) + " Uhr";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** y-Kante eines Ladestands auf der Platte: 0 % unten, 100 % oben. */
|
||||||
|
function akkuKante(prozent) {
|
||||||
|
const p = Math.max(0, Math.min(100, Number(prozent) || 0));
|
||||||
|
return AKKU.oben + AKKU.hoehe * (1 - p / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Rechteck vom Boden der Platte bis zum Ladestand aufziehen. */
|
||||||
|
function setzeFuellstand(el, prozent) {
|
||||||
|
if (!el) return;
|
||||||
|
const y = akkuKante(prozent);
|
||||||
|
el.setAttribute("y", y.toFixed(1));
|
||||||
|
el.setAttribute("height", (AKKU.oben + AKKU.hoehe - y).toFixed(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Sammelstelle */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alles, was die Fahrzeugkachel zeigt, an einer Stelle zusammengetragen.
|
||||||
|
* MQTT hat überall Vorrang, die Tabelle ist der Rückfall - sie kann eine
|
||||||
|
* Stunde alt sein, MQTT ist von eben.
|
||||||
|
*/
|
||||||
|
function liveWerte() {
|
||||||
const stand = letzterStand;
|
const stand = letzterStand;
|
||||||
|
|
||||||
// Ladestand: MQTT hat Vorrang, die Tabelle ist der Rückfall.
|
|
||||||
const soc = mqZahl("evSOC") ?? (stand ? Number(stand.soc) : null);
|
|
||||||
const ziel = stand && stand.target_soc ? Number(stand.target_soc) : null;
|
|
||||||
const zustand = mqText("evChgState") || (stand ? stand.chg_state : null);
|
const zustand = mqText("evChgState") || (stand ? stand.chg_state : null);
|
||||||
const laedt = zustand === "CHARGING";
|
const laedt = zustand === "CHARGING";
|
||||||
|
|
||||||
// Ring: der Umfang ist 2*pi*r, der Rest ergibt sich aus dem Ladestand.
|
|
||||||
const ring = $("skodaRing");
|
|
||||||
if (ring) {
|
|
||||||
const umfang = 2 * Math.PI * 82;
|
|
||||||
ring.style.strokeDasharray = umfang;
|
|
||||||
ring.style.strokeDashoffset = umfang * (1 - (soc || 0) / 100);
|
|
||||||
ring.style.stroke = laedt ? "#4caf7d" : (soc !== null && soc < 15 ? "#e06060" : "#7aa7c7");
|
|
||||||
}
|
|
||||||
const socText = $("skodaSocText");
|
|
||||||
if (socText) socText.textContent = soc === null ? "–" : nf(soc);
|
|
||||||
|
|
||||||
// Zielmarke auf dem Ring
|
|
||||||
const zielLinie = $("skodaZiel");
|
|
||||||
if (zielLinie) {
|
|
||||||
zielLinie.style.display = ziel ? "" : "none";
|
|
||||||
if (ziel) zielLinie.setAttribute("transform", "rotate(" + (ziel * 3.6) + " 100 100)");
|
|
||||||
}
|
|
||||||
$("skodaZielText").textContent = ziel ? nf(ziel) + " %" : "–";
|
|
||||||
|
|
||||||
// Reichweite. range_m trägt den Rohwert der API; über 1500 kann nur Meter
|
// Reichweite. range_m trägt den Rohwert der API; über 1500 kann nur Meter
|
||||||
// gemeint sein, ein Akku trägt keine 1500 Kilometer.
|
// gemeint sein, ein Akku trägt keine 1500 Kilometer.
|
||||||
let km = mqZahl("evRange");
|
let km = mqZahl("evRange");
|
||||||
if (km === null && stand && stand.range_m !== null && stand.range_m !== undefined) {
|
if (km === null && stand) {
|
||||||
const roh = Number(stand.range_m);
|
const roh = feldZahl(stand.range_m);
|
||||||
km = roh > 1500 ? roh / 1000 : roh;
|
if (roh !== null) km = roh > 1500 ? roh / 1000 : roh;
|
||||||
}
|
}
|
||||||
$("skodaReichweite").textContent = km === null ? "–" : nf(km) + " km";
|
|
||||||
|
|
||||||
// Ladeleistung: das Fahrzeug meldet DC-seitig, die Wallbox zählt am
|
// Ladeleistung: das Fahrzeug meldet DC-seitig, die Wallbox zählt am
|
||||||
// Hausanschluss. Beim Laden ist die Wallbox der belastbarere Wert.
|
// Hausanschluss. Beim Laden ist die Wallbox der belastbarere Wert.
|
||||||
const wbKw = mqZahl("evPower");
|
const wbKw = mqZahl("evPower");
|
||||||
const carKw = stand && stand.chg_kw !== null ? Number(stand.chg_kw) : null;
|
const carKw = stand ? feldZahl(stand.chg_kw) : null;
|
||||||
const kw = laedt ? (wbKw || carKw) : (wbKw && wbKw > 0.2 ? wbKw : null);
|
|
||||||
$("skodaLeistung").textContent = kw ? nf(kw, 1) + " kW" : "–";
|
|
||||||
|
|
||||||
const restMin = mqZahl("evRemChrgTime") ?? (stand ? Number(stand.chg_rem_min) : null);
|
const pvW = mqZahl("P_PV");
|
||||||
$("skodaRestzeit").textContent = laedt ? dauer(restMin) : "–";
|
|
||||||
|
|
||||||
$("skodaKm").textContent = stand && stand.odo_km ? nf(stand.odo_km) + " km" : "–";
|
return {
|
||||||
|
zustand: zustand,
|
||||||
const klima = stand ? stand.ac_state : null;
|
laedt: laedt,
|
||||||
const KLIMA = { OFF: "aus", COOLING: "kühlt", HEATING: "heizt",
|
soc: mqZahl("evSOC") ?? (stand ? feldZahl(stand.soc) : null),
|
||||||
HEATING_AUXILIARY: "Zuheizer", VENTILATION: "lüftet",
|
ziel: stand ? feldZahl(stand.target_soc) : null,
|
||||||
COMPLETED: "fertig", UNKNOWN: "unbekannt", UNSUPPORTED: "–" };
|
km: km,
|
||||||
$("skodaKlima").textContent = klima ? (KLIMA[klima] || klima) : "–";
|
kw: laedt ? (wbKw || carKw) : (wbKw && wbKw > 0.2 ? wbKw : null),
|
||||||
|
pvKw: pvW === null ? null : Math.max(0, pvW / 1000),
|
||||||
// Ladezustand als Kennzeichen
|
restMin: mqZahl("evRemChrgTime") ?? (stand ? feldZahl(stand.chg_rem_min) : null),
|
||||||
const ZUSTAND = {
|
ladeart: stand ? stand.chg_type : null,
|
||||||
CHARGING: ["lädt", "text-bg-success"],
|
odo: stand ? feldZahl(stand.odo_km) : null,
|
||||||
CONSERVING: ["hält Ladung", "text-bg-secondary"],
|
klima: stand ? stand.ac_state : null,
|
||||||
READY_FOR_CHARGING: ["bereit", "text-bg-info"],
|
// null heißt: das Fahrzeug meldet den Zustand des Schlosses selbst nicht.
|
||||||
CONNECT_CABLE: ["nicht angesteckt", "text-bg-secondary"],
|
verriegelt: (stand && stand.locked !== null && stand.locked !== undefined)
|
||||||
CHARGING_INTERRUPTED: ["unterbrochen", "text-bg-warning"],
|
? Number(stand.locked) === 1 : null
|
||||||
DISCHARGING: ["entlädt", "text-bg-warning"]
|
|
||||||
};
|
};
|
||||||
const kz = $("skodaLadeZustand");
|
}
|
||||||
const eintrag = ZUSTAND[zustand] || [zustand || "–", "text-bg-secondary"];
|
|
||||||
kz.textContent = eintrag[0];
|
/* --------------------------------------------------------- Zeichenschritte */
|
||||||
kz.className = "badge " + eintrag[1];
|
|
||||||
|
/**
|
||||||
|
* Die Akkuplatte im Fahrzeugboden - die Ladestandsanzeige der Seite.
|
||||||
|
*
|
||||||
|
* Gefüllt wird von hinten nach vorn, also im Bild von unten nach oben. Die
|
||||||
|
* Farbe kommt aus einer einzigen CSS-Variablen, die an der Zustandsklasse des
|
||||||
|
* <svg> hängt; hier wird nur die Klasse gewechselt.
|
||||||
|
*/
|
||||||
|
function zeichneAkku(w) {
|
||||||
|
const bild = $("skodaAuto");
|
||||||
|
if (!bild) return;
|
||||||
|
|
||||||
|
const bekannt = w.soc !== null && Number.isFinite(w.soc);
|
||||||
|
setzeAusGruppe(bild, AKKU_KLASSEN, !bekannt ? "akku-unbekannt"
|
||||||
|
: (w.laedt ? "akku-laedt" : (w.soc < AKKU_NIEDRIG ? "akku-niedrig" : null)));
|
||||||
|
|
||||||
|
const stand = bekannt ? w.soc : 0;
|
||||||
|
setzeFuellstand($("akkuFuellung"), stand);
|
||||||
|
setzeFuellstand($("akkuPegelFeld"), stand); // Schnittkante für die Strömung
|
||||||
|
|
||||||
|
// Die helle Kante sitzt auf der Füllung. Bei leerer Platte hat sie nichts
|
||||||
|
// zu begrenzen und bliebe sonst als Strich am Boden liegen.
|
||||||
|
const kante = $("akkuKante");
|
||||||
|
if (kante) {
|
||||||
|
kante.setAttribute("y", (akkuKante(stand) - 1.25).toFixed(1));
|
||||||
|
kante.style.display = (bekannt && stand > 0) ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zielmarke. Gezeichnet ist sie auf 100 Prozent; verschoben wird die ganze
|
||||||
|
// Gruppe, damit Linie und Beschriftung beieinanderbleiben.
|
||||||
|
const marke = $("akkuZielMarke");
|
||||||
|
if (marke) {
|
||||||
|
marke.style.display = w.ziel ? "" : "none";
|
||||||
|
if (w.ziel) {
|
||||||
|
marke.style.transform = "translateY(" + (akkuKante(w.ziel) - AKKU.oben).toFixed(1) + "px)";
|
||||||
|
setzeText("akkuZielText", "Ziel " + nf(w.ziel) + " %");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setzeText("akkuText", bekannt ? nf(w.soc) : "–");
|
||||||
|
setzeText("akkuNeben", w.km === null ? "–" : nf(w.km) + " km");
|
||||||
|
|
||||||
|
// Der Titel der Gruppe ist Sprechblase und Vorlesetext zugleich - er nennt
|
||||||
|
// in Worten, was die Platte zeigt.
|
||||||
|
setzeText("akkuTitel", "Antriebsbatterie: "
|
||||||
|
+ (bekannt ? nf(w.soc) + " Prozent" : "Ladestand unbekannt")
|
||||||
|
+ (w.ziel ? ", Ladeziel " + nf(w.ziel) + " Prozent" : "")
|
||||||
|
+ (w.km === null ? "" : ", Reichweite " + nf(w.km) + " Kilometer")
|
||||||
|
+ (w.laedt ? ", lädt" : ""));
|
||||||
|
|
||||||
|
// Der Ladeanschluss pulst, der Weg zur Platte fließt - beides hängt an
|
||||||
|
// derselben Zustandsklasse wie die Füllung.
|
||||||
|
schalteKlasse("teil-plug", "laedt", w.laedt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Klimaknopf im Fahrzeugbild. Sein Lüfterrad dreht sich, solange die
|
||||||
|
* Klimatisierung arbeitet - so zeigt der Knopf nicht nur, was er kann,
|
||||||
|
* sondern auch, ob es gerade geschieht.
|
||||||
|
*/
|
||||||
|
function zeichneKlimaknopf(w) {
|
||||||
|
schalteKlasse("skodaAuto", "klima-laeuft", KLIMA_LAEUFT.indexOf(w.klima) >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Kacheln neben dem Fahrzeug: alles, was sich nicht zeichnen lässt. */
|
||||||
|
function zeichneKennzahlen(w) {
|
||||||
|
setzeText("skodaLeistung", w.kw ? nf(w.kw, 1) + " kW" : "–");
|
||||||
|
setzeText("skodaLadeart", (w.laedt && w.ladeart && LADEART[w.ladeart]) || "");
|
||||||
|
schalteKlasse("skodaKachelLeistung", "aktiv", w.laedt && w.kw);
|
||||||
|
|
||||||
|
setzeText("skodaRestzeit", w.laedt ? dauer(w.restMin) : "–");
|
||||||
|
setzeText("skodaFertig", w.laedt ? fertigUm(w.restMin) : "");
|
||||||
|
schalteKlasse("skodaKachelRestzeit", "aktiv", w.laedt && w.restMin > 0);
|
||||||
|
|
||||||
|
setzeText("skodaKm", w.odo ? nf(w.odo) + " km" : "–");
|
||||||
|
|
||||||
|
setzeText("skodaKlima", w.klima ? (KLIMA[w.klima] || w.klima) : "–");
|
||||||
|
schalteKlasse("skodaKachelKlima", "aktiv", KLIMA_LAEUFT.indexOf(w.klima) >= 0);
|
||||||
|
const klimaZeichen = $("skodaKlimaZeichen");
|
||||||
|
if (klimaZeichen) {
|
||||||
|
klimaZeichen.className = "bi " + (KLIMA_ZEICHEN[w.klima] || KLIMA_ZEICHEN_SONST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die beiden Kennzeichen in der Kopfzeile: Schloss und Ladezustand. */
|
||||||
|
function zeichneAbzeichen(w) {
|
||||||
|
const zustand = $("skodaLadeZustand");
|
||||||
|
if (zustand) {
|
||||||
|
const eintrag = LADEZUSTAND[w.zustand] || [w.zustand || "–", "text-bg-secondary"];
|
||||||
|
zustand.textContent = eintrag[0];
|
||||||
|
zustand.className = "badge " + eintrag[1];
|
||||||
|
}
|
||||||
|
|
||||||
// Verriegelung. null heißt: das Fahrzeug meldet UNKNOWN.
|
|
||||||
const schloss = $("skodaSchloss");
|
const schloss = $("skodaSchloss");
|
||||||
const zu = stand ? stand.locked : null;
|
if (!schloss) return;
|
||||||
if (zu === null || zu === undefined) {
|
if (w.verriegelt === null) {
|
||||||
schloss.textContent = "Schloss unbekannt";
|
schloss.textContent = "Schloss unbekannt";
|
||||||
schloss.className = "badge text-bg-secondary";
|
schloss.className = "badge text-bg-secondary";
|
||||||
} else if (Number(zu) === 1) {
|
} else if (w.verriegelt) {
|
||||||
schloss.textContent = "verriegelt";
|
schloss.textContent = "verriegelt";
|
||||||
schloss.className = "badge text-bg-success";
|
schloss.className = "badge text-bg-success";
|
||||||
} else {
|
} else {
|
||||||
schloss.textContent = "offen";
|
schloss.textContent = "offen";
|
||||||
schloss.className = "badge text-bg-warning";
|
schloss.className = "badge text-bg-warning";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ladeanschluss pulst beim Laden
|
/**
|
||||||
const stecker = $("teil-plug");
|
* Herkunft des Ladestroms: was die Anlage im Augenblick liefert, gegen das,
|
||||||
if (stecker) stecker.classList.toggle("laedt", laedt);
|
* was die Wallbox zieht. Ohne Ladung hat der Balken nichts zu teilen und
|
||||||
|
* bleibt weg.
|
||||||
// Herkunft des Ladestroms: was die PV im Augenblick liefert, gegen das,
|
*/
|
||||||
// was die Wallbox zieht. Mehr als die Ladeleistung kann die PV nicht
|
function zeichneLadequelle(w) {
|
||||||
// beitragen, auch wenn gerade mehr Sonne da ist.
|
|
||||||
const box = $("skodaQuelleBox");
|
const box = $("skodaQuelleBox");
|
||||||
const pvW = mqZahl("P_PV");
|
if (!box) return;
|
||||||
if (laedt && kw && pvW !== null) {
|
|
||||||
const pvKw = Math.max(0, Math.min(kw, pvW / 1000));
|
const zeigen = w.laedt && w.kw > 0 && w.pvKw !== null;
|
||||||
const anteil = Math.round(100 * pvKw / kw);
|
box.hidden = !zeigen;
|
||||||
$("skodaAnteilPV").style.width = anteil + "%";
|
if (!zeigen) return;
|
||||||
$("skodaAnteilNetz").style.width = (100 - anteil) + "%";
|
|
||||||
$("skodaQuelleText").textContent = anteil + " % Sonne, " + (100 - anteil) + " % Netz";
|
// Mehr als die Ladeleistung kann die Anlage nicht beitragen, auch wenn
|
||||||
box.style.display = "";
|
// gerade mehr Sonne da ist - der Überschuss geht ins Haus oder ins Netz.
|
||||||
} else {
|
const ausPv = Math.max(0, Math.min(w.kw, w.pvKw));
|
||||||
box.style.display = "none";
|
const anteil = Math.round(100 * ausPv / w.kw);
|
||||||
}
|
|
||||||
|
$("skodaAnteilPV").style.width = anteil + "%";
|
||||||
|
$("skodaAnteilNetz").style.width = (100 - anteil) + "%";
|
||||||
|
setzeText("skodaQuelleText", nf(w.kw, 1) + " kW gesamt");
|
||||||
|
setzeText("skodaQuellePV", nf(ausPv, 1) + " kW");
|
||||||
|
setzeText("skodaQuelleNetz", nf(w.kw - ausPv, 1) + " kW");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Fahrzeugkachel neu zeichnen. */
|
||||||
|
function zeichneLive() {
|
||||||
|
const w = liveWerte();
|
||||||
|
zeichneAkku(w);
|
||||||
|
zeichneKlimaknopf(w);
|
||||||
|
zeichneKennzahlen(w);
|
||||||
|
zeichneAbzeichen(w);
|
||||||
|
zeichneLadequelle(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------- Diagramme */
|
/* ------------------------------------------------------------- Diagramme */
|
||||||
@@ -691,7 +951,7 @@ function zeigeVerbrauchsbasis() {
|
|||||||
if (!letzteKapazitaet) {
|
if (!letzteKapazitaet) {
|
||||||
// Ohne Kapazität gibt es nichts umzuschalten: dann bleibt nur Prozent.
|
// Ohne Kapazität gibt es nichts umzuschalten: dann bleibt nur Prozent.
|
||||||
feld.textContent = "";
|
feld.textContent = "";
|
||||||
feld.removeAttribute("title");
|
setzeHinweis(feld, null);
|
||||||
if (schalter) schalter.style.display = "none";
|
if (schalter) schalter.style.display = "none";
|
||||||
setzeEinheit("prozent", false);
|
setzeEinheit("prozent", false);
|
||||||
return;
|
return;
|
||||||
@@ -703,7 +963,7 @@ function zeigeVerbrauchsbasis() {
|
|||||||
|
|
||||||
// Woher der Wirkungsgrad stammt, gehört dazu: gemessen ist er aus dem
|
// Woher der Wirkungsgrad stammt, gehört dazu: gemessen ist er aus dem
|
||||||
// Verhältnis zweier Energien derselben Ladung, angenommen ist er geraten.
|
// Verhältnis zweier Energien derselben Ladung, angenommen ist er geraten.
|
||||||
feld.title = "Aus " + k.n + " Ladungen des letzten Jahres: "
|
setzeHinweis(feld, "Aus " + k.n + " Ladungen des letzten Jahres: "
|
||||||
+ nf(k.je100_zaehler, 1) + " kWh je 100 Prozentpunkte ab Wallbox-Zähler, "
|
+ nf(k.je100_zaehler, 1) + " kWh je 100 Prozentpunkte ab Wallbox-Zähler, "
|
||||||
+ nf(k.je100_netto, 1) + " kWh davon in der Batterie. Ladewirkungsgrad "
|
+ nf(k.je100_netto, 1) + " kWh davon in der Batterie. Ladewirkungsgrad "
|
||||||
+ nf(k.wirkungsgrad * 100, 1) + " %, "
|
+ nf(k.wirkungsgrad * 100, 1) + " %, "
|
||||||
@@ -711,7 +971,7 @@ function zeigeVerbrauchsbasis() {
|
|||||||
? "gemessen aus " + k.n_fahrzeug + " Ladungen — der Zähler misst vor dem "
|
? "gemessen aus " + k.n_fahrzeug + " Ladungen — der Zähler misst vor dem "
|
||||||
+ "Ladegerät, das Fahrzeug meldet dahinter."
|
+ "Ladegerät, das Fahrzeug meldet dahinter."
|
||||||
: "angenommen, weil noch zu wenige Ladungen die Ladeleistung des "
|
: "angenommen, weil noch zu wenige Ladungen die Ladeleistung des "
|
||||||
+ "Fahrzeugs mitgeliefert haben.");
|
+ "Fahrzeugs mitgeliefert haben."));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Die gewählte Zeile in der Fahrtenliste kenntlich machen. */
|
/** Die gewählte Zeile in der Fahrtenliste kenntlich machen. */
|
||||||
@@ -757,6 +1017,47 @@ async function ladeLive() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fahrleistung. Die Namen der Zeiträume stehen hier, ihre Länge in Tagen
|
||||||
|
* kommt vom Server mit - so muss nur eine Seite geändert werden, wenn ein
|
||||||
|
* Zeitraum hinzukommt.
|
||||||
|
*/
|
||||||
|
const STRECKE_ZEITRAEUME = { woche: "7 Tage", monat: "30 Tage", jahr: "12 Monate" };
|
||||||
|
|
||||||
|
async function ladeStrecke() {
|
||||||
|
const feld = $("skodaFahrleistung");
|
||||||
|
if (!feld) return;
|
||||||
|
loeseHinweise(feld);
|
||||||
|
try {
|
||||||
|
const d = await hole("strecke");
|
||||||
|
feld.innerHTML = Object.keys(STRECKE_ZEITRAEUME).map(function (name) {
|
||||||
|
const p = d.strecke ? d.strecke[name] : null;
|
||||||
|
const km = (p && p.km !== null && p.km !== undefined) ? nf(p.km) + " km" : "–";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Reicht die Aufzeichnung nicht über den ganzen Zeitraum, ist die Zahl
|
||||||
|
* kein Jahreswert, sondern nur das, was aufgezeichnet ist. Sie deshalb
|
||||||
|
* wegzulassen wäre falsch - sie kommentarlos als Jahr auszugeben aber
|
||||||
|
* auch. Also gestrichelt unterstrichen, mit dem Anfang im Titel.
|
||||||
|
*/
|
||||||
|
const knapp = p && p.km !== null && !p.vollstaendig;
|
||||||
|
return '<div class="posten' + (knapp ? " knapp" : "") + '">'
|
||||||
|
+ '<span class="v">' + km + '</span>'
|
||||||
|
+ '<span class="k">' + STRECKE_ZEITRAEUME[name] + "</span></div>";
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
// Die knappen Zeiträume tragen ihre Erklärung selbst - antippbar.
|
||||||
|
feld.querySelectorAll(".posten.knapp").forEach(function (posten) {
|
||||||
|
setzeHinweis(posten, "Aufgezeichnet wird erst seit " + zeitpunkt(d.seit, true)
|
||||||
|
+ ". Der Wert deckt deshalb nicht den ganzen Zeitraum ab, sondern nur "
|
||||||
|
+ "das, was seither aufgezeichnet ist.");
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
feld.innerHTML = '<div class="posten"><span class="v">–</span>'
|
||||||
|
+ '<span class="k">nicht abrufbar</span></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ladeLadungen() {
|
async function ladeLadungen() {
|
||||||
try {
|
try {
|
||||||
const d = await hole("ladungen", "&tage=180");
|
const d = await hole("ladungen", "&tage=180");
|
||||||
@@ -1137,6 +1438,7 @@ function verdrahteBefehle(bereich) {
|
|||||||
|
|
||||||
function alleLaden() {
|
function alleLaden() {
|
||||||
ladeLive();
|
ladeLive();
|
||||||
|
ladeStrecke();
|
||||||
ladeLadungen();
|
ladeLadungen();
|
||||||
ladeKurve();
|
ladeKurve();
|
||||||
ladeGesundheit();
|
ladeGesundheit();
|
||||||
@@ -1161,7 +1463,9 @@ document.addEventListener("readystatechange", function () {
|
|||||||
// Die Tabelle wächst im Takt des Fahrzeugs, nicht im Takt des Betrachters:
|
// Die Tabelle wächst im Takt des Fahrzeugs, nicht im Takt des Betrachters:
|
||||||
// beim Parken steht dort stundenlang dasselbe. Einmal pro Minute genügt.
|
// beim Parken steht dort stundenlang dasselbe. Einmal pro Minute genügt.
|
||||||
setInterval(ladeLive, 60000);
|
setInterval(ladeLive, 60000);
|
||||||
setInterval(function () { ladeLadungen(); ladeKurve(); ladeFahrten(); }, 300000);
|
setInterval(function () {
|
||||||
|
ladeLadungen(); ladeKurve(); ladeFahrten(); ladeStrecke();
|
||||||
|
}, 300000);
|
||||||
setInterval(ladeGesundheit, 900000);
|
setInterval(ladeGesundheit, 900000);
|
||||||
|
|
||||||
// Leaflet rechnet die Kachelanordnung selbst neu, sobald es erfährt, dass
|
// Leaflet rechnet die Kachelanordnung selbst neu, sobald es erfährt, dass
|
||||||
|
|||||||
@@ -593,7 +593,10 @@ var chartSettings = {
|
|||||||
getData(consChart, 'ajax/getConsData.php', { params: dayRange, sunrise: true, updateMode: "none" });
|
getData(consChart, 'ajax/getConsData.php', { params: dayRange, sunrise: true, updateMode: "none" });
|
||||||
getData(prodChart, 'ajax/getProdData.php', { params: dayRange, sunrise: true, updateMode: "none" });
|
getData(prodChart, 'ajax/getProdData.php', { params: dayRange, sunrise: true, updateMode: "none" });
|
||||||
getData(foreChart, 'ajax/getForecastData.php', { params: dayRange, updateMode: "none" });
|
getData(foreChart, 'ajax/getForecastData.php', { params: dayRange, updateMode: "none" });
|
||||||
getStats("Stats-Year","ajax/getStats.php?type=ThisYear");
|
// Dieselbe Statistik wie auf der Historienseite, nur das laufende Jahr:
|
||||||
|
// hier ist sie Beiwerk, dort der Zweck der Seite.
|
||||||
|
baueJahresstatistik(document.getElementById("Stats-Year"),
|
||||||
|
{ spalten: 1, werkzeuge: false, fussnoten: false });
|
||||||
solarSVG.fillElementArray();
|
solarSVG.fillElementArray();
|
||||||
mountMeteogram();
|
mountMeteogram();
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-12
@@ -316,17 +316,6 @@ function raumVorschlaege()
|
|||||||
return $vorschlag;
|
return $vorschlag;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raum je Geraet, wie gespeichert. */
|
|
||||||
function geraeteRaeume()
|
|
||||||
{
|
|
||||||
$res = meshDb()->query("SELECT id, room FROM actors");
|
|
||||||
$raeume = [];
|
|
||||||
while ($row = $res->fetch_assoc()) {
|
|
||||||
$raeume[intval($row["id"])] = $row["room"];
|
|
||||||
}
|
|
||||||
return $raeume;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Zuordnungen speichern. $zuordnung ist [actor_id => Raumschluessel|""]. */
|
/** Zuordnungen speichern. $zuordnung ist [actor_id => Raumschluessel|""]. */
|
||||||
function saveRooms($zuordnung)
|
function saveRooms($zuordnung)
|
||||||
{
|
{
|
||||||
@@ -352,6 +341,161 @@ function saveRooms($zuordnung)
|
|||||||
return $anzahl;
|
return $anzahl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Geraeteliste fuer die Einstellungsseite.
|
||||||
|
*
|
||||||
|
* Enthaelt alles, was man ueber ein Geraet wissen will, ohne es anzuklicken:
|
||||||
|
* Beschriftung, Art, Raum, ein Vorschlag fuer den Raum (aus dem MQTT-Topic),
|
||||||
|
* wie viele Messwerte und Kommandos daran haengen, wann zuletzt etwas davon
|
||||||
|
* gemeldet wurde - und in welchen Automatiken es steckt.
|
||||||
|
*
|
||||||
|
* Das Letzte ist der Grund, warum die Liste hier steht und nicht in der Maske:
|
||||||
|
* ein Geraet, auf das eine Automatik zeigt, darf nicht verschwinden, und wer
|
||||||
|
* das entscheidet, muss die Verweise kennen.
|
||||||
|
*/
|
||||||
|
function geraeteListe()
|
||||||
|
{
|
||||||
|
$db = meshDb();
|
||||||
|
$labels = geraeteBeschriftungen();
|
||||||
|
$tipps = raumVorschlaege();
|
||||||
|
|
||||||
|
$geraete = [];
|
||||||
|
$res = $db->query("SELECT id, name, type, url, room FROM actors");
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$id = intval($row["id"]);
|
||||||
|
$wo = raumEinordnung($row["room"]);
|
||||||
|
$geraete[$id] = [
|
||||||
|
"id" => $id,
|
||||||
|
"name" => $row["name"],
|
||||||
|
"label" => $labels[$id] ?? $row["name"],
|
||||||
|
"type" => $row["type"],
|
||||||
|
"url" => $row["url"],
|
||||||
|
"room" => $row["room"],
|
||||||
|
"roomLabel" => raumBeschriftung($row["room"]),
|
||||||
|
"floor" => $wo["floor"],
|
||||||
|
"tipp" => $tipps[$id] ?? null,
|
||||||
|
"messwerte" => 0,
|
||||||
|
"kommandos" => 0,
|
||||||
|
"gesehen" => null,
|
||||||
|
"benutzt" => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messwerte: Anzahl und juengste Meldung. Letztere sagt, ob ein Geraet
|
||||||
|
// noch lebt - ein Eintrag von vor einem halben Jahr ist eine Karteileiche.
|
||||||
|
$res = $db->query("SELECT actor_id, COUNT(*) AS n, MAX(last_updated) AS gesehen
|
||||||
|
FROM actor_states GROUP BY actor_id");
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$id = intval($row["actor_id"]);
|
||||||
|
if (isset($geraete[$id])) {
|
||||||
|
$geraete[$id]["messwerte"] = intval($row["n"]);
|
||||||
|
$geraete[$id]["gesehen"] = $row["gesehen"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = $db->query("SELECT actor_id, COUNT(*) AS n FROM actor_commands GROUP BY actor_id");
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$id = intval($row["actor_id"]);
|
||||||
|
if (isset($geraete[$id])) {
|
||||||
|
$geraete[$id]["kommandos"] = intval($row["n"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verweise aus den Automatiken - ueber Messwerte (Bedingungen) und ueber
|
||||||
|
// Kommandos (Aktionen).
|
||||||
|
$sql = "SELECT s.actor_id AS actor_id, a.id AS aid, a.name AS aname
|
||||||
|
FROM automation_conditions c
|
||||||
|
JOIN actor_states s ON s.id = c.state_id
|
||||||
|
JOIN automations a ON a.id = c.automation_id
|
||||||
|
UNION
|
||||||
|
SELECT k.actor_id AS actor_id, a.id AS aid, a.name AS aname
|
||||||
|
FROM automation_actions x
|
||||||
|
JOIN actor_commands k ON k.id = x.command_id
|
||||||
|
JOIN automations a ON a.id = x.automation_id";
|
||||||
|
$res = $db->query($sql);
|
||||||
|
while ($res && $row = $res->fetch_assoc()) {
|
||||||
|
$id = intval($row["actor_id"]);
|
||||||
|
if (isset($geraete[$id])) {
|
||||||
|
$geraete[$id]["benutzt"][intval($row["aid"])] = $row["aname"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($geraete as $id => $g) {
|
||||||
|
$geraete[$id]["benutzt"] = array_values($g["benutzt"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach Etage, Raum und Beschriftung - dieselbe Ordnung wie ueberall.
|
||||||
|
$reihenfolge = array_flip($GLOBALS["floors"]);
|
||||||
|
$liste = array_values($geraete);
|
||||||
|
usort($liste, function ($a, $b) use ($reihenfolge) {
|
||||||
|
$wa = explode("/", (string)$a["room"], 2);
|
||||||
|
$wb = explode("/", (string)$b["room"], 2);
|
||||||
|
// Geraete ohne Raum nach oben: sie sind das, was Arbeit macht.
|
||||||
|
$ra = ($a["room"] === null || $a["room"] === "") ? -1 : ($reihenfolge[$wa[0]] ?? 98);
|
||||||
|
$rb = ($b["room"] === null || $b["room"] === "") ? -1 : ($reihenfolge[$wb[0]] ?? 98);
|
||||||
|
if ($ra !== $rb) {
|
||||||
|
return $ra <=> $rb;
|
||||||
|
}
|
||||||
|
$raum = strcoll($wa[1] ?? "", $wb[1] ?? "");
|
||||||
|
return $raum !== 0 ? $raum : strcoll($a["label"], $b["label"]);
|
||||||
|
});
|
||||||
|
return $liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Geraet samt Messwerten, Kommandos und Parametern loeschen.
|
||||||
|
*
|
||||||
|
* Gedacht fuer Karteileichen: der Suchlauf loescht nichts (clear_tables steht
|
||||||
|
* bewusst auf false, weil die Automatiken auf die Ids zeigen), also bleibt ein
|
||||||
|
* abgebautes Geraet fuer immer in der Liste stehen.
|
||||||
|
*
|
||||||
|
* Zeigt eine Automatik darauf, wird nicht geloescht, sondern abgelehnt - mit
|
||||||
|
* den Namen der Automatiken. Sie stillschweigend mitzuloeschen waere ein
|
||||||
|
* stiller Datenverlust, sie kaputt zurueckzulassen ebenfalls. Wer das Geraet
|
||||||
|
* wirklich loswerden will, raeumt erst die Automatik auf.
|
||||||
|
*
|
||||||
|
* Steht das Geraet noch im Netz, findet der naechste Suchlauf es wieder - das
|
||||||
|
* ist kein Fehler, sondern der Zweck des Suchlaufs.
|
||||||
|
*/
|
||||||
|
function geraetLoeschen($id)
|
||||||
|
{
|
||||||
|
$id = intval($id);
|
||||||
|
if ($id <= 0) {
|
||||||
|
throw new InvalidArgumentException("Kein Gerät angegeben.");
|
||||||
|
}
|
||||||
|
$db = meshDb();
|
||||||
|
|
||||||
|
$res = $db->query("SELECT name FROM actors WHERE id = " . $id);
|
||||||
|
$geraet = $res ? $res->fetch_assoc() : null;
|
||||||
|
if (!$geraet) {
|
||||||
|
throw new InvalidArgumentException("Dieses Gerät gibt es nicht (mehr).");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (geraeteListe() as $g) {
|
||||||
|
if ($g["id"] === $id && $g["benutzt"]) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Das Gerät steckt in " . count($g["benutzt"]) . " Automatik(en): "
|
||||||
|
. implode(", ", $g["benutzt"]) . ". Erst dort entfernen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->begin_transaction();
|
||||||
|
try {
|
||||||
|
// Von innen nach aussen: Parameter haengen an Kommandos, Kommandos und
|
||||||
|
// Messwerte am Geraet.
|
||||||
|
$db->query("DELETE p FROM command_parameters p
|
||||||
|
JOIN actor_commands k ON k.id = p.command_id
|
||||||
|
WHERE k.actor_id = " . $id);
|
||||||
|
$db->query("DELETE FROM actor_commands WHERE actor_id = " . $id);
|
||||||
|
$db->query("DELETE FROM actor_states WHERE actor_id = " . $id);
|
||||||
|
$db->query("DELETE FROM actors WHERE id = " . $id);
|
||||||
|
$db->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$db->rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
return $geraet["name"];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* id => Beschriftung fuer alle Geraete, geputzt und eindeutig.
|
* id => Beschriftung fuer alle Geraete, geputzt und eindeutig.
|
||||||
*
|
*
|
||||||
@@ -425,8 +569,14 @@ function deviceCatalog()
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* value_path kommt mit, seit die Einstellungsseite denselben Katalog fuer
|
||||||
|
* die Home-Kacheln benutzt: bei Geraeten, die ihre Werte als JSON in einer
|
||||||
|
* einzigen Nachricht schicken (der Regensensor etwa), unterscheidet erst
|
||||||
|
* dieser Schluessel die sechs Messwerte unter demselben Topic.
|
||||||
|
*/
|
||||||
$res = $db->query("SELECT s.id, s.actor_id, s.state_name, s.unit, s.current_value,
|
$res = $db->query("SELECT s.id, s.actor_id, s.state_name, s.unit, s.current_value,
|
||||||
s.url, s.possible_values, t.type
|
s.url, s.value_path, s.possible_values, t.type
|
||||||
FROM actor_states s
|
FROM actor_states s
|
||||||
LEFT JOIN state_types t ON s.state_type = t.id
|
LEFT JOIN state_types t ON s.state_type = t.id
|
||||||
ORDER BY s.actor_id, s.id");
|
ORDER BY s.actor_id, s.id");
|
||||||
@@ -447,6 +597,8 @@ function deviceCatalog()
|
|||||||
"unit" => $row["unit"],
|
"unit" => $row["unit"],
|
||||||
"value" => $row["current_value"],
|
"value" => $row["current_value"],
|
||||||
"url" => $row["url"],
|
"url" => $row["url"],
|
||||||
|
// Schluessel in einer JSON-Nachricht, sonst leer - siehe oben.
|
||||||
|
"pfad" => $row["value_path"],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-3
@@ -325,9 +325,32 @@ function preiseSpeichern($key, $zeilen)
|
|||||||
function grundpreisImJahr($jahr)
|
function grundpreisImJahr($jahr)
|
||||||
{
|
{
|
||||||
$jahr = intval($jahr);
|
$jahr = intval($jahr);
|
||||||
$von = $jahr . "-01-01";
|
return grundpreisImZeitraum($jahr . "-01-01", $jahr . "-12-31");
|
||||||
$bis = $jahr . "-12-31";
|
}
|
||||||
$tage = intval((new DateTimeImmutable($bis))->format("z")) + 1; // 365 oder 366
|
|
||||||
|
/**
|
||||||
|
* Grundpreis Strom fuer einen Zeitraum innerhalb eines Kalenderjahres.
|
||||||
|
*
|
||||||
|
* Der Grundpreis steht als Jahresbetrag in der Tarifzeile; auf einen Zeitraum
|
||||||
|
* kommt er anteilig nach Tagen. Geteilt wird deshalb durch die Tage des
|
||||||
|
* JAHRES, nicht durch die des Zeitraums - sonst zahlte ein halbes Jahr den
|
||||||
|
* vollen Betrag.
|
||||||
|
*
|
||||||
|
* Von und bis muessen im selben Jahr liegen: die Statistik fragt entweder ein
|
||||||
|
* ganzes Jahr oder dessen Anfang bis zu einem Stichtag, und ein Zeitraum ueber
|
||||||
|
* den Jahreswechsel haette zwei verschiedene Nenner.
|
||||||
|
*/
|
||||||
|
function grundpreisImZeitraum($von, $bis)
|
||||||
|
{
|
||||||
|
$vonTag = new DateTimeImmutable($von);
|
||||||
|
$bisTag = new DateTimeImmutable($bis);
|
||||||
|
if ($vonTag->format("Y") !== $bisTag->format("Y")) {
|
||||||
|
throw new InvalidArgumentException("Zeitraum muss innerhalb eines Jahres liegen.");
|
||||||
|
}
|
||||||
|
$von = $vonTag->format("Y-m-d");
|
||||||
|
$bis = $bisTag->format("Y-m-d");
|
||||||
|
// Tage des Jahres, 365 oder 366 - der Nenner des Jahresbetrags.
|
||||||
|
$tage = intval((new DateTimeImmutable($vonTag->format("Y") . "-12-31"))->format("z")) + 1;
|
||||||
|
|
||||||
$stmt = solarDb()->prepare(
|
$stmt = solarDb()->prepare(
|
||||||
"WITH p AS (SELECT active_date, base,
|
"WITH p AS (SELECT active_date, base,
|
||||||
|
|||||||
@@ -173,6 +173,32 @@ class ShellyDiscovery:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_mqtt_config(self, device: Dict) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Holt die MQTT-Einstellung eines Gen2-Geräts.
|
||||||
|
|
||||||
|
Nur Gen2 kann MQTT; die alten Geräte werden weiterhin abgefragt. Wer
|
||||||
|
sendet, muss nicht gepollt werden - deshalb interessiert hier nicht
|
||||||
|
nur, ob MQTT eingeschaltet ist, sondern auch das Präfix: daraus ergibt
|
||||||
|
sich das Topic, unter dem die Werte ankommen.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Die Antwort von Mqtt.GetConfig oder None (Gen1, altes Firmware,
|
||||||
|
Gerät nicht erreichbar).
|
||||||
|
"""
|
||||||
|
if device.get('generation') != 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"http://{device['ip']}/rpc/Mqtt.GetConfig",
|
||||||
|
timeout=2
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Keine MQTT-Einstellung von {device['ip']}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def discover_devices(self) -> List[Dict]:
|
def discover_devices(self) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Entdeckt alle Shelly-Geräte im Netzwerk
|
Entdeckt alle Shelly-Geräte im Netzwerk
|
||||||
@@ -185,6 +211,7 @@ class ShellyDiscovery:
|
|||||||
for idx, device in enumerate(self.devices):
|
for idx, device in enumerate(self.devices):
|
||||||
status = self.get_device_status(device)
|
status = self.get_device_status(device)
|
||||||
self.devices[idx]['status'] = status
|
self.devices[idx]['status'] = status
|
||||||
|
self.devices[idx]['mqtt'] = self.get_mqtt_config(device)
|
||||||
|
|
||||||
logger.info(f"Insgesamt {len(self.devices)} Shelly-Geräte entdeckt")
|
logger.info(f"Insgesamt {len(self.devices)} Shelly-Geräte entdeckt")
|
||||||
return self.devices
|
return self.devices
|
||||||
@@ -205,6 +232,52 @@ class ShellyModule(BaseModule):
|
|||||||
"""Prüft ob Shelly aktiviert ist"""
|
"""Prüft ob Shelly aktiviert ist"""
|
||||||
return self.config.shelly_enable
|
return self.config.shelly_enable
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mqtt_praefix(device: Dict) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Das Topic-Präfix, unter dem ein Gerät seine Zustände sendet - oder
|
||||||
|
None, wenn es das nicht tut.
|
||||||
|
|
||||||
|
Verlangt beides: MQTT eingeschaltet UND die Statusmeldungen
|
||||||
|
eingeschaltet. Ohne status_ntf hält das Gerät die Verbindung, schickt
|
||||||
|
aber nichts von selbst - dann bliebe ein Messwert auf dem Broker ewig
|
||||||
|
aus, und Abfragen ist die richtige Antwort.
|
||||||
|
"""
|
||||||
|
mqtt = device.get('mqtt') or {}
|
||||||
|
if mqtt.get('enable') and mqtt.get('status_ntf') and mqtt.get('topic_prefix'):
|
||||||
|
return mqtt['topic_prefix']
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _adressiere(states: List[Dict], praefix: Optional[str],
|
||||||
|
komponente: str, index: int) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Die Messwerte einer Komponente adressieren.
|
||||||
|
|
||||||
|
Zwei Wege, und der Unterschied ist nicht Geschmack, sondern Technik:
|
||||||
|
|
||||||
|
ohne MQTT Das Gerät wird abgefragt. Die Adresse steht am Gerät
|
||||||
|
(actors.url = der RPC-Aufruf), der Messwert nennt nur
|
||||||
|
das Feld der Antwort: url = 'total_act_power'.
|
||||||
|
|
||||||
|
mit MQTT Es gibt nichts abzufragen, die Nachricht kommt von
|
||||||
|
selbst. Dann muss die Adresse am Messwert stehen:
|
||||||
|
url = 'Power_EG/status/em:0', und das Feld in der
|
||||||
|
JSON-Nachricht wandert nach value_path - genau so, wie
|
||||||
|
es das MQTT-Modul für alle anderen Geräte hält.
|
||||||
|
|
||||||
|
Damit fällt für die neuen Geräte das Abfragen im Automatik-Runner weg;
|
||||||
|
die alten Shellys ohne MQTT bleiben unverändert beim HTTP-Weg.
|
||||||
|
"""
|
||||||
|
if not praefix:
|
||||||
|
return states
|
||||||
|
topic = f"{praefix}/status/{komponente}:{index}"
|
||||||
|
for state in states:
|
||||||
|
# Was bisher die Adresse war, ist jetzt der Schlüssel darin.
|
||||||
|
state['value_path'] = state.get('url')
|
||||||
|
state['url'] = topic
|
||||||
|
return states
|
||||||
|
|
||||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||||
"""
|
"""
|
||||||
Führt Shelly Discovery durch
|
Führt Shelly Discovery durch
|
||||||
@@ -256,6 +329,11 @@ class ShellyModule(BaseModule):
|
|||||||
|
|
||||||
device_name = info.get('name', f"Shelly_{info.get('id', ip)}")
|
device_name = info.get('name', f"Shelly_{info.get('id', ip)}")
|
||||||
device_model = info.get('model', 'Unknown')
|
device_model = info.get('model', 'Unknown')
|
||||||
|
# Sendet das Geraet seine Zustaende selbst? Dann bekommen die
|
||||||
|
# Messwerte Topics statt Feldnamen - siehe _adressiere().
|
||||||
|
praefix = self._mqtt_praefix(device)
|
||||||
|
if praefix:
|
||||||
|
logger.info(f" {device_name}: Zustaende ueber MQTT ({praefix}/status/...)")
|
||||||
|
|
||||||
# Switches als Aktoren
|
# Switches als Aktoren
|
||||||
switch_count = sum(1 for key in status.keys() if key.startswith('switch:'))
|
switch_count = sum(1 for key in status.keys() if key.startswith('switch:'))
|
||||||
@@ -271,14 +349,14 @@ class ShellyModule(BaseModule):
|
|||||||
{'command': 'turn_off', 'url': 'on=false', 'parameters': []},
|
{'command': 'turn_off', 'url': 'on=false', 'parameters': []},
|
||||||
{'command': 'toggle', 'parameters': []}
|
{'command': 'toggle', 'parameters': []}
|
||||||
],
|
],
|
||||||
'states': [
|
'states': self._adressiere([
|
||||||
{
|
{
|
||||||
'name': 'output',
|
'name': 'output',
|
||||||
'type': 'boolean',
|
'type': 'boolean',
|
||||||
'url': 'output',
|
'url': 'output',
|
||||||
'current_value': switch_data.get('output', False)
|
'current_value': switch_data.get('output', False)
|
||||||
}
|
}
|
||||||
]
|
], praefix, 'switch', i)
|
||||||
})
|
})
|
||||||
|
|
||||||
# Temperatursensoren
|
# Temperatursensoren
|
||||||
@@ -290,7 +368,7 @@ class ShellyModule(BaseModule):
|
|||||||
'type': 'Temperatur',
|
'type': 'Temperatur',
|
||||||
'name': f"{device_name}_Temp_{i}",
|
'name': f"{device_name}_Temp_{i}",
|
||||||
'url': f"http://{ip}/rpc/Temperature.GetStatus?id={i}",
|
'url': f"http://{ip}/rpc/Temperature.GetStatus?id={i}",
|
||||||
'states': [
|
'states': self._adressiere([
|
||||||
{
|
{
|
||||||
'name': 'temperature',
|
'name': 'temperature',
|
||||||
'type': 'number',
|
'type': 'number',
|
||||||
@@ -298,7 +376,7 @@ class ShellyModule(BaseModule):
|
|||||||
'url': f"tC",
|
'url': f"tC",
|
||||||
'unit': '°C'
|
'unit': '°C'
|
||||||
}
|
}
|
||||||
]
|
], praefix, 'temperature', i)
|
||||||
})
|
})
|
||||||
# Energy-Meter
|
# Energy-Meter
|
||||||
em_count = sum(1 for key in status.keys() if key.startswith('em:'))
|
em_count = sum(1 for key in status.keys() if key.startswith('em:'))
|
||||||
@@ -422,6 +500,8 @@ class ShellyModule(BaseModule):
|
|||||||
'url': 'total_act_power',
|
'url': 'total_act_power',
|
||||||
'current_value': em_data.get('total_act_power'),
|
'current_value': em_data.get('total_act_power'),
|
||||||
'unit': 'W'})
|
'unit': 'W'})
|
||||||
|
# Erst hier, weil die Messwerte oben einzeln angehaengt werden.
|
||||||
|
self._adressiere(sensors[len(sensors)-1]['states'], praefix, 'em', i)
|
||||||
|
|
||||||
return actors, sensors
|
return actors, sensors
|
||||||
|
|
||||||
|
|||||||
+34
-19
@@ -1,16 +1,24 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* Seite "Vergangenheit": sechs Diagramme und darunter der Jahresvergleich.
|
||||||
|
*
|
||||||
|
* Der Vergleich war einmal drei Karten mit je achtzehn Kacheln - dieselben
|
||||||
|
* Beschriftungen dreimal untereinander, und wer zwei Jahre vergleichen wollte,
|
||||||
|
* musste scrollen. Jetzt ist es eine Tabelle: Kennzahlen in Zeilen, Jahre in
|
||||||
|
* Spalten, der Vergleich also eine Zeile weit.
|
||||||
|
*
|
||||||
|
* Gebaut wird der Vergleich von baueJahresstatistik() in
|
||||||
|
* js/solar/jahresstatistik.js, aus einer einzigen Antwort von
|
||||||
|
* ajax/getStats.php - auch die Zeilen selbst, denn welche Kennzahlen es gibt,
|
||||||
|
* weiss der Server. Denselben Baustein zeigt die Solarseite einspaltig.
|
||||||
|
*/
|
||||||
|
|
||||||
// Die sechs Diagramme unterscheiden sich nur in Zeitraum und Canvas-ID.
|
// Die sechs Diagramme unterscheiden sich nur in Zeitraum und Canvas-ID.
|
||||||
$periods = [
|
$periods = [
|
||||||
"month" => "Letzter Monat",
|
"month" => "Letzter Monat",
|
||||||
"year" => "Letzte 13 Monate",
|
"year" => "Letzte 13 Monate",
|
||||||
"decade" => "Letzte 10 Jahre",
|
"decade" => "Letzte 10 Jahre",
|
||||||
];
|
];
|
||||||
// Die Jahresstatistiken darunter folgen demselben Muster.
|
|
||||||
$statistics = [
|
|
||||||
"Stats-Year" => "Statistik dieses Jahr",
|
|
||||||
"Stats-Lastyear" => "Statistik letztes Jahr",
|
|
||||||
"Stats-Prelastyear" => "Statistik vorletztes Jahr",
|
|
||||||
];
|
|
||||||
?>
|
?>
|
||||||
<!--begin::App Main-->
|
<!--begin::App Main-->
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
@@ -63,23 +71,30 @@ $statistics = [
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php foreach ($statistics as $id => $title): ?>
|
<!-- ============================================ Jahresvergleich ==== -->
|
||||||
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
|
<!-- Der Inhalt kommt aus baueJahresstatistik() in
|
||||||
<div class="card p-0 mb-4" style="width:100%">
|
js/solar/jahresstatistik.js: Leitzahlen, Werkzeugleiste, Tabelle und
|
||||||
<div class="card-header pb-0 pt-1">
|
Fussnoten baut der Baustein selbst, damit die Solarseite dieselbe
|
||||||
<h3 class="card-title"><?= $title ?></h3>
|
Statistik in schmaler Form zeigen kann, ohne dass es sie zweimal
|
||||||
<div class="card-tools">
|
gaebe. -->
|
||||||
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
<div class="row">
|
||||||
<i class="bi bi-x-lg"></i>
|
<div class="col-12">
|
||||||
</button>
|
<div class="card mb-4 p-0 w-100">
|
||||||
|
<div class="card-header pb-0 pt-1">
|
||||||
|
<h3 class="card-title">Jahresvergleich</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body statistik">
|
||||||
|
<div id="jahresvergleich"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="<?= $id ?>">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- /.card -->
|
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
|
||||||
<!-- /.row (main row) -->
|
<!-- /.row (main row) -->
|
||||||
</div>
|
</div>
|
||||||
<!--end::Container-->
|
<!--end::Container-->
|
||||||
|
|||||||
+26
-22
@@ -1,4 +1,7 @@
|
|||||||
<?php require_once(__DIR__ . "/rooms.php"); ?>
|
<?php
|
||||||
|
require_once(__DIR__ . "/rooms.php");
|
||||||
|
require_once(__DIR__ . "/reiter.php");
|
||||||
|
?>
|
||||||
<!--begin::App Main-->
|
<!--begin::App Main-->
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<!--begin::App Content Header-->
|
<!--begin::App Content Header-->
|
||||||
@@ -185,12 +188,6 @@
|
|||||||
<div class="card-header pb-0 pt-1">
|
<div class="card-header pb-0 pt-1">
|
||||||
<h3 class="card-title">Automatismen</h3>
|
<h3 class="card-title">Automatismen</h3>
|
||||||
<div class="card-tools">
|
<div class="card-tools">
|
||||||
<!-- Zuordnung Geraet -> Raum: sie ordnet die Geraeteliste im
|
|
||||||
Editor nach Raeumen statt nach Geraeteart. -->
|
|
||||||
<button type="button" class="btn btn-tool" title="Geräte den Räumen zuordnen"
|
|
||||||
onclick="openRoomModal()">
|
|
||||||
<i class="bi bi-house-gear"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-x-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -198,28 +195,35 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body pb-1">
|
<div class="card-body pb-1">
|
||||||
<div class="card-header p-0 pt-1">
|
<?php
|
||||||
<ul class="nav nav-tabs" id="actions-tab" role="tablist">
|
/*
|
||||||
<?php foreach ($floors as $etage): $offen = $_GET["floor"] == $etage; ?>
|
* Eine Etage, ein Reiter. Gezeichnet von reiterLeiste() aus
|
||||||
<li class="nav-item">
|
* restricted/reiter.php - dieselbe Bauart wie im Raum-Modal und
|
||||||
<a class="nav-link <?php if ($offen) echo "active"; ?>" id="actions-<?= $etage ?>-tab"
|
* auf der Einstellungsseite. Frueher stand hier ein eigenes
|
||||||
href="#actions-<?= $etage ?>" onclick="switchTab('actions-<?= $etage ?>')"
|
* Markup samt eigenem switchTab() in homeMQTT.js; umgeschaltet
|
||||||
title="<?= htmlspecialchars(floorLabel($etage)) ?>"
|
* wird jetzt von Bootstrap wie ueberall sonst.
|
||||||
aria-controls="actions-<?= $etage ?>" aria-selected="<?= $offen ? "true" : "false" ?>"><?= $etage ?></a>
|
*
|
||||||
</li>
|
* Der Name "actions" haelt die Element-Ids, an denen
|
||||||
<?php endforeach; ?>
|
* autoActionFuncs.js haengt: actions-OG, actions-OG-list.
|
||||||
</ul>
|
*/
|
||||||
</div>
|
$autoReiter = array_map(function ($etage) {
|
||||||
|
return ["id" => $etage, "titel" => $etage,
|
||||||
|
"titel2" => floorLabel($etage)];
|
||||||
|
}, $floors);
|
||||||
|
$autoOpt = ["name" => "actions", "aktiv" => $_GET["floor"],
|
||||||
|
"klasse" => "mb-2"];
|
||||||
|
$autoOpt["aktiv"] = reiterAktiv($autoReiter, $autoOpt);
|
||||||
|
?>
|
||||||
|
<?= reiterLeiste($autoReiter, $autoOpt) ?>
|
||||||
<div class="tab-content" id="actions-tabContent">
|
<div class="tab-content" id="actions-tabContent">
|
||||||
<?php foreach ($floors as $etage): ?>
|
<?php foreach ($floors as $etage): ?>
|
||||||
<div class="tab-pane fade <?php if ($_GET["floor"] == $etage) echo "active show"; ?>"
|
<?= reiterFlaecheAuf($etage, $autoOpt) ?>
|
||||||
id="actions-<?= $etage ?>" role="tabpanel" aria-labelledby="actions-<?= $etage ?>-tab">
|
|
||||||
<!-- Inhalt kommt aus ajax/AutoAction.php?action=list, gefuellt von
|
<!-- Inhalt kommt aus ajax/AutoAction.php?action=list, gefuellt von
|
||||||
refreshAutomations() in js/solar/autoActionFuncs.js -->
|
refreshAutomations() in js/solar/autoActionFuncs.js -->
|
||||||
<div id="actions-<?= $etage ?>-list">Wird geladen...</div>
|
<div id="actions-<?= $etage ?>-list">Wird geladen...</div>
|
||||||
<button type="button" class="btn btn-success" title="Neue Automatik"
|
<button type="button" class="btn btn-success" title="Neue Automatik"
|
||||||
onclick="openAutoActionModal('?action=editor&floor=<?= $etage ?>')"><i class="bi bi-plus-square"></i></button>
|
onclick="openAutoActionModal('?action=editor&floor=<?= $etage ?>')"><i class="bi bi-plus-square"></i></button>
|
||||||
</div>
|
<?= reiterFlaecheZu() ?>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Modell-Schicht für die Anzeigewerte der Home-Kacheln.
|
||||||
|
*
|
||||||
|
* Was auf einer Kachel steht, stand bisher fest in restricted/rooms.php. Das
|
||||||
|
* ist weiterhin die Vorgabe - hier kommt nur die Möglichkeit dazu, sie zu
|
||||||
|
* überschreiben, ohne die Datei anzufassen. Eine Kachel ohne Zeile in
|
||||||
|
* kachelWerte zeigt also genau das, was sie immer gezeigt hat.
|
||||||
|
*
|
||||||
|
* Die Form einer Wertdefinition ist dieselbe wie in rooms.php, damit zwischen
|
||||||
|
* Maske, Datenbank und Anzeige nichts übersetzt werden muss:
|
||||||
|
*
|
||||||
|
* topic MQTT-Topic, aus dem der Wert kommt
|
||||||
|
* pfad optional: Schlüssel in einer JSON-Nachricht
|
||||||
|
* wechselrichter Liste von Namen unter solarManager/invertersN (statt topic)
|
||||||
|
* feld bei Wechselrichtern das Feld, meist p_AC
|
||||||
|
* format "leistung" skaliert selbst zwischen W und kW
|
||||||
|
* einheit sonst die Einheit, die hinter der Zahl steht
|
||||||
|
* stellen Nachkommastellen (0-3)
|
||||||
|
* negativ das Vorzeichen umdrehen - fuer Zaehler, die verkehrt
|
||||||
|
* herum eingebaut sind
|
||||||
|
* gross die Zahl steht gross auf der Kachel
|
||||||
|
* icon Symbol vor der Zahl: der Name eines Bootstrap-Icons ohne
|
||||||
|
* das "bi-" davor ("thermometer-half"). Erlaubt ist jedes,
|
||||||
|
* das im Stylesheet steht - siehe bootstrapIcons() in
|
||||||
|
* rooms.php; kachelSymbole() ist nur die Vorschlagsliste.
|
||||||
|
*
|
||||||
|
* Die Tabelle darf fehlen. Ist sie nicht angelegt (solarLog_kacheln.sql), so
|
||||||
|
* verhält sich alles wie vorher, statt dass die Startseite mit einem
|
||||||
|
* SQL-Fehler stehenbleibt - die Kacheln sind keine Einstellung, ohne die man
|
||||||
|
* das Haus nicht mehr sieht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Geladen wird nicht hier, sondern in rooms.php: kachelVorgabe() und
|
||||||
|
* kachelUeberschreibungen() stehen dort, weil die Startseite sie braucht und
|
||||||
|
* diese Datei nicht. So bleibt der Weg zur Anzeige kurz - und es gibt keine
|
||||||
|
* Ringabhängigkeit zwischen Raum-Modell und Maske.
|
||||||
|
*/
|
||||||
|
require_once(__DIR__ . "/rooms.php"); // Räume, Vorgaben, Überschreibungen
|
||||||
|
require_once(__DIR__ . "/costs.php"); // solarDb()
|
||||||
|
|
||||||
|
/** Höchstens drei Werte je Kachel - mehr passt nicht auf 24 x 19 Einheiten. */
|
||||||
|
const KACHEL_MAX = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorschläge für das Symbol vor einer Zahl.
|
||||||
|
*
|
||||||
|
* Erlaubt ist jedes Bootstrap-Icon (bootstrapIcons() in rooms.php liest sie
|
||||||
|
* aus dem Stylesheet, es sind gut zweitausend). Diese Liste ist deshalb keine
|
||||||
|
* Beschränkung, sondern eine Abkürzung: sie steht in der Maske obenan, damit
|
||||||
|
* man für die üblichen Messwerte nicht erst einen Namen suchen muss.
|
||||||
|
*
|
||||||
|
* Gruppiert nach dem, was auf den Kacheln steht - nicht nach dem, wie die
|
||||||
|
* Symbole heißen.
|
||||||
|
*/
|
||||||
|
function kachelSymbole()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
"Klima" => [
|
||||||
|
"thermometer-half" => "Temperatur",
|
||||||
|
"thermometer-sun" => "Temperatur draußen",
|
||||||
|
"thermometer-snow" => "Kälte",
|
||||||
|
"droplet-half" => "Feuchte",
|
||||||
|
"moisture" => "Luftfeuchte",
|
||||||
|
"fan" => "Lüftung",
|
||||||
|
"fire" => "Heizung",
|
||||||
|
"wind" => "Wind",
|
||||||
|
],
|
||||||
|
"Strom" => [
|
||||||
|
"lightning-charge-fill" => "Leistung",
|
||||||
|
"plug-fill" => "Verbrauch",
|
||||||
|
"sun" => "Erzeugung",
|
||||||
|
"battery-half" => "Speicher",
|
||||||
|
"battery-charging" => "Ladung",
|
||||||
|
"ev-station" => "Wallbox",
|
||||||
|
"car-front" => "Fahrzeug",
|
||||||
|
"speedometer2" => "Zähler",
|
||||||
|
"graph-up-arrow" => "Ertrag",
|
||||||
|
"arrow-down-up" => "Bezug und Einspeisung",
|
||||||
|
],
|
||||||
|
"Wasser und Wetter" => [
|
||||||
|
"cloud-rain" => "Regen",
|
||||||
|
"cloud-sun" => "Wetter",
|
||||||
|
"snow" => "Schnee",
|
||||||
|
"water" => "Wasserstand",
|
||||||
|
"bucket" => "Menge",
|
||||||
|
"tree" => "Garten",
|
||||||
|
],
|
||||||
|
"Haus" => [
|
||||||
|
"lightbulb" => "Licht",
|
||||||
|
"door-open" => "Tür",
|
||||||
|
"window" => "Fenster",
|
||||||
|
"house-door" => "Haus",
|
||||||
|
"clock-history" => "Zeit",
|
||||||
|
"wifi" => "Verbindung",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Vorschläge flach, wie die Maske sie braucht: Name => Beschriftung. */
|
||||||
|
function kachelSymboleFlach()
|
||||||
|
{
|
||||||
|
$flach = [];
|
||||||
|
foreach (kachelSymbole() as $gruppe) {
|
||||||
|
foreach ($gruppe as $name => $titel) {
|
||||||
|
$flach[$name] = $titel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $flach;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Symbolnamen für die Vorschlagsliste der Maske.
|
||||||
|
*
|
||||||
|
* Die Vorschläge zuerst und mit ihrer Beschriftung, danach der ganze Rest
|
||||||
|
* alphabetisch: so steht das Übliche oben, und wer etwas anderes will, tippt
|
||||||
|
* los und bekommt es angeboten.
|
||||||
|
*/
|
||||||
|
function kachelIconNamen()
|
||||||
|
{
|
||||||
|
$vorschlag = kachelSymboleFlach();
|
||||||
|
$liste = [];
|
||||||
|
foreach ($vorschlag as $name => $titel) {
|
||||||
|
if (isset(bootstrapIcons()[$name])) {
|
||||||
|
$liste[] = ["name" => $name, "titel" => $titel];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (array_keys(bootstrapIcons()) as $name) {
|
||||||
|
if (!isset($vorschlag[$name])) {
|
||||||
|
$liste[] = ["name" => $name, "titel" => ""];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Kacheln mit ihrem aktuellen Stand, für die Einstellungsseite.
|
||||||
|
*
|
||||||
|
* Je Kachel steht dabei, ob die Werte die Vorgabe sind oder von Hand gesetzt -
|
||||||
|
* die Maske zeigt "Standard wiederherstellen" nur dort, wo es etwas
|
||||||
|
* herzustellen gibt.
|
||||||
|
*/
|
||||||
|
function kachelListe()
|
||||||
|
{
|
||||||
|
$ueber = kachelUeberschreibungen();
|
||||||
|
$liste = [];
|
||||||
|
foreach (allRooms() as $room) {
|
||||||
|
if (!isset($room["x"])) {
|
||||||
|
continue; // kein Platz im Grundriss, keine Kachel
|
||||||
|
}
|
||||||
|
$key = $room["floor"] . "|" . $room["id"];
|
||||||
|
$liste[] = [
|
||||||
|
"floor" => $room["floor"],
|
||||||
|
"floorLabel" => floorLabel($room["floor"]),
|
||||||
|
"room" => $room["id"],
|
||||||
|
"name" => $room["mqtt"],
|
||||||
|
"eigen" => isset($ueber[$key]),
|
||||||
|
"werte" => $ueber[$key] ?? kachelVorgabe($room),
|
||||||
|
"vorgabe" => kachelVorgabe($room),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Messwerte, die sich auswählen lassen - nach Gerät gruppiert.
|
||||||
|
*
|
||||||
|
* Quelle ist derselbe Katalog, aus dem der Automatik-Editor seine Bedingungen
|
||||||
|
* baut (deviceCatalog() in restricted/automations.php). Damit steht in beiden
|
||||||
|
* Masken dasselbe zur Auswahl, und ein neu erkanntes Gerät taucht ohne Zutun
|
||||||
|
* auch hier auf.
|
||||||
|
*
|
||||||
|
* Nicht alles daraus taugt für eine Kachel: die Tahoma-Geräte melden ihre
|
||||||
|
* Zustände über die Box, nicht über den Broker (ihre "url" ist dann ein
|
||||||
|
* Zustandsname wie core:OpenClosedState). Eine Kachel liest aber ausschließlich
|
||||||
|
* MQTT - solche Werte blieben ewig auf "–" stehen und sind deshalb hier nicht
|
||||||
|
* dabei. Übrig bleibt, was ein Topic hat.
|
||||||
|
*
|
||||||
|
* Der Katalog darf fehlen: ohne Mesh-Datenbank gibt es eben keine Auswahl,
|
||||||
|
* und die Maske fällt auf die Eingabe von Hand zurück.
|
||||||
|
*/
|
||||||
|
function kachelKatalog()
|
||||||
|
{
|
||||||
|
$datei = __DIR__ . "/automations.php";
|
||||||
|
if (!file_exists($datei)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
require_once($datei);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$katalog = deviceCatalog();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$geraete = [];
|
||||||
|
foreach ($katalog as $g) {
|
||||||
|
$werte = [];
|
||||||
|
foreach ($g["states"] as $s) {
|
||||||
|
$topic = (string)($s["url"] ?? "");
|
||||||
|
// Ein Topic hat Schrägstriche und kein Schema davor; "core:..."
|
||||||
|
// und "io://..." kommen von der Tahoma-Box.
|
||||||
|
if ($topic === "" || strpos($topic, "/") === false
|
||||||
|
|| preg_match('/^[a-z]+:/', $topic)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$werte[] = [
|
||||||
|
"name" => $s["name"],
|
||||||
|
"einheit" => (string)($s["unit"] ?? ""),
|
||||||
|
"topic" => $topic,
|
||||||
|
"pfad" => (string)($s["pfad"] ?? ""),
|
||||||
|
"wert" => $s["value"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$werte) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Etage und Raum auch als Kuerzel, nicht nur ausgeschrieben: die
|
||||||
|
* Maske stellt die Geraete des eigenen Raums nach oben und muss dafuer
|
||||||
|
* genau vergleichen koennen. actors.room steht als "EG/Technik" da -
|
||||||
|
* dieselben Kuerzel, die auch rooms.php benutzt.
|
||||||
|
*/
|
||||||
|
$wo = explode("/", (string)($g["room"] ?? ""), 2);
|
||||||
|
$geraete[] = [
|
||||||
|
"label" => $g["label"],
|
||||||
|
"floor" => $g["floorLabel"],
|
||||||
|
"floorKey" => $wo[0] ?? "",
|
||||||
|
"raum" => $g["roomName"],
|
||||||
|
"raumKey" => $wo[1] ?? "",
|
||||||
|
"werte" => $werte,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Nach Etage sortieren, darin nach Raum und Geraet. deviceCatalog()
|
||||||
|
* liefert alphabetisch nach Geraetenamen - in einer nach Etagen
|
||||||
|
* gruppierten Liste stuende dieselbe Etage dann mehrfach da, jedes Mal
|
||||||
|
* mit ein, zwei Geraeten darin.
|
||||||
|
*/
|
||||||
|
$reihenfolge = array_flip($GLOBALS["floors"]);
|
||||||
|
usort($geraete, function ($a, $b) use ($reihenfolge) {
|
||||||
|
$fa = $reihenfolge[$a["floorKey"]] ?? 99;
|
||||||
|
$fb = $reihenfolge[$b["floorKey"]] ?? 99;
|
||||||
|
if ($fa !== $fb) {
|
||||||
|
return $fa <=> $fb;
|
||||||
|
}
|
||||||
|
$raum = strcoll($a["raum"], $b["raum"]);
|
||||||
|
return $raum !== 0 ? $raum : strcoll($a["label"], $b["label"]);
|
||||||
|
});
|
||||||
|
return $geraete;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Topics für die Vorschlagsliste der Eingabe von Hand.
|
||||||
|
*
|
||||||
|
* Alles aus dem Katalog plus das, was die Kacheln heute zeigen - denn nicht
|
||||||
|
* jedes Topic am Broker ist irgendwo registriert. Die beiden Shelly-Zähler in
|
||||||
|
* den Technikräumen etwa stehen in keinem Katalog, sind aber auf zwei Kacheln
|
||||||
|
* in Gebrauch.
|
||||||
|
*/
|
||||||
|
function kachelTopicVorschlaege()
|
||||||
|
{
|
||||||
|
$topics = [];
|
||||||
|
|
||||||
|
foreach (allRooms() as $room) {
|
||||||
|
foreach (kachelVorgabe($room) as $wert) {
|
||||||
|
if (!empty($wert["topic"])) {
|
||||||
|
$topics[$wert["topic"]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (kachelUeberschreibungen() as $werte) {
|
||||||
|
foreach ($werte as $wert) {
|
||||||
|
if (!empty($wert["topic"])) {
|
||||||
|
$topics[$wert["topic"]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (kachelKatalog() as $g) {
|
||||||
|
foreach ($g["werte"] as $w) {
|
||||||
|
$topics[$w["topic"]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$liste = array_keys($topics);
|
||||||
|
sort($liste, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
return $liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Wertdefinition prüfen und auf das Nötige eindampfen.
|
||||||
|
*
|
||||||
|
* Geworfen wird bei allem, was die Anzeige nicht darstellen könnte. Was nur
|
||||||
|
* überflüssig ist - eine Einheit neben format "leistung" etwa - fällt still
|
||||||
|
* weg, damit in der Datenbank keine Angaben stehen, die niemand liest.
|
||||||
|
*/
|
||||||
|
function kachelWertPruefen($wert, $nummer)
|
||||||
|
{
|
||||||
|
if (!is_array($wert)) {
|
||||||
|
throw new InvalidArgumentException("Wert " . $nummer . " ist unlesbar.");
|
||||||
|
}
|
||||||
|
$sauber = [];
|
||||||
|
|
||||||
|
$wechselrichter = array_values(array_filter(array_map(
|
||||||
|
"trim", (array)($wert["wechselrichter"] ?? []))));
|
||||||
|
$topic = trim((string)($wert["topic"] ?? ""));
|
||||||
|
|
||||||
|
if ($wechselrichter) {
|
||||||
|
$sauber["wechselrichter"] = $wechselrichter;
|
||||||
|
$feld = trim((string)($wert["feld"] ?? "p_AC"));
|
||||||
|
$sauber["feld"] = $feld !== "" ? $feld : "p_AC";
|
||||||
|
} elseif ($topic !== "") {
|
||||||
|
if (mb_strlen($topic) > 120) {
|
||||||
|
throw new InvalidArgumentException("Wert " . $nummer . ": das Topic ist zu lang.");
|
||||||
|
}
|
||||||
|
$sauber["topic"] = $topic;
|
||||||
|
$pfad = trim((string)($wert["pfad"] ?? ""));
|
||||||
|
if ($pfad !== "") {
|
||||||
|
$sauber["pfad"] = $pfad;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Wert " . $nummer . " braucht ein Topic oder einen Wechselrichter.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entweder das Format rechnet die Einheit selbst aus, oder sie steht da.
|
||||||
|
if (($wert["format"] ?? "") === "leistung") {
|
||||||
|
$sauber["format"] = "leistung";
|
||||||
|
} else {
|
||||||
|
$einheit = trim((string)($wert["einheit"] ?? ""));
|
||||||
|
if (mb_strlen($einheit) > 8) {
|
||||||
|
throw new InvalidArgumentException("Wert " . $nummer . ": die Einheit ist zu lang.");
|
||||||
|
}
|
||||||
|
if ($einheit !== "") {
|
||||||
|
$sauber["einheit"] = $einheit;
|
||||||
|
}
|
||||||
|
$stellen = $wert["stellen"] ?? null;
|
||||||
|
if ($stellen !== null && $stellen !== "") {
|
||||||
|
$stellen = intval($stellen);
|
||||||
|
if ($stellen < 0 || $stellen > 3) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Wert " . $nummer . ": Nachkommastellen gehen von 0 bis 3.");
|
||||||
|
}
|
||||||
|
$sauber["stellen"] = $stellen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jedes Bootstrap-Icon darf es sein; geprüft wird gegen dieselbe Liste,
|
||||||
|
// aus der die Anzeige später das Zeichen holt. Ein Tippfehler faellt so
|
||||||
|
// beim Speichern auf und nicht erst als leere Stelle auf der Kachel.
|
||||||
|
$icon = trim((string)($wert["icon"] ?? ""));
|
||||||
|
if ($icon !== "") {
|
||||||
|
if (!isset(bootstrapIcons()[$icon])) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Wert " . $nummer . ": das Symbol \"" . $icon . "\" gibt es nicht. "
|
||||||
|
. "Die Namen stehen auf icons.getbootstrap.com, ohne das \"bi-\" davor.");
|
||||||
|
}
|
||||||
|
$sauber["icon"] = $icon;
|
||||||
|
}
|
||||||
|
if (!empty($wert["negativ"])) {
|
||||||
|
$sauber["negativ"] = true;
|
||||||
|
}
|
||||||
|
if (!empty($wert["gross"])) {
|
||||||
|
$sauber["gross"] = true;
|
||||||
|
}
|
||||||
|
return $sauber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Werte einer Kachel speichern.
|
||||||
|
*
|
||||||
|
* $werte === null setzt auf die Vorgabe zurück - die Zeile verschwindet, statt
|
||||||
|
* die Vorgabe hineinzukopieren. Sonst stünde in der Datenbank eine Kopie, die
|
||||||
|
* nicht mitzöge, wenn sich rooms.php ändert.
|
||||||
|
*
|
||||||
|
* Eine leere Liste ist etwas anderes: die Kachel zeigt dann bewusst keinen
|
||||||
|
* Messwert, sondern nur ihren Namen.
|
||||||
|
*/
|
||||||
|
function kachelWerteSpeichern($floor, $room, $werte)
|
||||||
|
{
|
||||||
|
if (!kachelTabelleDa()) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Die Tabelle kachelWerte fehlt. Sie steht in solarLog_kacheln.sql.");
|
||||||
|
}
|
||||||
|
$treffer = null;
|
||||||
|
foreach (allRooms() as $r) {
|
||||||
|
if ($r["floor"] === $floor && $r["id"] === $room && isset($r["x"])) {
|
||||||
|
$treffer = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$treffer) {
|
||||||
|
throw new InvalidArgumentException("Diese Kachel gibt es nicht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = solarDb();
|
||||||
|
if ($werte === null) {
|
||||||
|
$stmt = $db->prepare("DELETE FROM kachelWerte WHERE floor = ? AND room = ?");
|
||||||
|
$stmt->bind_param("ss", $floor, $room);
|
||||||
|
$stmt->execute();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($werte)) {
|
||||||
|
throw new InvalidArgumentException("Die Werteliste ist unlesbar.");
|
||||||
|
}
|
||||||
|
if (count($werte) > KACHEL_MAX) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Mehr als " . KACHEL_MAX . " Werte passen nicht auf eine Kachel.");
|
||||||
|
}
|
||||||
|
$sauber = [];
|
||||||
|
foreach (array_values($werte) as $i => $wert) {
|
||||||
|
$sauber[] = kachelWertPruefen($wert, $i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zwei grosse Zahlen nebeneinander gibt es nicht: die Kachel hat genau
|
||||||
|
// eine Zeile, die aus der Entfernung lesbar ist.
|
||||||
|
$grosse = count(array_filter($sauber, function ($w) { return !empty($w["gross"]); }));
|
||||||
|
if ($grosse > 1) {
|
||||||
|
throw new InvalidArgumentException("Nur ein Wert darf gross stehen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_encode($sauber, JSON_UNESCAPED_UNICODE);
|
||||||
|
$stmt = $db->prepare("INSERT INTO kachelWerte (floor, room, werte) VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE werte = VALUES(werte)");
|
||||||
|
$stmt->bind_param("sss", $floor, $room, $json);
|
||||||
|
if (!$stmt->execute()) {
|
||||||
|
throw new RuntimeException($stmt->error);
|
||||||
|
}
|
||||||
|
return $sauber;
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Reiter - eine Bauart für die ganze Seite.
|
||||||
|
*
|
||||||
|
* Vorher gab es drei: die Etagenreiter über den Automatiken (Home) mit einem
|
||||||
|
* eigenen switchTab() in JavaScript, die Gerätegruppen im Raum-Modal als
|
||||||
|
* Bootstrap-Reiter mit eigener CSS-Klasse, und die Einstellungsseite noch
|
||||||
|
* einmal anders. Drei Markups, zwei Verhaltensweisen, ein Sonderaussehen -
|
||||||
|
* und jede Änderung am Erscheinungsbild wäre dreimal zu machen gewesen.
|
||||||
|
*
|
||||||
|
* Hier steht nur noch, wie ein Reiter aussieht und heißt. Umgeschaltet wird
|
||||||
|
* von Bootstrap (data-bs-toggle="tab"); eigenes JavaScript dafür gibt es
|
||||||
|
* nicht mehr. Wer sich den offenen Reiter merken will, setzt "merken" - das
|
||||||
|
* übernimmt reiterMerken() in js/solar/common.js für alle Leisten der Seite.
|
||||||
|
*
|
||||||
|
* Ein Reiter ist:
|
||||||
|
*
|
||||||
|
* id Kürzel, taucht in den Element-Ids und in der Adresse auf
|
||||||
|
* titel Beschriftung
|
||||||
|
* symbol optional ein Bootstrap-Icon ("bi-currency-euro")
|
||||||
|
* titel2 optional der lange Name als Sprechblase (Etagenkürzel!)
|
||||||
|
* inhalt nur für reiterBlock(): der fertige Inhalt der Fläche
|
||||||
|
*
|
||||||
|
* Aufrufarten - je nachdem, ob der Inhalt schon als Text vorliegt:
|
||||||
|
*
|
||||||
|
* reiterBlock($reiter, $opt) Leiste und Flächen am Stück
|
||||||
|
* reiterLeiste($reiter, $opt) nur die Leiste ...
|
||||||
|
* reiterFlaecheAuf($id, $opt) ... und die Flächen einzeln, wenn der
|
||||||
|
* reiterFlaecheZu() Inhalt im Template steht
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Kennung eines Reiters. Der Name der Leiste steht davor, damit zwei
|
||||||
|
* Leisten auf derselben Seite sich nicht dieselben Ids teilen.
|
||||||
|
*/
|
||||||
|
function reiterId($opt, $id, $anhang = "")
|
||||||
|
{
|
||||||
|
return ($opt["name"] ?? "reiter") . "-" . $id . $anhang;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welcher Reiter offen ist: der ausdrücklich genannte, sonst der erste.
|
||||||
|
*
|
||||||
|
* Als eigene Funktion, weil Leiste und Flächen dieselbe Antwort brauchen -
|
||||||
|
* und weil "der erste" bei den Flächen sonst noch einmal ausgerechnet würde.
|
||||||
|
*/
|
||||||
|
function reiterAktiv($reiter, $opt)
|
||||||
|
{
|
||||||
|
$ids = array_column($reiter, "id");
|
||||||
|
$aktiv = $opt["aktiv"] ?? null;
|
||||||
|
return ($aktiv !== null && in_array($aktiv, $ids, true)) ? $aktiv : ($ids[0] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Leiste.
|
||||||
|
*
|
||||||
|
* $opt:
|
||||||
|
* name Namensraum der Ids; sollte gesetzt sein, sonst "reiter"
|
||||||
|
* aktiv id des offenen Reiters (Vorgabe: der erste)
|
||||||
|
* merken true: der offene Reiter steht in der Adresse und übersteht ein
|
||||||
|
* Neuladen. Nur eine Leiste je Seite sollte das tun.
|
||||||
|
* schmal true: kleinere Reiter, die waagerecht scrollen statt umzubrechen
|
||||||
|
* - für enge Stellen wie das Raum-Modal
|
||||||
|
* klasse zusätzliche Klassen für die Leiste
|
||||||
|
*/
|
||||||
|
function reiterLeiste($reiter, $opt = [])
|
||||||
|
{
|
||||||
|
if (count($reiter) < 2 && empty($opt["immer"])) {
|
||||||
|
// Ein einzelner Reiter beschriftet nur sich selbst. Das Raum-Modal
|
||||||
|
// hat oft nur eine Gerätegruppe - dort ist die Leiste dann weg.
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
$aktiv = reiterAktiv($reiter, $opt);
|
||||||
|
$klassen = "nav nav-tabs reiter"
|
||||||
|
. (!empty($opt["schmal"]) ? " reiter-schmal" : "")
|
||||||
|
. (isset($opt["klasse"]) ? " " . $opt["klasse"] : "");
|
||||||
|
|
||||||
|
$html = "<ul class='" . $klassen . "' role='tablist'"
|
||||||
|
. (!empty($opt["merken"]) ? " data-reiter-merken='" . htmlspecialchars($opt["name"] ?? "reiter") . "'" : "")
|
||||||
|
. ">";
|
||||||
|
foreach ($reiter as $r) {
|
||||||
|
$offen = $r["id"] === $aktiv;
|
||||||
|
$html .= "<li class='nav-item' role='presentation'>"
|
||||||
|
. "<button class='nav-link" . ($offen ? " active" : "") . "' type='button' role='tab'"
|
||||||
|
. " id='" . reiterId($opt, $r["id"], "-tab") . "'"
|
||||||
|
. " data-bs-toggle='tab' data-bs-target='#" . reiterId($opt, $r["id"]) . "'"
|
||||||
|
. " data-reiter='" . htmlspecialchars($r["id"]) . "'"
|
||||||
|
. " aria-controls='" . reiterId($opt, $r["id"]) . "'"
|
||||||
|
. " aria-selected='" . ($offen ? "true" : "false") . "'"
|
||||||
|
. (isset($r["titel2"]) ? " title='" . htmlspecialchars($r["titel2"]) . "'" : "")
|
||||||
|
. ">"
|
||||||
|
// Der Abstand zum Text kommt aus dem Stylesheet, nicht aus
|
||||||
|
// einer Hilfsklasse: in der schmalen Fassung ist er kleiner.
|
||||||
|
. (isset($r["symbol"]) ? "<i class='bi " . htmlspecialchars($r["symbol"]) . "'></i>" : "")
|
||||||
|
. htmlspecialchars($r["titel"])
|
||||||
|
. "</button></li>";
|
||||||
|
}
|
||||||
|
return $html . "</ul>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anfang einer Fläche - der Inhalt steht danach im Template. */
|
||||||
|
function reiterFlaecheAuf($id, $opt = [])
|
||||||
|
{
|
||||||
|
$offen = ($opt["aktiv"] ?? null) === $id;
|
||||||
|
return "<div class='tab-pane fade" . ($offen ? " show active" : "") . "'"
|
||||||
|
. " id='" . reiterId($opt, $id) . "' role='tabpanel'"
|
||||||
|
. " aria-labelledby='" . reiterId($opt, $id, "-tab") . "'>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ende einer Fläche. Eigene Funktion, damit im Template kein nacktes </div> steht. */
|
||||||
|
function reiterFlaecheZu()
|
||||||
|
{
|
||||||
|
return "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leiste und Flächen am Stück - für Aufrufer, die den Inhalt schon als Text
|
||||||
|
* haben (siehe zeichneGruppen() im Raum-Modal).
|
||||||
|
*/
|
||||||
|
function reiterBlock($reiter, $opt = [])
|
||||||
|
{
|
||||||
|
$opt["aktiv"] = reiterAktiv($reiter, $opt);
|
||||||
|
$html = reiterLeiste($reiter, $opt) . "<div class='tab-content'>";
|
||||||
|
foreach ($reiter as $r) {
|
||||||
|
$html .= reiterFlaecheAuf($r["id"], $opt) . ($r["inhalt"] ?? "") . reiterFlaecheZu();
|
||||||
|
}
|
||||||
|
return $html . "</div>";
|
||||||
|
}
|
||||||
+20
-25
@@ -19,6 +19,10 @@
|
|||||||
* die hier gesetzt werden.
|
* die hier gesetzt werden.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Die Geraetegruppen werden als Reiter gezeichnet - eine Bauart fuer die
|
||||||
|
// ganze Seite, siehe restricted/reiter.php.
|
||||||
|
require_once(__DIR__ . "/reiter.php");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Was das Kommando schaltet: "ein", "aus", "um" - oder null, wenn es kein
|
* Was das Kommando schaltet: "ein", "aus", "um" - oder null, wenn es kein
|
||||||
* Schaltkommando ist.
|
* Schaltkommando ist.
|
||||||
@@ -209,38 +213,29 @@ function zeichneGruppen($gruppen, $fertig = [])
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
$html = "";
|
// Aus den Gruppen werden Reiter. Wie sie aussehen und wie umgeschaltet
|
||||||
if (count($vorhanden) > 1) {
|
// wird, steht in restricted/reiter.php - hier nur, was drinsteht.
|
||||||
$html .= "<ul class='nav nav-tabs flex-nowrap mb-2 raum-tabs' role='tablist'>";
|
$reiter = [];
|
||||||
$erster = true;
|
|
||||||
foreach ($vorhanden as $form => $titel) {
|
|
||||||
$html .= "<li class='nav-item' role='presentation'>"
|
|
||||||
. "<button class='nav-link" . ($erster ? " active" : "") . "' type='button'"
|
|
||||||
. " data-bs-toggle='tab' data-bs-target='#raumtab-" . $form . "' role='tab'>"
|
|
||||||
. "<i class='bi " . $titel[1] . "'></i>"
|
|
||||||
. "<span class='ms-1'>" . htmlspecialchars($titel[0]) . "</span>"
|
|
||||||
. "</button></li>";
|
|
||||||
$erster = false;
|
|
||||||
}
|
|
||||||
$html .= "</ul>";
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= "<div class='tab-content'>";
|
|
||||||
$erster = true;
|
|
||||||
foreach ($vorhanden as $form => $titel) {
|
foreach ($vorhanden as $form => $titel) {
|
||||||
$html .= "<div class='tab-pane fade" . ($erster ? " show active" : "") . "'"
|
$inhalt = "";
|
||||||
. " id='raumtab-" . $form . "' role='tabpanel'>";
|
|
||||||
if (isset($fertig[$form])) {
|
if (isset($fertig[$form])) {
|
||||||
$html .= $fertig[$form];
|
$inhalt = $fertig[$form];
|
||||||
} else {
|
} else {
|
||||||
foreach ($gruppen[$form] as $d) {
|
foreach ($gruppen[$form] as $d) {
|
||||||
$html .= zeichneGeraet($d, $form);
|
$inhalt .= zeichneGeraet($d, $form);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$html .= "</div>";
|
$reiter[] = [
|
||||||
$erster = false;
|
"id" => $form,
|
||||||
|
"titel" => $titel[0],
|
||||||
|
"symbol" => $titel[1],
|
||||||
|
"inhalt" => $inhalt,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return $html . "</div>";
|
// "schmal", weil das Modal eng ist: kleinere Reiter, die waagerecht
|
||||||
|
// scrollen statt umzubrechen.
|
||||||
|
return reiterBlock($reiter, ["name" => "raumtab", "schmal" => true,
|
||||||
|
"klasse" => "mb-2"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
+131
-20
@@ -2,6 +2,11 @@
|
|||||||
/*
|
/*
|
||||||
* Einzige Quelle für die Räume der Home-Ansicht.
|
* Einzige Quelle für die Räume der Home-Ansicht.
|
||||||
*
|
*
|
||||||
|
* Die Werte auf den Kacheln sind hier die Vorgabe; überschrieben werden sie
|
||||||
|
* auf der Einstellungsseite (Reiter "Home-Kacheln"). Was dort gesetzt wurde,
|
||||||
|
* steht in der Tabelle kachelWerte und hat Vorrang - alles Übrige zeigt
|
||||||
|
* weiterhin, was hier steht.
|
||||||
|
*
|
||||||
* Aus dieser Tabelle entstehen sowohl die SVG-Kacheln in home.php als auch
|
* Aus dieser Tabelle entstehen sowohl die SVG-Kacheln in home.php als auch
|
||||||
* die Aktualisierungsschleife in js/solar/homeMQTT.js (die Tabelle wird dafür
|
* die Aktualisierungsschleife in js/solar/homeMQTT.js (die Tabelle wird dafür
|
||||||
* als JSON in die Seite geschrieben). Ein neuer oder umbenannter Raum ist
|
* als JSON in die Seite geschrieben). Ein neuer oder umbenannter Raum ist
|
||||||
@@ -12,7 +17,9 @@
|
|||||||
* id Kürzel in den SVG-Element-IDs: <floor>_w0_<id>, <floor>_<id>_heater
|
* id Kürzel in den SVG-Element-IDs: <floor>_w0_<id>, <floor>_<id>_heater
|
||||||
* x, y Position der Kachel im SVG (viewBox 400x300) - fehlt bei Räumen
|
* x, y Position der Kachel im SVG (viewBox 400x300) - fehlt bei Räumen
|
||||||
* ohne Kachel
|
* ohne Kachel
|
||||||
* werte Was auf der Kachel steht: bis zu drei Messwerte, jeder mit
|
* werte Was auf der Kachel steht, sofern auf der Einstellungsseite nichts
|
||||||
|
* anderes gewählt wurde (Tabelle kachelWerte, siehe
|
||||||
|
* 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) oder dem Paar
|
||||||
@@ -33,6 +40,10 @@
|
|||||||
* ist die linke obere Ecke der 24 x 19 grossen Kachel, nicht ihre Mitte.
|
* ist die linke obere Ecke der 24 x 19 grossen Kachel, nicht ihre Mitte.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// solarDb() für die Kachelwerte. costs.php definiert nur Funktionen und
|
||||||
|
// verbindet sich erst, wenn eine davon gerufen wird.
|
||||||
|
require_once(__DIR__ . "/costs.php");
|
||||||
|
|
||||||
// Reihenfolge im Menü und in der Anzeige; OG ist die Standardetage.
|
// Reihenfolge im Menü und in der Anzeige; OG ist die Standardetage.
|
||||||
//
|
//
|
||||||
// AG ist das Außengelände - eine Etage ohne Grundriss. Was draußen hängt,
|
// AG ist das Außengelände - eine Etage ohne Grundriss. Was draußen hängt,
|
||||||
@@ -191,25 +202,55 @@ function floorLabel($floor)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Codepunkt eines Bootstrap-Icons für die Kacheln.
|
* Alle Bootstrap-Icons als Name => Codepunkt.
|
||||||
|
*
|
||||||
|
* Die Zahlen standen früher als Handabschrift hier - drei Stück, und jedes
|
||||||
|
* weitere Symbol war eine Codeänderung. Sie stehen aber ohnehin schon im
|
||||||
|
* Haus: css/bootstrap-icons.min.css führt sie unter .bi-<name>::before, das
|
||||||
|
* ist die Quelle, aus der auch der Browser liest. Also wird sie gelesen,
|
||||||
|
* statt sie abzuschreiben; damit steht jedes der gut zweitausend Symbole zur
|
||||||
|
* Verfügung, und ein Wechsel der Icon-Version zieht von selbst mit.
|
||||||
|
*
|
||||||
|
* Einmal je Anfrage - das Auslesen kostet rund eine Millisekunde, und die
|
||||||
|
* Startseite fragt für jede Kachel nach.
|
||||||
|
*/
|
||||||
|
function bootstrapIcons()
|
||||||
|
{
|
||||||
|
static $icons = null;
|
||||||
|
if ($icons !== null) {
|
||||||
|
return $icons;
|
||||||
|
}
|
||||||
|
$icons = [];
|
||||||
|
$css = @file_get_contents(__DIR__ . "/../css/bootstrap-icons.min.css");
|
||||||
|
if ($css === false) {
|
||||||
|
return $icons; // ohne Datei eben ohne Symbole
|
||||||
|
}
|
||||||
|
// Im CSS steht content:"\f5a2" - der Backslash gehoert zum Inhalt, im
|
||||||
|
// Muster ist er deshalb doppelt.
|
||||||
|
if (preg_match_all('/\.bi-([a-z0-9-]+)::before\{content:"\\\\([0-9a-f]+)"\}/',
|
||||||
|
$css, $treffer, PREG_SET_ORDER)) {
|
||||||
|
foreach ($treffer as $t) {
|
||||||
|
$icons[$t[1]] = hexdec($t[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $icons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Zeichen eines Bootstrap-Icons für die Kacheln.
|
||||||
*
|
*
|
||||||
* Die Icons kommen als Schrift, nicht als Pfad: css/bootstrap-icons.min.css
|
* Die Icons kommen als Schrift, nicht als Pfad: css/bootstrap-icons.min.css
|
||||||
* ist ohnehin eingebunden (restricted/header.php), und SVG-Text darf dieselbe
|
* ist ohnehin eingebunden (restricted/header.php), und SVG-Text darf dieselbe
|
||||||
* Schriftfamilie benutzen wie HTML. Ein eingebettetes <path> je Symbol wäre
|
* Schriftfamilie benutzen wie HTML. Ein eingebettetes <path> je Symbol wäre
|
||||||
* dieselbe Wirkung mit mehr Zeilen.
|
* dieselbe Wirkung mit mehr Zeilen.
|
||||||
*
|
*
|
||||||
* Die Zahlen stehen in genau dieser CSS unter .bi-<name>::before und sind von
|
* Ein unbekannter Name gibt einen leeren Text - die Kachel zeigt dann die
|
||||||
* dort abgeschrieben - deshalb steht der Name daneben, sonst prüft das nie
|
* Zahl ohne Symbol, statt ein leeres Kästchen anzuzeigen.
|
||||||
* wieder jemand nach.
|
|
||||||
*/
|
*/
|
||||||
function kachelIcon($name)
|
function kachelIcon($name)
|
||||||
{
|
{
|
||||||
$icons = [
|
$icons = bootstrapIcons();
|
||||||
"ev-station" => "F83A", // Wallbox, Ladeleistung
|
return isset($icons[$name]) ? mb_chr($icons[$name], "UTF-8") : "";
|
||||||
"cloud-rain" => "F2B6", // Regenmenge
|
|
||||||
"sun" => "F5A2", // PV-Erzeugung
|
|
||||||
];
|
|
||||||
return isset($icons[$name]) ? mb_chr(hexdec($icons[$name]), "UTF-8") : "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -239,27 +280,97 @@ function roomsWithTile()
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Messwerte einer Kachel, fertig ausgeschrieben.
|
* Was eine Kachel ohne Zutun zeigt.
|
||||||
*
|
*
|
||||||
* Ohne eigene Angabe sind es die drei Thermostatwerte, die dort schon immer
|
* Ohne eigene Angabe sind es die drei Thermostatwerte, die dort schon immer
|
||||||
* standen: Solltemperatur klein darüber, Isttemperatur gross, Feuchte klein
|
* standen: Solltemperatur klein darüber, Isttemperatur gross, Feuchte klein
|
||||||
* darunter. Nur diese Räume bekommen auch die Symbole für "heizt" und
|
* darunter.
|
||||||
* "Pufferbetrieb" - anderswo gibt es nichts zu heizen.
|
|
||||||
*/
|
*/
|
||||||
function mitWerten($room)
|
function kachelVorgabe($room)
|
||||||
{
|
{
|
||||||
if (isset($room["werte"])) {
|
if (isset($room["werte"])) {
|
||||||
$room["heizung"] = false;
|
return array_slice($room["werte"], 0, 3);
|
||||||
$room["werte"] = array_slice($room["werte"], 0, 3);
|
|
||||||
return $room;
|
|
||||||
}
|
}
|
||||||
$zweig = "Raumtemp/" . $room["floor"] . "/" . $room["mqtt"] . "/";
|
$zweig = "Raumtemp/" . $room["floor"] . "/" . $room["mqtt"] . "/";
|
||||||
$room["heizung"] = true;
|
return [
|
||||||
$room["werte"] = [
|
|
||||||
["topic" => $zweig . "Set Temp[degC]", "einheit" => "°C", "stellen" => 0],
|
["topic" => $zweig . "Set Temp[degC]", "einheit" => "°C", "stellen" => 0],
|
||||||
["topic" => $zweig . "Temp[degC]", "einheit" => "°C", "gross" => true],
|
["topic" => $zweig . "Temp[degC]", "einheit" => "°C", "gross" => true],
|
||||||
["topic" => $zweig . "rHum[%]", "einheit" => "%rF"],
|
["topic" => $zweig . "rHum[%]", "einheit" => "%rF"],
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt es die Tabelle mit den selbst gewählten Kachelwerten?
|
||||||
|
*
|
||||||
|
* Sie darf fehlen (solarLog_kacheln.sql legt sie an). Dann gilt überall die
|
||||||
|
* Vorgabe - die Startseite bleibt nicht mit einem SQL-Fehler stehen, nur weil
|
||||||
|
* eine Einstellung noch nie benutzt wurde.
|
||||||
|
*/
|
||||||
|
function kachelTabelleDa()
|
||||||
|
{
|
||||||
|
static $da = null;
|
||||||
|
if ($da === null) {
|
||||||
|
/*
|
||||||
|
* Auch der Ausfall der Datenbank fuehrt hier nur zur Vorgabe. Die
|
||||||
|
* Kacheln holen ihre Zahlen vom Broker, nicht aus MySQL - die
|
||||||
|
* Startseite lief bisher ohne Datenbank, und das soll so bleiben.
|
||||||
|
* Ohne dieses try stuende dort im Ernstfall ein Fatal Error statt des
|
||||||
|
* Hauses.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
$res = solarDb()->query("SHOW TABLES LIKE 'kachelWerte'");
|
||||||
|
$da = $res && $res->num_rows > 0;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$da = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $da;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die von Hand gesetzten Werte, nach "Etage|Raum" abgelegt.
|
||||||
|
*
|
||||||
|
* Eine Abfrage je Seitenaufbau, nicht eine je Kachel - die Startseite zeichnet
|
||||||
|
* zwanzig davon.
|
||||||
|
*/
|
||||||
|
function kachelUeberschreibungen()
|
||||||
|
{
|
||||||
|
static $werte = null;
|
||||||
|
if ($werte !== null) {
|
||||||
|
return $werte;
|
||||||
|
}
|
||||||
|
$werte = [];
|
||||||
|
if (!kachelTabelleDa()) {
|
||||||
|
return $werte;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$res = solarDb()->query("SELECT floor, room, werte FROM kachelWerte");
|
||||||
|
while ($res && $row = $res->fetch_assoc()) {
|
||||||
|
$liste = json_decode($row["werte"], true);
|
||||||
|
if (is_array($liste)) {
|
||||||
|
$werte[$row["floor"] . "|" . $row["room"]] = array_slice($liste, 0, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$werte = []; // siehe kachelTabelleDa(): dann eben die Vorgabe
|
||||||
|
}
|
||||||
|
return $werte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Messwerte einer Kachel, fertig ausgeschrieben.
|
||||||
|
*
|
||||||
|
* Zuerst das, was auf der Einstellungsseite gewählt wurde; sonst die Vorgabe.
|
||||||
|
*
|
||||||
|
* Die Symbole für "heizt" und "Pufferbetrieb" hängen am Raum, nicht an der
|
||||||
|
* Anzeige: ein Thermostatraum bleibt einer, auch wenn auf seiner Kachel
|
||||||
|
* inzwischen etwas anderes steht. Anderswo gibt es nichts zu heizen.
|
||||||
|
*/
|
||||||
|
function mitWerten($room)
|
||||||
|
{
|
||||||
|
$room["heizung"] = !isset($room["werte"]);
|
||||||
|
$eigen = kachelUeberschreibungen()[$room["floor"] . "|" . $room["id"]] ?? null;
|
||||||
|
$room["werte"] = $eigen !== null ? $eigen : kachelVorgabe($room);
|
||||||
return $room;
|
return $room;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+104
-4
@@ -2,10 +2,15 @@
|
|||||||
/**
|
/**
|
||||||
* Einstellungsseite: Parameter, die von Hand gepflegt werden.
|
* Einstellungsseite: Parameter, die von Hand gepflegt werden.
|
||||||
*
|
*
|
||||||
* Bisher sind das die Preistabellen aus restricted/costs.php. Die Seite ist
|
* Gegliedert in Reiter, einer je Thema - Preise und die Anzeigewerte der
|
||||||
* als Sammlung von Karten angelegt - eine Karte je Thema. Kommt spaeter ein
|
* Home-Kacheln. Die Liste $reiter unten ist die einzige Stelle, die davon
|
||||||
* weiterer Parameter dazu, kommt er als eigene Karte hinzu, ohne dass die
|
* weiss: ein neues Thema ist ein Eintrag dort plus der Inhalt, alles Uebrige
|
||||||
* vorhandenen etwas davon merken.
|
* (Navigation, Umschalten, Merken des Reiters in der Adresse) ergibt sich
|
||||||
|
* daraus. Vorher stand jedes Thema als Karte untereinander; bei drei Themen
|
||||||
|
* waere die Seite ein Scrollband geworden.
|
||||||
|
*
|
||||||
|
* Innerhalb eines Reiters bleibt es bei Karten - eine je Tabelle bzw. je
|
||||||
|
* Etage.
|
||||||
*
|
*
|
||||||
* Die Zeilen zeichnet js/solar/settings.js aus dem Modell, das hier
|
* Die Zeilen zeichnet js/solar/settings.js aus dem Modell, das hier
|
||||||
* eingebettet wird - dieselbe Arbeitsteilung wie im Automatik-Editor. Sonst
|
* eingebettet wird - dieselbe Arbeitsteilung wie im Automatik-Editor. Sonst
|
||||||
@@ -15,6 +20,22 @@
|
|||||||
* die Felder dort muessen dieselben sein.
|
* die Felder dort muessen dieselben sein.
|
||||||
*/
|
*/
|
||||||
require_once(__DIR__ . "/costs.php");
|
require_once(__DIR__ . "/costs.php");
|
||||||
|
require_once(__DIR__ . "/kacheln.php");
|
||||||
|
require_once(__DIR__ . "/reiter.php");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Reiter. Reihenfolge ist Anzeigereihenfolge; der erste ist der offene,
|
||||||
|
* solange die Adresse nichts anderes sagt (#kacheln).
|
||||||
|
*/
|
||||||
|
$reiter = [
|
||||||
|
["id" => "preise", "titel" => "Preise", "symbol" => "bi-currency-euro"],
|
||||||
|
["id" => "kacheln", "titel" => "Home-Kacheln", "symbol" => "bi-grid-1x2"],
|
||||||
|
["id" => "geraete", "titel" => "Geräte", "symbol" => "bi-house-gear"],
|
||||||
|
];
|
||||||
|
// "merken": der offene Reiter steht in der Adresse und uebersteht ein
|
||||||
|
// Neuladen - siehe reiterMerken() in js/solar/common.js.
|
||||||
|
$reiterOpt = ["name" => "einstellung", "merken" => true, "klasse" => "mb-3"];
|
||||||
|
$reiterOpt["aktiv"] = reiterAktiv($reiter, $reiterOpt);
|
||||||
|
|
||||||
$reihen = preisReihen();
|
$reihen = preisReihen();
|
||||||
$daten = [];
|
$daten = [];
|
||||||
@@ -50,6 +71,13 @@ try {
|
|||||||
<?php if ($fehler): ?>
|
<?php if ($fehler): ?>
|
||||||
<div class="alert alert-danger">Die Einstellungen sind nicht erreichbar: <?= htmlspecialchars($fehler) ?></div>
|
<div class="alert alert-danger">Die Einstellungen sind nicht erreichbar: <?= htmlspecialchars($fehler) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Reiter. Welcher offen ist, merkt sich die Adresse (#kacheln) -
|
||||||
|
ein Neuladen nach dem Speichern landet damit wieder dort. -->
|
||||||
|
<?= reiterLeiste($reiter, $reiterOpt) ?>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<?= reiterFlaecheAuf("preise", $reiterOpt) ?>
|
||||||
<?php foreach ($reihen as $key => $reihe): ?>
|
<?php foreach ($reihen as $key => $reihe): ?>
|
||||||
<?php $breiten = preisSpalten($reihe["felder"]); ?>
|
<?php $breiten = preisSpalten($reihe["felder"]); ?>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -94,6 +122,78 @@ try {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
<?= reiterFlaecheZu() ?><!-- /Preise -->
|
||||||
|
|
||||||
|
<!-- ================================================ Home-Kacheln ==== -->
|
||||||
|
<!-- Der Inhalt kommt aus js/solar/settings.js: eine Karte je Etage, darin
|
||||||
|
je Raum eine Zeile mit ihren Werten. Gezeichnet wird aus dem Modell,
|
||||||
|
das ajax/settings.php?action=kacheln liefert - dieselbe Arbeits-
|
||||||
|
teilung wie bei den Preisen. -->
|
||||||
|
<?= reiterFlaecheAuf("kacheln", $reiterOpt) ?>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-xxl-9">
|
||||||
|
<div class="card mb-4" id="kachelKarte">
|
||||||
|
<div class="card-header pb-2 pt-2">
|
||||||
|
<h3 class="card-title"><i class="bi bi-grid-1x2 me-2"></i>Werte auf den Kacheln</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<button type="button" class="btn btn-tool" id="kachelNeuLaden"
|
||||||
|
title="Neu laden">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-secondary small mb-3">
|
||||||
|
Jede Kachel im Grundriss zeigt bis zu drei Messwerte. Ohne eigene
|
||||||
|
Angabe sind das die Werte aus <code>restricted/rooms.php</code> –
|
||||||
|
bei Räumen mit Thermostat Soll, Ist und Feuchte. Was hier gesetzt
|
||||||
|
wird, gilt stattdessen; „Standard“ nimmt es wieder zurück.
|
||||||
|
</p>
|
||||||
|
<div id="kachelListe">
|
||||||
|
<p class="text-secondary small mb-0">Wird geladen …</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?= reiterFlaecheZu() ?><!-- /Home-Kacheln -->
|
||||||
|
|
||||||
|
<!-- ===================================================== Geräte ==== -->
|
||||||
|
<!-- Die Zuordnung Gerät -> Raum lag vorher als Modal auf der Home-Seite.
|
||||||
|
Sie gehört hierher: sie wird selten geändert, sie ist eine
|
||||||
|
Einstellung, und hier ist Platz für das, was daneben gehört - wie
|
||||||
|
lange ein Gerät nichts mehr gemeldet hat, in welchen Automatiken es
|
||||||
|
steckt, und der Weg, eine Karteileiche loszuwerden. -->
|
||||||
|
<?= reiterFlaecheAuf("geraete", $reiterOpt) ?>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-xxl-10">
|
||||||
|
<div class="card mb-4" id="geraeteKarte">
|
||||||
|
<div class="card-header pb-2 pt-2">
|
||||||
|
<h3 class="card-title"><i class="bi bi-house-gear me-2"></i>Geräte und Räume</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<button type="button" class="btn btn-tool" id="geraeteNeuLaden" title="Neu laden">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-secondary small mb-3">
|
||||||
|
Der Raum ordnet die Geräteliste im Automatik-Editor und bestimmt,
|
||||||
|
welche Messwerte bei einer Home-Kachel oben stehen. Er wird von Hand
|
||||||
|
vergeben und bleibt bei einem neuen Suchlauf erhalten – der Suchlauf
|
||||||
|
legt nur an und aktualisiert, er löscht nichts. Abgebaute Geräte
|
||||||
|
bleiben deshalb stehen, bis sie hier entfernt werden.
|
||||||
|
</p>
|
||||||
|
<div id="geraeteListe">
|
||||||
|
<p class="text-secondary small mb-0">Wird geladen …</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?= reiterFlaecheZu() ?><!-- /Geräte -->
|
||||||
|
</div><!-- /tab-content -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!--end::App Content-->
|
<!--end::App Content-->
|
||||||
|
|||||||
+375
-180
@@ -19,10 +19,16 @@
|
|||||||
* 4648 x 1877 mm, Radstand 2765 mm, daraus das Seitenverhältnis 2,48:1 und
|
* 4648 x 1877 mm, Radstand 2765 mm, daraus das Seitenverhältnis 2,48:1 und
|
||||||
* die Lage der Achsen. Kein Foto, sondern ein Schema - die Teile sollen
|
* die Lage der Achsen. Kein Foto, sondern ein Schema - die Teile sollen
|
||||||
* ihren Zustand zeigen, nicht das Auto porträtieren.
|
* ihren Zustand zeigen, nicht das Auto porträtieren.
|
||||||
|
*
|
||||||
|
* Der Ladestand ist Teil dieser Grafik und nicht mehr eine Anzeige daneben:
|
||||||
|
* die Akkuplatte im Fahrzeugboden füllt sich von hinten nach vorn, trägt die
|
||||||
|
* Zielmarke und nennt Prozent und Reichweite in ihrer Mitte. Alles, was sich
|
||||||
|
* nicht zeichnen lässt - Ladeleistung, Restzeit, Kilometerstand, Klima und
|
||||||
|
* die Herkunft des Ladestroms - steht als Kennzahl daneben.
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<!--begin::App Main-->
|
<!--begin::App Main-->
|
||||||
<main class="app-main">
|
<main class="app-main skoda-seite">
|
||||||
<div class="app-content-header">
|
<div class="app-content-header">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row align-items-center">
|
<div class="row align-items-center">
|
||||||
@@ -39,208 +45,397 @@
|
|||||||
<div class="app-content">
|
<div class="app-content">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
|
|
||||||
<!-- ================================================ Zustand ===== -->
|
<!-- ================================================ Übersicht ==== -->
|
||||||
|
<!-- Eine Kachel, zwei Hälften: links das Fahrzeug, rechts die Zahlen
|
||||||
|
dazu. Der Ladestand steht nicht mehr als eigener Ring daneben,
|
||||||
|
sondern dort, wo er im Auto auch sitzt - in der Akkuplatte im
|
||||||
|
Fahrzeugboden. Ladestand, Ladeziel und Reichweite gehören zusammen
|
||||||
|
und stehen deshalb beieinander im Bild; alles, was sich nicht
|
||||||
|
zeichnen lässt, steht als Kennzahl daneben. -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
<!-- Das Fahrzeug als Anzeigeelement. Jedes Teil trägt seinen Zustand
|
|
||||||
selbst: offene Türen und Klappen leuchten bernstein, Licht gelb,
|
|
||||||
der Ladeanschluss pulst beim Laden. Die Zeile darunter benennt
|
|
||||||
dasselbe in Worten und hebt beim Überfahren das zugehörige Teil
|
|
||||||
hervor - Farbe allein soll die Aussage nicht tragen. -->
|
|
||||||
<div class="col-xxl-4 col-lg-5 d-flex align-items-stretch">
|
|
||||||
<div class="card mb-4 p-0 w-100">
|
<div class="card mb-4 p-0 w-100">
|
||||||
<div class="card-header pb-0 pt-1">
|
<div class="card-header pb-0 pt-1">
|
||||||
<h3 class="card-title">Fahrzeug</h3>
|
<h3 class="card-title">Übersicht</h3>
|
||||||
<div class="card-tools">
|
<div class="card-tools d-flex align-items-center gap-2">
|
||||||
<span class="badge text-bg-secondary" id="skodaSchloss">–</span>
|
<span class="badge text-bg-secondary" id="skodaSchloss">–</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body d-flex flex-column align-items-center pt-2">
|
|
||||||
|
|
||||||
<svg id="skodaAuto" class="skoda-auto" viewBox="0 0 224 500"
|
|
||||||
role="img" aria-label="Draufsicht des Fahrzeugs mit dem Zustand von Türen, Fenstern, Klappen und Licht">
|
|
||||||
|
|
||||||
<!-- Räder. Vorderachse bei 860 mm, Hinterachse bei 3625 mm
|
|
||||||
ab Bugspitze - der Radstand von 2765 mm maßstäblich. -->
|
|
||||||
<g class="rad">
|
|
||||||
<rect x="5" y="68" width="21" height="58" rx="9"/>
|
|
||||||
<rect x="198" y="68" width="21" height="58" rx="9"/>
|
|
||||||
<rect x="5" y="362" width="21" height="58" rx="9"/>
|
|
||||||
<rect x="198" y="362" width="21" height="58" rx="9"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Karosserie -->
|
|
||||||
<path class="karosserie" d="M112 6
|
|
||||||
C150 6, 198 16, 204 50 C209 90, 212 160, 212 250
|
|
||||||
C212 340, 209 410, 204 450 C198 484, 150 494, 112 494
|
|
||||||
C74 494, 26 484, 20 450 C15 410, 12 340, 12 250
|
|
||||||
C12 160, 15 90, 20 50 C26 16, 74 6, 112 6 Z"/>
|
|
||||||
|
|
||||||
<!-- Tech-Deck Face: die glänzend schwarze Blende über die
|
|
||||||
volle Bugbreite, die den Enyaq seit der Überarbeitung
|
|
||||||
kennzeichnet. Von oben sieht man ihre Oberkante. -->
|
|
||||||
<path class="techdeck" d="M112 7
|
|
||||||
C150 7, 197 17, 203 49 L194 53 C187 27, 148 18, 112 18
|
|
||||||
C76 18, 37 27, 30 53 L21 49 C27 17, 74 7, 112 7 Z"/>
|
|
||||||
|
|
||||||
<!-- Scheinwerfer, vier Elemente je Seite wie beim Original,
|
|
||||||
dazu die Rückleuchten als durchgehendes Band. -->
|
|
||||||
<g id="teil-lights" class="teil teil-licht" data-teil="lights">
|
|
||||||
<rect x="36" y="30" width="9" height="13" rx="4"/>
|
|
||||||
<rect x="48" y="27" width="9" height="13" rx="4"/>
|
|
||||||
<rect x="60" y="25" width="9" height="13" rx="4"/>
|
|
||||||
<rect x="72" y="24" width="9" height="13" rx="4"/>
|
|
||||||
<rect x="143" y="24" width="9" height="13" rx="4"/>
|
|
||||||
<rect x="155" y="25" width="9" height="13" rx="4"/>
|
|
||||||
<rect x="167" y="27" width="9" height="13" rx="4"/>
|
|
||||||
<rect x="179" y="30" width="9" height="13" rx="4"/>
|
|
||||||
<rect class="rueck" x="40" y="462" width="52" height="13" rx="6"/>
|
|
||||||
<rect class="rueck" x="132" y="462" width="52" height="13" rx="6"/>
|
|
||||||
<title>Beleuchtung</title>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Motorhaube mit angedeuteter Mittelsicke -->
|
|
||||||
<g id="teil-bonnet" class="teil" tabindex="0" data-teil="bonnet">
|
|
||||||
<path d="M31 152 L193 152 L189 62 C169 40, 55 40, 35 62 Z"/>
|
|
||||||
<title>Motorhaube</title>
|
|
||||||
</g>
|
|
||||||
<path class="sicke" d="M112 60 L112 150"/>
|
|
||||||
|
|
||||||
<!-- Windschutzscheibe, Dach, Heckscheibe -->
|
|
||||||
<path class="scheibe" d="M34 156 L190 156 L178 200 L46 200 Z"/>
|
|
||||||
<rect class="dach" x="46" y="200" width="132" height="136" rx="10"/>
|
|
||||||
|
|
||||||
<!-- Panoramaglasdach: beim Enyaq nahezu die ganze Dachfläche -->
|
|
||||||
<g id="teil-sunroof" class="teil" tabindex="0" data-teil="sunroof">
|
|
||||||
<rect x="62" y="206" width="100" height="120" rx="8"/>
|
|
||||||
<title>Panoramadach</title>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Dachreling -->
|
|
||||||
<g class="reling">
|
|
||||||
<rect x="48" y="204" width="9" height="134" rx="4"/>
|
|
||||||
<rect x="167" y="204" width="9" height="134" rx="4"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<path class="scheibe" d="M46 336 L178 336 L190 382 L34 382 Z"/>
|
|
||||||
<rect class="spoiler" x="52" y="330" width="120" height="9" rx="4"/>
|
|
||||||
|
|
||||||
<!-- Heckklappe -->
|
|
||||||
<g id="teil-trunk" class="teil" tabindex="0" data-teil="trunk">
|
|
||||||
<path d="M34 384 L190 384 L186 458 C168 478, 56 478, 38 458 Z"/>
|
|
||||||
<title>Heckklappe</title>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Türen, jeweils mit Seitenfenster darin -->
|
|
||||||
<g id="teil-doors" class="teil" data-teil="doors">
|
|
||||||
<g tabindex="0"><rect x="12" y="202" width="23" height="74" rx="6"/><title>Tür vorn links</title></g>
|
|
||||||
<g tabindex="0"><rect x="12" y="278" width="23" height="66" rx="6"/><title>Tür hinten links</title></g>
|
|
||||||
<g tabindex="0"><rect x="189" y="202" width="23" height="74" rx="6"/><title>Tür vorn rechts</title></g>
|
|
||||||
<g tabindex="0"><rect x="189" y="278" width="23" height="66" rx="6"/><title>Tür hinten rechts</title></g>
|
|
||||||
</g>
|
|
||||||
<g id="teil-windows" class="teil teil-fenster" data-teil="windows">
|
|
||||||
<rect x="17" y="210" width="13" height="58" rx="4"/>
|
|
||||||
<rect x="17" y="286" width="13" height="50" rx="4"/>
|
|
||||||
<rect x="194" y="210" width="13" height="58" rx="4"/>
|
|
||||||
<rect x="194" y="286" width="13" height="50" rx="4"/>
|
|
||||||
<title>Seitenfenster</title>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Außenspiegel -->
|
|
||||||
<g class="spiegel">
|
|
||||||
<rect x="1" y="194" width="16" height="10" rx="5"/>
|
|
||||||
<rect x="207" y="194" width="16" height="10" rx="5"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- ============================================================
|
|
||||||
Steuerung: die beiden anklickbaren Stellen.
|
|
||||||
|
|
||||||
Bedient wird das, worum es geht - der Ladeanschluss für
|
|
||||||
das Laden, der Innenraum für Klima und Lüftung. Beide
|
|
||||||
tragen einen Ring als Hinweis darauf, dass sie mehr sind
|
|
||||||
als Anzeige, und öffnen ein Modal mit den Befehlen.
|
|
||||||
|
|
||||||
Sie stehen bewusst NICHT in der Zustandszeile darunter:
|
|
||||||
die zeigt, was ist. Diese beiden ändern etwas.
|
|
||||||
============================================================ -->
|
|
||||||
|
|
||||||
<!-- Ladeanschluss. Beim Enyaq sitzt die Klappe am hinteren
|
|
||||||
rechten Kotflügel; sie pulst, solange geladen wird. -->
|
|
||||||
<g id="teil-plug" class="teil teil-stecker steuer" tabindex="0"
|
|
||||||
role="button" data-steuer="laden" aria-label="Laden steuern">
|
|
||||||
<circle class="ring" cx="203" cy="408" r="17"/>
|
|
||||||
<circle cx="203" cy="408" r="11"/>
|
|
||||||
<path d="M198 402 l6 5 -4 0 4 6"/>
|
|
||||||
<title>Laden steuern</title>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Innenraum, auf der Windschutzscheibe: Klima und Lüftung. -->
|
|
||||||
<g id="steuer-klima" class="steuer" tabindex="0"
|
|
||||||
role="button" data-steuer="klima" aria-label="Klima und Lüftung steuern">
|
|
||||||
<circle class="ring" cx="112" cy="178" r="19"/>
|
|
||||||
<circle class="flaeche" cx="112" cy="178" r="13"/>
|
|
||||||
<path class="glyphe" d="M112 170 v10 M108 174 h8 M105 185 q7 -4 14 0"/>
|
|
||||||
<title>Klima und Lüftung steuern</title>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="skoda-teile" id="skodaTeile"></div>
|
|
||||||
<p class="skoda-hinweis">
|
|
||||||
Ladeanschluss und Innenraum antippen, um zu steuern
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Batterie, Reichweite, Ladung -->
|
|
||||||
<div class="col-xxl-8 col-lg-7 d-flex align-items-stretch">
|
|
||||||
<div class="card mb-4 p-0 w-100">
|
|
||||||
<div class="card-header pb-0 pt-1">
|
|
||||||
<h3 class="card-title">Batterie und Ladung</h3>
|
|
||||||
<div class="card-tools">
|
|
||||||
<span class="badge text-bg-secondary" id="skodaLadeZustand">–</span>
|
<span class="badge text-bg-secondary" id="skodaLadeZustand">–</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row align-items-center g-3">
|
<div class="skoda-fahrzeug">
|
||||||
|
|
||||||
<!-- Ladestandsring. Der Strich auf dem Ring ist das
|
<!-- Das Fahrzeug als Anzeigeelement. Jedes Teil trägt seinen
|
||||||
eingestellte Ladeziel - eine Ladung, die dort endet, ist
|
Zustand selbst: offene Türen und Klappen leuchten
|
||||||
kein Fehler, sondern Absicht. -->
|
bernstein, Licht gelb, der Ladeanschluss pulst beim Laden.
|
||||||
<div class="col-md-5 text-center">
|
Die Zustandszeile rechts benennt dasselbe in Worten und
|
||||||
<svg class="skoda-ring" viewBox="0 0 200 200" role="img" aria-labelledby="skodaSocText">
|
hebt beim Überfahren das zugehörige Teil hervor - Farbe
|
||||||
<circle class="ring-bahn" cx="100" cy="100" r="82"/>
|
allein soll die Aussage nicht tragen. -->
|
||||||
<circle class="ring-wert" id="skodaRing" cx="100" cy="100" r="82"/>
|
<div class="skoda-bild">
|
||||||
<line class="ring-ziel" id="skodaZiel" x1="100" y1="8" x2="100" y2="26"/>
|
<svg id="skodaAuto" class="skoda-auto" viewBox="-16 -8 256 516"
|
||||||
<text id="skodaSocText" class="ring-zahl" x="100" y="98">–</text>
|
role="img" aria-label="Draufsicht des Fahrzeugs mit Ladestand der Batterie und dem Zustand von Türen, Fenstern, Klappen und Licht">
|
||||||
<text class="ring-einheit" x="100" y="126">Prozent</text>
|
|
||||||
|
<defs>
|
||||||
|
<!-- Der Verlauf der Füllung ist am Fahrzeug verankert,
|
||||||
|
nicht am Füllrechteck: sonst wanderte er mit dem
|
||||||
|
Ladestand mit und die Farbe unten änderte sich bei
|
||||||
|
jedem Prozentpunkt. Die Farbe selbst kommt aus
|
||||||
|
--akku-farbe, gesetzt von der Zustandsklasse am
|
||||||
|
SVG-Element; das Attribut hier ist der Rückfall. -->
|
||||||
|
<linearGradient id="akkuVerlauf" gradientUnits="userSpaceOnUse"
|
||||||
|
x1="0" y1="378" x2="0" y2="108">
|
||||||
|
<stop offset="0" stop-color="#7aa7c7" stop-opacity=".75"/>
|
||||||
|
<stop offset="1" stop-color="#7aa7c7" stop-opacity=".3"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Innenkontur der Platte: schneidet Füllung, Strömung
|
||||||
|
und Pegelkante auf die abgerundete Form zu. -->
|
||||||
|
<clipPath id="akkuInnen">
|
||||||
|
<rect x="50" y="108" width="124" height="270" rx="7"/>
|
||||||
|
</clipPath>
|
||||||
|
|
||||||
|
<!-- Auf Höhe des Ladestands beschnitten, damit die
|
||||||
|
Strömung nur im gefüllten Teil läuft. Das Rechteck
|
||||||
|
darin setzt zeichneAkku() mit. -->
|
||||||
|
<clipPath id="akkuPegel">
|
||||||
|
<rect id="akkuPegelFeld" x="50" y="378" width="124" height="0"/>
|
||||||
|
</clipPath>
|
||||||
|
|
||||||
|
<!-- Ein Flügel des Lüfterrads, um die eigene Nabe im
|
||||||
|
Ursprung gezeichnet. Das Rad setzt ihn dreimal um je
|
||||||
|
120 Grad versetzt ein. -->
|
||||||
|
<path id="klimaFluegel"
|
||||||
|
d="M0 0 C 1.5 -4, 5 -7, 7.5 -5 C 9.6 -3.2, 5.5 -0.8, 0 0 Z"/>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Räder. Vorderachse bei 860 mm, Hinterachse bei 3625 mm
|
||||||
|
ab Bugspitze - der Radstand von 2765 mm maßstäblich. -->
|
||||||
|
<g class="rad">
|
||||||
|
<rect x="5" y="68" width="21" height="58" rx="9"/>
|
||||||
|
<rect x="198" y="68" width="21" height="58" rx="9"/>
|
||||||
|
<rect x="5" y="362" width="21" height="58" rx="9"/>
|
||||||
|
<rect x="198" y="362" width="21" height="58" rx="9"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Karosserie -->
|
||||||
|
<path class="karosserie" d="M112 6
|
||||||
|
C150 6, 198 16, 204 50 C209 90, 212 160, 212 250
|
||||||
|
C212 340, 209 410, 204 450 C198 484, 150 494, 112 494
|
||||||
|
C74 494, 26 484, 20 450 C15 410, 12 340, 12 250
|
||||||
|
C12 160, 15 90, 20 50 C26 16, 74 6, 112 6 Z"/>
|
||||||
|
|
||||||
|
<!-- Tech-Deck Face: die glänzend schwarze Blende über die
|
||||||
|
volle Bugbreite, die den Enyaq seit der Überarbeitung
|
||||||
|
kennzeichnet. Von oben sieht man ihre Oberkante, also
|
||||||
|
ein schmales Band, das zu den Kotflügeln hin ausläuft.
|
||||||
|
|
||||||
|
Schmal und nur halb deckend: die Blende steht am Auto
|
||||||
|
fast senkrecht, von oben ist sie deshalb ein Streifen
|
||||||
|
und keine Fläche. Als breites, fast schwarzes Band war
|
||||||
|
sie das dunkelste im Bild und las sich wie ein Loch.
|
||||||
|
Die Enden laufen spitz zusammen statt gerade abzu-
|
||||||
|
schneiden - ein gerader Schnitt ergab dort zwei Stufen,
|
||||||
|
die es am Auto nicht gibt. -->
|
||||||
|
<path class="techdeck" d="M22 46
|
||||||
|
C 30 17, 74 7, 112 7 C 150 7, 194 17, 202 46
|
||||||
|
C 196 24, 156 17, 112 17 C 68 17, 28 24, 22 46 Z"/>
|
||||||
|
|
||||||
|
<!-- Scheinwerfer, vier Elemente je Seite wie beim Original,
|
||||||
|
dazu die Rückleuchten als durchgehendes Band. -->
|
||||||
|
<g id="teil-lights" class="teil teil-licht" data-teil="lights">
|
||||||
|
<rect x="36" y="30" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="48" y="27" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="60" y="25" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="72" y="24" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="143" y="24" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="155" y="25" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="167" y="27" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="179" y="30" width="9" height="13" rx="4"/>
|
||||||
|
<rect class="rueck" x="40" y="462" width="52" height="13" rx="6"/>
|
||||||
|
<rect class="rueck" x="132" y="462" width="52" height="13" rx="6"/>
|
||||||
|
<title>Beleuchtung</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Motorhaube mit angedeuteter Mittelsicke -->
|
||||||
|
<g id="teil-bonnet" class="teil" tabindex="0" data-teil="bonnet">
|
||||||
|
<path d="M31 152 L193 152 L189 62 C169 40, 55 40, 35 62 Z"/>
|
||||||
|
<title>Motorhaube</title>
|
||||||
|
</g>
|
||||||
|
<path class="sicke" d="M112 60 L112 150"/>
|
||||||
|
|
||||||
|
<!-- Windschutzscheibe, Dach, Heckscheibe -->
|
||||||
|
<path class="scheibe" d="M34 156 L190 156 L178 200 L46 200 Z"/>
|
||||||
|
<rect class="dach" x="46" y="200" width="132" height="136" rx="10"/>
|
||||||
|
|
||||||
|
<!-- Kein Panoramadach: dieses Fahrzeug hat ein geschlossenes
|
||||||
|
Blechdach. Die Zustandszeile kennt es deshalb auch nicht
|
||||||
|
(TEILE in js/solar/skodaMQTT.js). -->
|
||||||
|
|
||||||
|
<!-- Dachreling. Sie liegt AUF dem Dach: nach innen gerückt,
|
||||||
|
nicht auf dessen Seitenkanten, und sie bleibt zwischen
|
||||||
|
den Scheiben - vorn hinter der Windschutzscheibe, hinten
|
||||||
|
am Spoiler. Läge sie bündig auf den Kanten, ergäbe sie
|
||||||
|
mit Platte und Spoiler ein geschlossenes Rechteck, und
|
||||||
|
das sieht aus wie ein Fensterrahmen. Die runden Enden
|
||||||
|
kommen von der Strichstärke, nicht aus der Geometrie. -->
|
||||||
|
<g class="reling">
|
||||||
|
<line x1="54" y1="206" x2="54" y2="330"/>
|
||||||
|
<line x1="170" y1="206" x2="170" y2="330"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<path class="scheibe" d="M46 336 L178 336 L190 382 L34 382 Z"/>
|
||||||
|
<!-- Heckspoiler: ein Blatt über der Dachkante, in der Mitte
|
||||||
|
breit, zu den Enden auslaufend - so sitzt er auf der
|
||||||
|
Kante statt sie nachzuzeichnen. -->
|
||||||
|
<path class="spoiler" d="M44 336 C 74 327, 150 327, 180 336
|
||||||
|
C 150 344, 74 344, 44 336 Z"/>
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
Antriebsbatterie - der Ladestand selbst.
|
||||||
|
|
||||||
|
Beim MEB liegt der Akku als flache Platte zwischen den
|
||||||
|
Achsen; genau dort sitzt sie hier auch, von 108 bis 378
|
||||||
|
über den Radstand. Gefüllt wird von hinten nach vorn,
|
||||||
|
also im Bild von unten nach oben - dieselbe Richtung,
|
||||||
|
in der jeder einen Füllstand liest.
|
||||||
|
|
||||||
|
Die Reihenfolge ist Absicht und gilt ohne Ausnahme:
|
||||||
|
die Platte liegt ÜBER allem, was nur Blech und Glas ist
|
||||||
|
- Karosserie, Haube, beide Scheiben, Dach, Reling,
|
||||||
|
Spoiler -, und UNTER allem, was Zustand zeigt oder
|
||||||
|
bedient wird: Türen, Fenster, Klappen, Licht und die
|
||||||
|
beiden Knöpfe. Eine offene Klappe muss sichtbar
|
||||||
|
bleiben, auch wenn die Batterie darunter liegt - der
|
||||||
|
Zustand hat Vorrang vor der Anzeige.
|
||||||
|
|
||||||
|
Deshalb steht dieser Block hier und nicht weiter oben:
|
||||||
|
stand er vor der Heckscheibe, lag die Platte über der
|
||||||
|
Windschutzscheibe, aber unter der Heckscheibe, und die
|
||||||
|
Relingstäbe schwammen auf ihr.
|
||||||
|
|
||||||
|
Alles Veränderliche - Füllhöhe, Zielmarke, Zahlen und
|
||||||
|
Farbe - setzt zeichneAkku().
|
||||||
|
======================================================== -->
|
||||||
|
<g id="akku" class="akku">
|
||||||
|
<rect class="huelle" x="46" y="104" width="132" height="278" rx="10"/>
|
||||||
|
|
||||||
|
<g clip-path="url(#akkuInnen)">
|
||||||
|
<rect class="fuellung" id="akkuFuellung" x="50" y="378" width="124" height="0"/>
|
||||||
|
|
||||||
|
<!-- Ladestrom: Streifen, die im gefüllten Teil nach
|
||||||
|
vorn wandern. Nur beim Laden sichtbar. -->
|
||||||
|
<g class="stroemung" clip-path="url(#akkuPegel)">
|
||||||
|
<g class="bahn">
|
||||||
|
<rect x="50" y="430" width="124" height="3" rx="1.5"/>
|
||||||
|
<rect x="50" y="376" width="124" height="3" rx="1.5"/>
|
||||||
|
<rect x="50" y="322" width="124" height="3" rx="1.5"/>
|
||||||
|
<rect x="50" y="268" width="124" height="3" rx="1.5"/>
|
||||||
|
<rect x="50" y="214" width="124" height="3" rx="1.5"/>
|
||||||
|
<rect x="50" y="160" width="124" height="3" rx="1.5"/>
|
||||||
|
<rect x="50" y="106" width="124" height="3" rx="1.5"/>
|
||||||
|
<rect x="50" y="52" width="124" height="3" rx="1.5"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Pegelkante: die helle Linie oben auf der Füllung.
|
||||||
|
Sie macht den Stand auch dann ablesbar, wenn die
|
||||||
|
Fläche darunter nur schwach getönt ist. -->
|
||||||
|
<rect class="kante" id="akkuKante" x="50" y="377" width="124" height="2.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Modulfugen. Sie liegen auf 20, 40, 60 und 80 Prozent
|
||||||
|
und dienen damit zugleich als Skala. -->
|
||||||
|
<g class="fugen">
|
||||||
|
<line x1="50" y1="324" x2="174" y2="324"/>
|
||||||
|
<line x1="50" y1="270" x2="174" y2="270"/>
|
||||||
|
<line x1="50" y1="216" x2="174" y2="216"/>
|
||||||
|
<line x1="50" y1="162" x2="174" y2="162"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Ladeziel. Eine Ladung, die dort endet, ist kein
|
||||||
|
Fehler, sondern Absicht - deshalb steht die Marke im
|
||||||
|
Bild und nicht nur als Zahl daneben. -->
|
||||||
|
<g class="zielmarke" id="akkuZielMarke">
|
||||||
|
<line id="akkuZiel" x1="44" y1="108" x2="180" y2="108"/>
|
||||||
|
<text id="akkuZielText" x="174" y="102">Ziel –</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Ladestand und Reichweite in der Platte. Der dunkle
|
||||||
|
Umriss der Zahlen (paint-order) hält sie lesbar,
|
||||||
|
gleich ob Füllung oder leere Zelle dahinter liegt.
|
||||||
|
|
||||||
|
Die Höhe ist gerechnet, nicht geschätzt: die Platte
|
||||||
|
reicht von 108 bis 378, ist also 270 hoch; der
|
||||||
|
Goldene Schnitt von oben liegt bei 108 + 270 × 0,618
|
||||||
|
= 274,9. Dort sitzt die optische Mitte der beiden
|
||||||
|
Zeilen (Oberkante der Ziffern 246, Grundlinie der
|
||||||
|
Reichweite 303). Weiter oben klebten sie am
|
||||||
|
Klimaknopf, dessen Ring bei 205 endet - jetzt liegen
|
||||||
|
41 Einheiten Luft dazwischen. -->
|
||||||
|
<text class="wert" x="112" y="275">
|
||||||
|
<tspan id="akkuText">–</tspan><tspan class="einheit" dx="3">%</tspan>
|
||||||
|
</text>
|
||||||
|
<text class="neben" id="akkuNeben" x="112" y="303">–</text>
|
||||||
|
|
||||||
|
<title id="akkuTitel">Antriebsbatterie</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Heckklappe -->
|
||||||
|
<g id="teil-trunk" class="teil" tabindex="0" data-teil="trunk">
|
||||||
|
<path d="M34 384 L190 384 L186 458 C168 478, 56 478, 38 458 Z"/>
|
||||||
|
<title>Heckklappe</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Türen, jeweils mit Seitenfenster darin -->
|
||||||
|
<g id="teil-doors" class="teil" data-teil="doors">
|
||||||
|
<g tabindex="0"><rect x="12" y="202" width="23" height="74" rx="6"/><title>Tür vorn links</title></g>
|
||||||
|
<g tabindex="0"><rect x="12" y="278" width="23" height="66" rx="6"/><title>Tür hinten links</title></g>
|
||||||
|
<g tabindex="0"><rect x="189" y="202" width="23" height="74" rx="6"/><title>Tür vorn rechts</title></g>
|
||||||
|
<g tabindex="0"><rect x="189" y="278" width="23" height="66" rx="6"/><title>Tür hinten rechts</title></g>
|
||||||
|
</g>
|
||||||
|
<g id="teil-windows" class="teil teil-fenster" data-teil="windows">
|
||||||
|
<rect x="17" y="210" width="13" height="58" rx="4"/>
|
||||||
|
<rect x="17" y="286" width="13" height="50" rx="4"/>
|
||||||
|
<rect x="194" y="210" width="13" height="58" rx="4"/>
|
||||||
|
<rect x="194" y="286" width="13" height="50" rx="4"/>
|
||||||
|
<title>Seitenfenster</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Außenspiegel -->
|
||||||
|
<g class="spiegel">
|
||||||
|
<rect x="1" y="194" width="16" height="10" rx="5"/>
|
||||||
|
<rect x="207" y="194" width="16" height="10" rx="5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
Steuerung: die beiden anklickbaren Stellen.
|
||||||
|
|
||||||
|
Bedient wird das, worum es geht - der Ladeanschluss für
|
||||||
|
das Laden, der Innenraum für Klima und Lüftung. Beide
|
||||||
|
tragen einen Ring als Hinweis darauf, dass sie mehr
|
||||||
|
sind als Anzeige, und öffnen ein Modal mit den
|
||||||
|
Befehlen.
|
||||||
|
|
||||||
|
Sie stehen bewusst NICHT in der Zustandszeile: die
|
||||||
|
zeigt, was ist. Diese beiden ändern etwas.
|
||||||
|
======================================================== -->
|
||||||
|
|
||||||
|
<!-- Der Weg des Ladestroms vom Anschluss in die Platte.
|
||||||
|
Läuft nur, solange geladen wird. -->
|
||||||
|
<path class="ladefluss" d="M199 399 C 192 393, 183 386, 176 376"/>
|
||||||
|
|
||||||
|
<!-- Ladeanschluss. Beim Enyaq sitzt die Klappe am hinteren
|
||||||
|
rechten Kotflügel.
|
||||||
|
|
||||||
|
Gleiche Bauweise wie der Klimaknopf: gestrichelter Ring,
|
||||||
|
deckende Scheibe, Zeichen darauf. Beides sind Knöpfe, also
|
||||||
|
sehen beide gleich aus - was sie unterscheidet, ist das
|
||||||
|
Zeichen und beim Laden die Farbe. Die deckende Scheibe ist
|
||||||
|
auch hier nötig, weil darunter die Akkuplatte durchläuft. -->
|
||||||
|
<g id="teil-plug" class="teil-stecker steuer" tabindex="0"
|
||||||
|
role="button" data-steuer="laden" aria-label="Laden steuern">
|
||||||
|
<circle class="ring" cx="203" cy="408" r="19"/>
|
||||||
|
<circle class="flaeche" cx="203" cy="408" r="13"/>
|
||||||
|
<path class="glyphe" d="M199 401 L207 407 L202 407 L207 415"/>
|
||||||
|
<title>Laden steuern</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Innenraum, auf der Windschutzscheibe: Klima und Lüftung. -->
|
||||||
|
<g id="steuer-klima" class="steuer" tabindex="0"
|
||||||
|
role="button" data-steuer="klima" aria-label="Klima und Lüftung steuern">
|
||||||
|
<circle class="ring" cx="112" cy="186" r="19"/>
|
||||||
|
<circle class="flaeche" cx="112" cy="186" r="13"/>
|
||||||
|
<!-- Lüfterrad. Es dreht sich, solange die Klimatisierung
|
||||||
|
läuft; die Drehung sitzt in der inneren Gruppe, damit
|
||||||
|
sie die Verschiebung nach aussen nicht überschreibt. -->
|
||||||
|
<g class="luefter" transform="translate(112 186)">
|
||||||
|
<g class="blaetter">
|
||||||
|
<use href="#klimaFluegel"/>
|
||||||
|
<use href="#klimaFluegel" transform="rotate(120)"/>
|
||||||
|
<use href="#klimaFluegel" transform="rotate(240)"/>
|
||||||
|
</g>
|
||||||
|
<circle class="nabe" r="1.9"/>
|
||||||
|
</g>
|
||||||
|
<title>Klima und Lüftung steuern</title>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
<p class="skoda-hinweis">
|
||||||
|
Ladeanschluss und Innenraum antippen, um zu steuern
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-7">
|
<!-- Alles, was sich nicht ins Bild zeichnen lässt. -->
|
||||||
<div class="skoda-werte">
|
<div class="skoda-daten">
|
||||||
<div class="skoda-wert"><span class="k">Reichweite</span><span class="v" id="skodaReichweite">–</span></div>
|
|
||||||
<div class="skoda-wert"><span class="k">Ladeleistung</span><span class="v" id="skodaLeistung">–</span></div>
|
<div class="skoda-kennzahlen">
|
||||||
<div class="skoda-wert"><span class="k">Restzeit</span><span class="v" id="skodaRestzeit">–</span></div>
|
<div class="kennzahl" id="skodaKachelLeistung">
|
||||||
<div class="skoda-wert"><span class="k">Ladeziel</span><span class="v" id="skodaZielText">–</span></div>
|
<span class="zeichen"><i class="bi bi-lightning-charge-fill" aria-hidden="true"></i></span>
|
||||||
<div class="skoda-wert"><span class="k">Kilometerstand</span><span class="v" id="skodaKm">–</span></div>
|
<span class="text">
|
||||||
<div class="skoda-wert"><span class="k">Klima</span><span class="v" id="skodaKlima">–</span></div>
|
<span class="k">Ladeleistung</span>
|
||||||
|
<span class="v" id="skodaLeistung">–</span>
|
||||||
|
<span class="neben" id="skodaLadeart"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="kennzahl" id="skodaKachelRestzeit">
|
||||||
|
<span class="zeichen"><i class="bi bi-hourglass-split" aria-hidden="true"></i></span>
|
||||||
|
<span class="text">
|
||||||
|
<span class="k">Restzeit</span>
|
||||||
|
<span class="v" id="skodaRestzeit">–</span>
|
||||||
|
<span class="neben" id="skodaFertig"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="kennzahl">
|
||||||
|
<span class="zeichen"><i class="bi bi-speedometer2" aria-hidden="true"></i></span>
|
||||||
|
<span class="text">
|
||||||
|
<span class="k">Kilometer­stand</span>
|
||||||
|
<span class="v" id="skodaKm">–</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="kennzahl" id="skodaKachelKlima">
|
||||||
|
<span class="zeichen"><i class="bi bi-fan" id="skodaKlimaZeichen" aria-hidden="true"></i></span>
|
||||||
|
<span class="text">
|
||||||
|
<span class="k">Klima</span>
|
||||||
|
<span class="v" id="skodaKlima">–</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Woher der Strom gerade kommt: der Balken teilt die
|
<!-- Woher der Strom gerade kommt: der Balken teilt die
|
||||||
Ladeleistung in den Anteil, den die PV deckt, und den
|
Ladeleistung in den Anteil, den die PV deckt, und den
|
||||||
Rest aus dem Netz. -->
|
Rest aus dem Netz. Steht nur da, solange geladen wird -
|
||||||
<div class="skoda-quelle mt-3" id="skodaQuelleBox">
|
ohne Ladung hat der Balken nichts zu teilen. -->
|
||||||
<div class="d-flex justify-content-between small text-secondary mb-1">
|
<div class="skoda-quelle" id="skodaQuelleBox" hidden>
|
||||||
<span>Ladestrom</span><span id="skodaQuelleText">–</span>
|
<div class="kopf">
|
||||||
|
<span class="skoda-marke">Ladestrom</span>
|
||||||
|
<span class="summe" id="skodaQuelleText">–</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="skoda-balken">
|
<div class="skoda-balken">
|
||||||
<div class="teil-pv" id="skodaAnteilPV"></div>
|
<div class="teil-pv" id="skodaAnteilPV"></div>
|
||||||
<div class="teil-netz" id="skodaAnteilNetz"></div>
|
<div class="teil-netz" id="skodaAnteilNetz"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="legende">
|
||||||
|
<span><i class="pkt pv"></i>Sonne <b id="skodaQuellePV">–</b></span>
|
||||||
|
<span><i class="pkt netz"></i>Netz <b id="skodaQuelleNetz">–</b></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Was das Fahrzeug in Woche, Monat und Jahr gefahren ist.
|
||||||
|
Gerechnet aus dem Kilometerstand, nicht aus den erkannten
|
||||||
|
Fahrten - siehe ajax/skoda.php, ?was=strecke. -->
|
||||||
|
<div class="skoda-fahrleistung">
|
||||||
|
<span class="skoda-marke">Fahrleistung</span>
|
||||||
|
<div class="reihe" id="skodaFahrleistung"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zustandszeile: benennt in Worten, was die Farben im Bild
|
||||||
|
zeigen, und hebt beim Überfahren das Teil hervor. -->
|
||||||
|
<div class="skoda-zustand">
|
||||||
|
<span class="skoda-marke">Zustand</span>
|
||||||
|
<div class="skoda-teile" id="skodaTeile"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+16
-17
@@ -64,23 +64,6 @@
|
|||||||
<!-- /.card -->
|
<!-- /.card -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
|
|
||||||
<div class="card p-0 mb-4" style="width:100%">
|
|
||||||
<div class="card-header pb-0 pt-1">
|
|
||||||
<h3 class="card-title">Statistik dieses Jahr</h3>
|
|
||||||
<div class="card-tools">
|
|
||||||
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
|
||||||
<i class="bi bi-x-lg"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="Stats-Year">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- /.card -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
|
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
|
||||||
<div class="card p-0 mb-4" style="width:100%">
|
<div class="card p-0 mb-4" style="width:100%">
|
||||||
<div class="card-header pb-0 pt-1">
|
<div class="card-header pb-0 pt-1">
|
||||||
@@ -118,6 +101,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
|
||||||
|
<div class="card p-0 mb-4" style="width:100%">
|
||||||
|
<div class="card-header pb-0 pt-1">
|
||||||
|
<h3 class="card-title">Statistik dieses Jahr</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Gefuellt von baueJahresstatistik() aus js/solar/jahresstatistik.js -
|
||||||
|
dieselbe Tabelle wie auf der Historienseite, nur einspaltig. -->
|
||||||
|
<div class="card-body statistik" id="Stats-Year"></div>
|
||||||
|
</div>
|
||||||
|
<!-- /.card -->
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Consumption Graph-->
|
<!-- Consumption Graph-->
|
||||||
<div class="col-lg-12">
|
<div class="col-lg-12">
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Anzeigewerte der Home-Kacheln.
|
||||||
|
--
|
||||||
|
-- Eine Zeile je Kachel, und nur fuer die Kacheln, an denen etwas geaendert
|
||||||
|
-- wurde. Steht keine Zeile da, gilt die Vorgabe aus restricted/rooms.php: bei
|
||||||
|
-- Thermostatraeumen die drei Werte Soll, Ist und Feuchte, sonst die dort
|
||||||
|
-- eingetragene Liste. Die Tabelle ist damit reine Ueberschreibung - sie muss
|
||||||
|
-- weder vollstaendig sein noch mitwachsen, wenn ein Raum hinzukommt.
|
||||||
|
--
|
||||||
|
-- Die Werte stehen als JSON in einer Spalte und nicht in Einzelspalten. Eine
|
||||||
|
-- Wertdefinition ist heterogen: mal ein Topic mit Pfad in einer
|
||||||
|
-- JSON-Nachricht, mal eine Liste von Wechselrichternamen, dazu Einheit,
|
||||||
|
-- Nachkommastellen, Symbol und ob die Zahl gross steht. In Spalten gegossen
|
||||||
|
-- waeren das ein Dutzend Felder, von denen je nach Quelle die Haelfte leer
|
||||||
|
-- bleibt - und jede neue Anzeigeoption waere eine Schemaaenderung. So ist es
|
||||||
|
-- genau die Form, die restricted/rooms.php ohnehin verarbeitet.
|
||||||
|
--
|
||||||
|
-- Geprueft wird beim Speichern in restricted/kacheln.php, nicht hier: die
|
||||||
|
-- Datenbank kann JSON zwar halten, aber nicht wissen, welche Symbole es gibt.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `kachelWerte` (
|
||||||
|
`floor` varchar(4) NOT NULL COMMENT 'Etage, wie in rooms.php',
|
||||||
|
`room` varchar(40) NOT NULL COMMENT 'Kuerzel des Raums (rooms.id)',
|
||||||
|
`werte` text NOT NULL COMMENT 'JSON-Liste der Wertdefinitionen, hoechstens drei',
|
||||||
|
`aktualisiert` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
|
PRIMARY KEY (`floor`, `room`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
Reference in New Issue
Block a user