Compare commits
7
Commits
22ecc1b50f
...
060336c891
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
060336c891 | ||
|
|
cd398bc800 | ||
|
|
eb1aa8c760 | ||
|
|
07bca5104e | ||
|
|
1b58579c22 | ||
|
|
cca452806f | ||
|
|
0cb0d4c3ae |
@@ -44,6 +44,11 @@ logs/
|
||||
.DS_Store
|
||||
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
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
@@ -9,11 +9,9 @@
|
||||
* GET ?action=editor&id=N Editor fuer eine vorhandene Automatik
|
||||
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik
|
||||
* 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=delete {"id": N}
|
||||
* POST ?action=toggle {"id": N} - pausieren / fortsetzen
|
||||
* POST ?action=saveRooms {"<actor_id>": "OG/Bad", ...}
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
@@ -51,8 +49,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
jsonAntwort(["ok" => true]);
|
||||
case "toggle":
|
||||
jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]);
|
||||
case "saveRooms":
|
||||
jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]);
|
||||
default:
|
||||
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||
}
|
||||
@@ -130,73 +126,6 @@ if ($action === "list") {
|
||||
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 --------------------------------------------------------------
|
||||
$id = intval($_GET["id"] ?? 0);
|
||||
$auto = $id > 0 ? loadAutomation($id) : null;
|
||||
|
||||
+332
-174
@@ -1,73 +1,177 @@
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
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.
|
||||
/*
|
||||
* Jahresstatistik der Anlage - alle Jahre, alle Zeitraeume, als JSON.
|
||||
*
|
||||
* Gerechnet wird nicht mehr ueber die Rohdaten aus EnergyFlow, sondern ueber
|
||||
* die Tageswerte. Zwei Gruende:
|
||||
* Frueher lieferte diese Datei fertiges HTML: je Aufruf ein Jahr, 18 Kacheln,
|
||||
* 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
|
||||
* Stundenarchiv verschoben (solarlog-rollup.sh). Eine Kachel, die auf
|
||||
* EnergyFlow rechnet, zeigt fuer alte Jahre danach zu wenig an.
|
||||
* Stundenarchiv verschoben (solarlog-rollup.sh). Wer auf EnergyFlow
|
||||
* rechnet, zeigt fuer alte Jahre zu wenig an.
|
||||
* 2. Ein Jahr sind 365 Zeilen statt rund 105.000.
|
||||
*
|
||||
* Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt
|
||||
* LEAD() beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie
|
||||
* zwei Tarife haben - die Verdichtung auf Tage verliert hier also nichts.
|
||||
* Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt LEAD()
|
||||
* beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie zwei
|
||||
* Tarife haben - die Verdichtung auf Tage verliert hier also nichts.
|
||||
*
|
||||
* Was in stats_daily vorberechnet sein MUSS, weil es eine Bedingung je
|
||||
* Messwert auswertet und sich aus Tagessummen nicht rekonstruieren laesst:
|
||||
*
|
||||
* eigenverbrauch_kwh pv - Einspeisung - Batterieladung
|
||||
* - Heizstab + Batterieentladung. Dazu addiert
|
||||
* wird pv_fehlbetrag_kwh: bei Ausfall einzelner
|
||||
* eigenverbrauch_kwh pv - Einspeisung - Batterieladung - Heizstab
|
||||
* + Batterieentladung. Dazu addiert wird
|
||||
* pv_fehlbetrag_kwh: bei Ausfall einzelner
|
||||
* Wechselrichter fehlt deren Beitrag in pv, alle
|
||||
* Senken sind dagegen gemessen - die Differenz
|
||||
* faellt sonst zu klein oder negativ aus.
|
||||
* heizstab_solar_kwh Heizstab, anteilig nach autonomy
|
||||
* auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom
|
||||
* Geraet in feiner Aufloesung gerechneten
|
||||
* Nicht-Netz-Anteil der Zufuhr
|
||||
* auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom Geraet
|
||||
* in feiner Aufloesung gerechneten Nicht-Netz-
|
||||
* Anteil der Zufuhr
|
||||
*
|
||||
* Einheiten sind in stats_daily bereits bereinigt: PL*_EV und PL*_EVog
|
||||
* liefern Kilowatt, alle uebrigen Leistungsspalten Watt. In der alten
|
||||
* Abfrage stand das als /12 gegen /12000 nebeneinander.
|
||||
* Die Abfragen liefern nur SUMMEN. Jede Verhaeltniszahl - Autarkie, Quoten,
|
||||
* effektiver Preis - rechnet kennzahlenAus() daraus, und zwar auf jeder Ebene
|
||||
* 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
|
||||
* vorher 0/0 heraus.
|
||||
* Der Grundpreis faellt unabhaengig vom Verbrauch an und steckt deshalb in
|
||||
* keiner Summe, sondern kommt aus grundpreisImZeitraum() dazu.
|
||||
*
|
||||
* "Ersparnis Batterie" rechnet den Nutzen netto: was die entladene Energie
|
||||
* aus dem Netz gekostet haette, abzueglich der Verguetung, die fuer den zum
|
||||
* Laden verwendeten Strom entgangen ist. Brutto waere die Kachel zu
|
||||
* freundlich - der Speicher ist nicht umsonst zu fuellen.
|
||||
* Achtung beim Lesen der Ertragsgruppe: Eigenverbrauch, Einspeisung und
|
||||
* Heizung TEILEN den Ertrag auf, sie ergeben ihn zusammen. Der Beitrag der
|
||||
* Batterie und die Solarladung des Autos stecken dagegen IM EIGENVERBRAUCH -
|
||||
* 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
|
||||
* zum Strompreis. "Ersparnis Batterie" und "Ersparnis Solarladung" sind
|
||||
* Teilmengen davon, nach Herkunft bzw. Verwendung herausgegriffen. Sie
|
||||
* duerfen nicht addiert werden.
|
||||
*
|
||||
* Der Grundpreis fehlt weiterhin bewusst - er haengt an den Tagen des
|
||||
* Jahres, nicht an einem Messwert, und kommt aus grundpreisImJahr() dazu.
|
||||
* Beim Beitrag der Batterie ist "darin" der Groesse nach zu lesen: gerechnet
|
||||
* ist er netto, also abzueglich der Verguetung, die fuer den zum Laden
|
||||
* verwendeten Strom entgangen ist. Brutto waere die Zeile zu freundlich - der
|
||||
* Speicher ist nicht umsonst zu fuellen.
|
||||
*/
|
||||
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
|
||||
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),
|
||||
sprit AS (SELECT active_date, cost, lPer100km, kwhPer100km,
|
||||
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM fuelCosts)
|
||||
SELECT
|
||||
SUM(d.netz_bezug_kwh) AS 'Stromverbrauch',
|
||||
SUM(d.netz_bezug_kwh * preis.cost) AS 'Stromkosten',
|
||||
SUM((d.eigenverbrauch_kwh + IFNULL(d.pv_fehlbetrag_kwh,0)) * preis.cost) AS 'Verbrauchsersparnis',
|
||||
SUM(d.netz_einsp_kwh * preis.gain) AS 'Einspeisevergütung',
|
||||
SUM(d.heizstab_solar_kwh * gas.cost / NULLIF(gas.kwhPerLitre,0)) AS 'Ersparnis Heizung',
|
||||
SUM(d.batt_entladen_kwh * preis.cost - d.batt_laden_kwh * preis.gain) AS 'Ersparnis Batterie',
|
||||
SUM(d.autarkie_avg * d.stunden_erfasst) / NULLIF(SUM(d.stunden_erfasst),0) AS 'Ø Autarkie',
|
||||
SUM(d.auto_eg_kwh) AS 'Autoladung ges. EG',
|
||||
SUM(d.auto_eg_solar_kwh) / NULLIF(SUM(d.auto_eg_kwh),0) * 100 AS 'Autoladung Solar EG',
|
||||
SUM(d.auto_eg_solar_kwh * preis.cost) AS 'Ersparnis Solarladung EG',
|
||||
SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart EG',
|
||||
SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin EG',
|
||||
SUM(d.auto_og_kwh) AS 'Autoladung ges. OG',
|
||||
SUM(d.auto_og_solar_kwh) / NULLIF(SUM(d.auto_og_kwh),0) * 100 AS 'Autoladung Solar OG',
|
||||
SUM(d.auto_og_solar_kwh * preis.cost) AS 'Ersparnis Solarladung OG',
|
||||
SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart OG',
|
||||
SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin OG'
|
||||
FROM stats_daily d
|
||||
JOIN preis ON d.tag >= preis.active_date AND (preis.folgt IS NULL OR d.tag < preis.folgt)
|
||||
LEFT JOIN gas ON d.tag >= gas.active_date AND (gas.folgt IS NULL OR d.tag < gas.folgt)
|
||||
LEFT JOIN sprit ON d.tag >= sprit.active_date AND (sprit.folgt IS NULL OR d.tag < sprit.folgt)
|
||||
WHERE YEAR(d.tag) = " . intval($jahr) . ";";
|
||||
SELECT ";
|
||||
}
|
||||
|
||||
$Query = statistikAbfrage($_GET["year"]);
|
||||
$PrevQuery = statistikAbfrage($_GET["year"] - 1);
|
||||
/* --------------------------------------------------------------- Rechnung --
|
||||
*
|
||||
* 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.
|
||||
$units = Array("kWh","€","€","€","€","€","€","%",
|
||||
"kWh","%","€","L","€",
|
||||
"kWh","%","€","L","€");
|
||||
// Nur bei Verbrauch und Kosten ist weniger besser.
|
||||
$LessIsBetter = array_merge([true, true], array_fill(0, count($units) - 2, false));
|
||||
$bezug = floatval($s["bezug_kwh"]);
|
||||
$kosten = floatval($s["bezug_eur"]) + $grundpreis;
|
||||
$geladen = floatval($s["eg_kwh"]) + floatval($s["og_kwh"]);
|
||||
$solar = floatval($s["eg_solar_kwh"]) + floatval($s["og_solar_kwh"]);
|
||||
|
||||
function array_insert($array,$values,$offset) {
|
||||
return array_slice($array, 0, $offset, true) + $values + array_slice($array, $offset, NULL, true);
|
||||
return [
|
||||
"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"]),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
|
||||
$html = "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
|
||||
$mysql = solarDb();
|
||||
$Res = mysqli_query($mysql,$Query);
|
||||
$ResPrev = mysqli_query($mysql,$PrevQuery);
|
||||
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++;
|
||||
/** Zwei Summensaetze addieren - fuer Jahre aus Monaten und die Gesamtsumme. */
|
||||
function summenPlus($a, $b)
|
||||
{
|
||||
if ($a === null) return $b;
|
||||
foreach ($b as $k => $v) {
|
||||
if ($v === null) continue;
|
||||
$a[$k] = ($a[$k] === null ? 0 : $a[$k]) + $v;
|
||||
}
|
||||
}
|
||||
$html .= "</div>";
|
||||
return $a;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ Abruf --*/
|
||||
|
||||
//header('Content-Type: application/json');
|
||||
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]}]}';
|
||||
$db = solarDb();
|
||||
|
||||
// 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
|
||||
* 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
|
||||
* restricted/costs.php, damit Maske und Endpunkt dieselbe Wahrheit ueber
|
||||
* einen Zeitraum haben.
|
||||
* restricted/costs.php bzw. restricted/kacheln.php, damit Maske und Endpunkt
|
||||
* dieselbe Wahrheit ueber einen Zeitraum oder eine Kachel haben.
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
require_once("../restricted/costs.php");
|
||||
require_once("../restricted/kacheln.php");
|
||||
require_once("../restricted/automations.php");
|
||||
|
||||
if (!checkLogin()) {
|
||||
http_response_code(403);
|
||||
@@ -43,6 +51,41 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
"gespeichert" => $anzahl,
|
||||
"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:
|
||||
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") {
|
||||
try {
|
||||
jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* ?was=kurve Verlauf einer einzelnen Ladung (SoC und kW über Zeit)
|
||||
* ?was=gesundheit kWh je 100 Ladestandsprozent, je Ladung, mit Trend
|
||||
* ?was=fahrten Fahrten zwischen zwei Parkständen, mit Koordinaten
|
||||
* ?was=strecke gefahrene Kilometer über Woche, Monat und Jahr
|
||||
*
|
||||
* Die Fahrzeugdaten stehen in der Tabelle skoda, geschrieben von
|
||||
* gatherSkodaData.py. Der Zeilentakt ist nicht fest: geschrieben wird, wenn
|
||||
@@ -358,6 +359,52 @@ case "fahrten":
|
||||
$antwort["kapazitaet"] = kapazitaetSchaetzen($mysql);
|
||||
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:
|
||||
http_response_code(400);
|
||||
$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 = [
|
||||
"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,
|
||||
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
|
||||
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false,
|
||||
"scripts" => ["js/solar/heatMQTT.js"]],
|
||||
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false,
|
||||
"scripts" => ["js/solar/historyMQTT.js"]],
|
||||
"scripts" => ["js/solar/jahresstatistik.js", "js/solar/historyMQTT.js"]],
|
||||
"skoda" => ["template" => "skoda.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => true,
|
||||
"scripts" => ["js/solar/skodaMQTT.js"]],
|
||||
"weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true, "leaflet" => false,
|
||||
|
||||
@@ -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) {
|
||||
automationRequest("toggle", { id: id }).then(antwort => {
|
||||
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.
|
||||
* 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("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(prodChartDecade, 'ajax/energyHistory.php?series=prod&range=decade');
|
||||
getData(consChartDecade, 'ajax/energyHistory.php?series=cons&range=decade');
|
||||
getStats("Stats-Year","ajax/getStats.php?type=ThisYear");
|
||||
getStats("Stats-Lastyear","ajax/getStats.php?type=LastYear");
|
||||
getStats("Stats-Prelastyear","ajax/getStats.php?type=PreLastYear");
|
||||
baueJahresstatistik(document.getElementById("jahresvergleich"),
|
||||
{ spalten: 3, werkzeuge: true, fussnoten: true });
|
||||
}
|
||||
});
|
||||
|
||||
+13
-13
@@ -122,6 +122,12 @@ const homeSVG = {
|
||||
* Wie er dasteht, sagt entweder "format" oder das Paar aus "einheit" und
|
||||
* "stellen": "stellen" schneidet Nachkommastellen ab, ohne Angabe bleibt
|
||||
* 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) {
|
||||
const roh = wert.wechselrichter
|
||||
@@ -138,6 +144,13 @@ const homeSVG = {
|
||||
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.
|
||||
// powerToString() aus common.js macht genau das und wird auf der
|
||||
// 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
|
||||
// home.php aus der Raumtabelle gebaut hat. Vorher standen OG, EG und UG hier
|
||||
// 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){
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
/* ==========================================================================
|
||||
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
|
||||
* immer auch das Alter des Standes, statt einen alten Wert als aktuell
|
||||
* 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 = {};
|
||||
@@ -86,6 +91,70 @@ function dauer(minuten) {
|
||||
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 = "") {
|
||||
const antwort = await fetch("ajax/skoda.php?was=" + was + extra, { cache: "no-store" });
|
||||
if (!antwort.ok) throw new Error("HTTP " + antwort.status);
|
||||
@@ -115,7 +184,6 @@ const ZUSTANDSWORT = {
|
||||
const TEILE = [
|
||||
{ id: "teil-doors", feld: "doors", name: "Türen", 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-bonnet", feld: "bonnet", name: "Motorhaube", zu: "CLOSED" },
|
||||
{ id: "teil-lights", feld: "lights", name: "Licht", zu: "OFF" }
|
||||
@@ -201,6 +269,17 @@ function zeichneTeile(stand) {
|
||||
|
||||
/* -------------------------------------------------------------- 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;
|
||||
function planeLiveAnzeige() {
|
||||
if (livePlanung) return; // höchstens fünfmal je Sekunde
|
||||
@@ -209,108 +288,289 @@ function planeLiveAnzeige() {
|
||||
|
||||
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;
|
||||
|
||||
// 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 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
|
||||
// gemeint sein, ein Akku trägt keine 1500 Kilometer.
|
||||
let km = mqZahl("evRange");
|
||||
if (km === null && stand && stand.range_m !== null && stand.range_m !== undefined) {
|
||||
const roh = Number(stand.range_m);
|
||||
km = roh > 1500 ? roh / 1000 : roh;
|
||||
if (km === null && stand) {
|
||||
const roh = feldZahl(stand.range_m);
|
||||
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
|
||||
// Hausanschluss. Beim Laden ist die Wallbox der belastbarere Wert.
|
||||
const wbKw = mqZahl("evPower");
|
||||
const carKw = stand && stand.chg_kw !== null ? Number(stand.chg_kw) : null;
|
||||
const kw = laedt ? (wbKw || carKw) : (wbKw && wbKw > 0.2 ? wbKw : null);
|
||||
$("skodaLeistung").textContent = kw ? nf(kw, 1) + " kW" : "–";
|
||||
const carKw = stand ? feldZahl(stand.chg_kw) : null;
|
||||
|
||||
const restMin = mqZahl("evRemChrgTime") ?? (stand ? Number(stand.chg_rem_min) : null);
|
||||
$("skodaRestzeit").textContent = laedt ? dauer(restMin) : "–";
|
||||
const pvW = mqZahl("P_PV");
|
||||
|
||||
$("skodaKm").textContent = stand && stand.odo_km ? nf(stand.odo_km) + " km" : "–";
|
||||
|
||||
const klima = stand ? stand.ac_state : null;
|
||||
const KLIMA = { OFF: "aus", COOLING: "kühlt", HEATING: "heizt",
|
||||
HEATING_AUXILIARY: "Zuheizer", VENTILATION: "lüftet",
|
||||
COMPLETED: "fertig", UNKNOWN: "unbekannt", UNSUPPORTED: "–" };
|
||||
$("skodaKlima").textContent = klima ? (KLIMA[klima] || klima) : "–";
|
||||
|
||||
// Ladezustand als Kennzeichen
|
||||
const ZUSTAND = {
|
||||
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"]
|
||||
return {
|
||||
zustand: zustand,
|
||||
laedt: laedt,
|
||||
soc: mqZahl("evSOC") ?? (stand ? feldZahl(stand.soc) : null),
|
||||
ziel: stand ? feldZahl(stand.target_soc) : null,
|
||||
km: km,
|
||||
kw: laedt ? (wbKw || carKw) : (wbKw && wbKw > 0.2 ? wbKw : null),
|
||||
pvKw: pvW === null ? null : Math.max(0, pvW / 1000),
|
||||
restMin: mqZahl("evRemChrgTime") ?? (stand ? feldZahl(stand.chg_rem_min) : null),
|
||||
ladeart: stand ? stand.chg_type : null,
|
||||
odo: stand ? feldZahl(stand.odo_km) : null,
|
||||
klima: stand ? stand.ac_state : null,
|
||||
// null heißt: das Fahrzeug meldet den Zustand des Schlosses selbst nicht.
|
||||
verriegelt: (stand && stand.locked !== null && stand.locked !== undefined)
|
||||
? Number(stand.locked) === 1 : null
|
||||
};
|
||||
const kz = $("skodaLadeZustand");
|
||||
const eintrag = ZUSTAND[zustand] || [zustand || "–", "text-bg-secondary"];
|
||||
kz.textContent = eintrag[0];
|
||||
kz.className = "badge " + eintrag[1];
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Zeichenschritte */
|
||||
|
||||
/**
|
||||
* 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 zu = stand ? stand.locked : null;
|
||||
if (zu === null || zu === undefined) {
|
||||
if (!schloss) return;
|
||||
if (w.verriegelt === null) {
|
||||
schloss.textContent = "Schloss unbekannt";
|
||||
schloss.className = "badge text-bg-secondary";
|
||||
} else if (Number(zu) === 1) {
|
||||
} else if (w.verriegelt) {
|
||||
schloss.textContent = "verriegelt";
|
||||
schloss.className = "badge text-bg-success";
|
||||
} else {
|
||||
schloss.textContent = "offen";
|
||||
schloss.className = "badge text-bg-warning";
|
||||
}
|
||||
}
|
||||
|
||||
// Ladeanschluss pulst beim Laden
|
||||
const stecker = $("teil-plug");
|
||||
if (stecker) stecker.classList.toggle("laedt", laedt);
|
||||
|
||||
// Herkunft des Ladestroms: was die PV im Augenblick liefert, gegen das,
|
||||
// was die Wallbox zieht. Mehr als die Ladeleistung kann die PV nicht
|
||||
// beitragen, auch wenn gerade mehr Sonne da ist.
|
||||
/**
|
||||
* Herkunft des Ladestroms: was die Anlage im Augenblick liefert, gegen das,
|
||||
* was die Wallbox zieht. Ohne Ladung hat der Balken nichts zu teilen und
|
||||
* bleibt weg.
|
||||
*/
|
||||
function zeichneLadequelle(w) {
|
||||
const box = $("skodaQuelleBox");
|
||||
const pvW = mqZahl("P_PV");
|
||||
if (laedt && kw && pvW !== null) {
|
||||
const pvKw = Math.max(0, Math.min(kw, pvW / 1000));
|
||||
const anteil = Math.round(100 * pvKw / kw);
|
||||
$("skodaAnteilPV").style.width = anteil + "%";
|
||||
$("skodaAnteilNetz").style.width = (100 - anteil) + "%";
|
||||
$("skodaQuelleText").textContent = anteil + " % Sonne, " + (100 - anteil) + " % Netz";
|
||||
box.style.display = "";
|
||||
} else {
|
||||
box.style.display = "none";
|
||||
}
|
||||
if (!box) return;
|
||||
|
||||
const zeigen = w.laedt && w.kw > 0 && w.pvKw !== null;
|
||||
box.hidden = !zeigen;
|
||||
if (!zeigen) return;
|
||||
|
||||
// Mehr als die Ladeleistung kann die Anlage nicht beitragen, auch wenn
|
||||
// gerade mehr Sonne da ist - der Überschuss geht ins Haus oder ins Netz.
|
||||
const ausPv = Math.max(0, Math.min(w.kw, w.pvKw));
|
||||
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 */
|
||||
@@ -691,7 +951,7 @@ function zeigeVerbrauchsbasis() {
|
||||
if (!letzteKapazitaet) {
|
||||
// Ohne Kapazität gibt es nichts umzuschalten: dann bleibt nur Prozent.
|
||||
feld.textContent = "";
|
||||
feld.removeAttribute("title");
|
||||
setzeHinweis(feld, null);
|
||||
if (schalter) schalter.style.display = "none";
|
||||
setzeEinheit("prozent", false);
|
||||
return;
|
||||
@@ -703,7 +963,7 @@ function zeigeVerbrauchsbasis() {
|
||||
|
||||
// Woher der Wirkungsgrad stammt, gehört dazu: gemessen ist er aus dem
|
||||
// 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_netto, 1) + " kWh davon in der Batterie. Ladewirkungsgrad "
|
||||
+ nf(k.wirkungsgrad * 100, 1) + " %, "
|
||||
@@ -711,7 +971,7 @@ function zeigeVerbrauchsbasis() {
|
||||
? "gemessen aus " + k.n_fahrzeug + " Ladungen — der Zähler misst vor dem "
|
||||
+ "Ladegerät, das Fahrzeug meldet dahinter."
|
||||
: "angenommen, weil noch zu wenige Ladungen die Ladeleistung des "
|
||||
+ "Fahrzeugs mitgeliefert haben.");
|
||||
+ "Fahrzeugs mitgeliefert haben."));
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
try {
|
||||
const d = await hole("ladungen", "&tage=180");
|
||||
@@ -1137,6 +1438,7 @@ function verdrahteBefehle(bereich) {
|
||||
|
||||
function alleLaden() {
|
||||
ladeLive();
|
||||
ladeStrecke();
|
||||
ladeLadungen();
|
||||
ladeKurve();
|
||||
ladeGesundheit();
|
||||
@@ -1161,7 +1463,9 @@ document.addEventListener("readystatechange", function () {
|
||||
// Die Tabelle wächst im Takt des Fahrzeugs, nicht im Takt des Betrachters:
|
||||
// beim Parken steht dort stundenlang dasselbe. Einmal pro Minute genügt.
|
||||
setInterval(ladeLive, 60000);
|
||||
setInterval(function () { ladeLadungen(); ladeKurve(); ladeFahrten(); }, 300000);
|
||||
setInterval(function () {
|
||||
ladeLadungen(); ladeKurve(); ladeFahrten(); ladeStrecke();
|
||||
}, 300000);
|
||||
setInterval(ladeGesundheit, 900000);
|
||||
|
||||
// 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(prodChart, 'ajax/getProdData.php', { params: dayRange, sunrise: true, 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();
|
||||
mountMeteogram();
|
||||
}
|
||||
|
||||
+164
-12
@@ -316,17 +316,6 @@ function raumVorschlaege()
|
||||
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|""]. */
|
||||
function saveRooms($zuordnung)
|
||||
{
|
||||
@@ -352,6 +341,161 @@ function saveRooms($zuordnung)
|
||||
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.
|
||||
*
|
||||
@@ -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,
|
||||
s.url, s.possible_values, t.type
|
||||
s.url, s.value_path, s.possible_values, t.type
|
||||
FROM actor_states s
|
||||
LEFT JOIN state_types t ON s.state_type = t.id
|
||||
ORDER BY s.actor_id, s.id");
|
||||
@@ -447,6 +597,8 @@ function deviceCatalog()
|
||||
"unit" => $row["unit"],
|
||||
"value" => $row["current_value"],
|
||||
"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)
|
||||
{
|
||||
$jahr = intval($jahr);
|
||||
$von = $jahr . "-01-01";
|
||||
$bis = $jahr . "-12-31";
|
||||
$tage = intval((new DateTimeImmutable($bis))->format("z")) + 1; // 365 oder 366
|
||||
return grundpreisImZeitraum($jahr . "-01-01", $jahr . "-12-31");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
"WITH p AS (SELECT active_date, base,
|
||||
|
||||
@@ -173,6 +173,32 @@ class ShellyDiscovery:
|
||||
|
||||
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]:
|
||||
"""
|
||||
Entdeckt alle Shelly-Geräte im Netzwerk
|
||||
@@ -185,6 +211,7 @@ class ShellyDiscovery:
|
||||
for idx, device in enumerate(self.devices):
|
||||
status = self.get_device_status(device)
|
||||
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")
|
||||
return self.devices
|
||||
@@ -205,6 +232,52 @@ class ShellyModule(BaseModule):
|
||||
"""Prüft ob Shelly aktiviert ist"""
|
||||
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]]:
|
||||
"""
|
||||
Führt Shelly Discovery durch
|
||||
@@ -256,6 +329,11 @@ class ShellyModule(BaseModule):
|
||||
|
||||
device_name = info.get('name', f"Shelly_{info.get('id', ip)}")
|
||||
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
|
||||
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': 'toggle', 'parameters': []}
|
||||
],
|
||||
'states': [
|
||||
'states': self._adressiere([
|
||||
{
|
||||
'name': 'output',
|
||||
'type': 'boolean',
|
||||
'url': 'output',
|
||||
'current_value': switch_data.get('output', False)
|
||||
}
|
||||
]
|
||||
], praefix, 'switch', i)
|
||||
})
|
||||
|
||||
# Temperatursensoren
|
||||
@@ -290,7 +368,7 @@ class ShellyModule(BaseModule):
|
||||
'type': 'Temperatur',
|
||||
'name': f"{device_name}_Temp_{i}",
|
||||
'url': f"http://{ip}/rpc/Temperature.GetStatus?id={i}",
|
||||
'states': [
|
||||
'states': self._adressiere([
|
||||
{
|
||||
'name': 'temperature',
|
||||
'type': 'number',
|
||||
@@ -298,7 +376,7 @@ class ShellyModule(BaseModule):
|
||||
'url': f"tC",
|
||||
'unit': '°C'
|
||||
}
|
||||
]
|
||||
], praefix, 'temperature', i)
|
||||
})
|
||||
# Energy-Meter
|
||||
em_count = sum(1 for key in status.keys() if key.startswith('em:'))
|
||||
@@ -422,6 +500,8 @@ class ShellyModule(BaseModule):
|
||||
'url': 'total_act_power',
|
||||
'current_value': em_data.get('total_act_power'),
|
||||
'unit': 'W'})
|
||||
# Erst hier, weil die Messwerte oben einzeln angehaengt werden.
|
||||
self._adressiere(sensors[len(sensors)-1]['states'], praefix, 'em', i)
|
||||
|
||||
return actors, sensors
|
||||
|
||||
|
||||
+34
-19
@@ -1,16 +1,24 @@
|
||||
<?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.
|
||||
$periods = [
|
||||
"month" => "Letzter Monat",
|
||||
"year" => "Letzte 13 Monate",
|
||||
"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-->
|
||||
<main class="app-main">
|
||||
@@ -63,23 +71,30 @@ $statistics = [
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php foreach ($statistics as $id => $title): ?>
|
||||
<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"><?= $title ?></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>
|
||||
<!-- ============================================ Jahresvergleich ==== -->
|
||||
<!-- Der Inhalt kommt aus baueJahresstatistik() in
|
||||
js/solar/jahresstatistik.js: Leitzahlen, Werkzeugleiste, Tabelle und
|
||||
Fussnoten baut der Baustein selbst, damit die Solarseite dieselbe
|
||||
Statistik in schmaler Form zeigen kann, ohne dass es sie zweimal
|
||||
gaebe. -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<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 class="row-cols-1 row-cols-md-1 g-4 p-2" id="<?= $id ?>">
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.card -->
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- /.row (main row) -->
|
||||
</div>
|
||||
<!--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-->
|
||||
<main class="app-main">
|
||||
<!--begin::App Content Header-->
|
||||
@@ -185,12 +188,6 @@
|
||||
<div class="card-header pb-0 pt-1">
|
||||
<h3 class="card-title">Automatismen</h3>
|
||||
<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">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
@@ -198,28 +195,35 @@
|
||||
|
||||
</div>
|
||||
<div class="card-body pb-1">
|
||||
<div class="card-header p-0 pt-1">
|
||||
<ul class="nav nav-tabs" id="actions-tab" role="tablist">
|
||||
<?php foreach ($floors as $etage): $offen = $_GET["floor"] == $etage; ?>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?php if ($offen) echo "active"; ?>" id="actions-<?= $etage ?>-tab"
|
||||
href="#actions-<?= $etage ?>" onclick="switchTab('actions-<?= $etage ?>')"
|
||||
title="<?= htmlspecialchars(floorLabel($etage)) ?>"
|
||||
aria-controls="actions-<?= $etage ?>" aria-selected="<?= $offen ? "true" : "false" ?>"><?= $etage ?></a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php
|
||||
/*
|
||||
* Eine Etage, ein Reiter. Gezeichnet von reiterLeiste() aus
|
||||
* restricted/reiter.php - dieselbe Bauart wie im Raum-Modal und
|
||||
* auf der Einstellungsseite. Frueher stand hier ein eigenes
|
||||
* Markup samt eigenem switchTab() in homeMQTT.js; umgeschaltet
|
||||
* wird jetzt von Bootstrap wie ueberall sonst.
|
||||
*
|
||||
* Der Name "actions" haelt die Element-Ids, an denen
|
||||
* autoActionFuncs.js haengt: actions-OG, actions-OG-list.
|
||||
*/
|
||||
$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">
|
||||
<?php foreach ($floors as $etage): ?>
|
||||
<div class="tab-pane fade <?php if ($_GET["floor"] == $etage) echo "active show"; ?>"
|
||||
id="actions-<?= $etage ?>" role="tabpanel" aria-labelledby="actions-<?= $etage ?>-tab">
|
||||
<?= reiterFlaecheAuf($etage, $autoOpt) ?>
|
||||
<!-- Inhalt kommt aus ajax/AutoAction.php?action=list, gefuellt von
|
||||
refreshAutomations() in js/solar/autoActionFuncs.js -->
|
||||
<div id="actions-<?= $etage ?>-list">Wird geladen...</div>
|
||||
<button type="button" class="btn btn-success" title="Neue Automatik"
|
||||
onclick="openAutoActionModal('?action=editor&floor=<?= $etage ?>')"><i class="bi bi-plus-square"></i></button>
|
||||
</div>
|
||||
<?= reiterFlaecheZu() ?>
|
||||
<?php endforeach; ?>
|
||||
</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 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
|
||||
* Schaltkommando ist.
|
||||
@@ -209,38 +213,29 @@ function zeichneGruppen($gruppen, $fertig = [])
|
||||
return "";
|
||||
}
|
||||
|
||||
$html = "";
|
||||
if (count($vorhanden) > 1) {
|
||||
$html .= "<ul class='nav nav-tabs flex-nowrap mb-2 raum-tabs' role='tablist'>";
|
||||
$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;
|
||||
// Aus den Gruppen werden Reiter. Wie sie aussehen und wie umgeschaltet
|
||||
// wird, steht in restricted/reiter.php - hier nur, was drinsteht.
|
||||
$reiter = [];
|
||||
foreach ($vorhanden as $form => $titel) {
|
||||
$html .= "<div class='tab-pane fade" . ($erster ? " show active" : "") . "'"
|
||||
. " id='raumtab-" . $form . "' role='tabpanel'>";
|
||||
$inhalt = "";
|
||||
if (isset($fertig[$form])) {
|
||||
$html .= $fertig[$form];
|
||||
$inhalt = $fertig[$form];
|
||||
} else {
|
||||
foreach ($gruppen[$form] as $d) {
|
||||
$html .= zeichneGeraet($d, $form);
|
||||
$inhalt .= zeichneGeraet($d, $form);
|
||||
}
|
||||
}
|
||||
$html .= "</div>";
|
||||
$erster = false;
|
||||
$reiter[] = [
|
||||
"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.
|
||||
*
|
||||
* 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
|
||||
* die Aktualisierungsschleife in js/solar/homeMQTT.js (die Tabelle wird dafür
|
||||
* 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
|
||||
* x, y Position der Kachel im SVG (viewBox 400x300) - fehlt bei Räumen
|
||||
* 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
|
||||
* (Schlüssel in einer JSON-Nachricht) und entweder format
|
||||
* ("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.
|
||||
*/
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
* ist ohnehin eingebunden (restricted/header.php), und SVG-Text darf dieselbe
|
||||
* Schriftfamilie benutzen wie HTML. Ein eingebettetes <path> je Symbol wäre
|
||||
* dieselbe Wirkung mit mehr Zeilen.
|
||||
*
|
||||
* Die Zahlen stehen in genau dieser CSS unter .bi-<name>::before und sind von
|
||||
* dort abgeschrieben - deshalb steht der Name daneben, sonst prüft das nie
|
||||
* wieder jemand nach.
|
||||
* Ein unbekannter Name gibt einen leeren Text - die Kachel zeigt dann die
|
||||
* Zahl ohne Symbol, statt ein leeres Kästchen anzuzeigen.
|
||||
*/
|
||||
function kachelIcon($name)
|
||||
{
|
||||
$icons = [
|
||||
"ev-station" => "F83A", // Wallbox, Ladeleistung
|
||||
"cloud-rain" => "F2B6", // Regenmenge
|
||||
"sun" => "F5A2", // PV-Erzeugung
|
||||
];
|
||||
return isset($icons[$name]) ? mb_chr(hexdec($icons[$name]), "UTF-8") : "";
|
||||
$icons = bootstrapIcons();
|
||||
return isset($icons[$name]) ? mb_chr($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
|
||||
* standen: Solltemperatur klein darüber, Isttemperatur gross, Feuchte klein
|
||||
* darunter. Nur diese Räume bekommen auch die Symbole für "heizt" und
|
||||
* "Pufferbetrieb" - anderswo gibt es nichts zu heizen.
|
||||
* darunter.
|
||||
*/
|
||||
function mitWerten($room)
|
||||
function kachelVorgabe($room)
|
||||
{
|
||||
if (isset($room["werte"])) {
|
||||
$room["heizung"] = false;
|
||||
$room["werte"] = array_slice($room["werte"], 0, 3);
|
||||
return $room;
|
||||
return array_slice($room["werte"], 0, 3);
|
||||
}
|
||||
$zweig = "Raumtemp/" . $room["floor"] . "/" . $room["mqtt"] . "/";
|
||||
$room["heizung"] = true;
|
||||
$room["werte"] = [
|
||||
return [
|
||||
["topic" => $zweig . "Set Temp[degC]", "einheit" => "°C", "stellen" => 0],
|
||||
["topic" => $zweig . "Temp[degC]", "einheit" => "°C", "gross" => true],
|
||||
["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;
|
||||
}
|
||||
|
||||
|
||||
+104
-4
@@ -2,10 +2,15 @@
|
||||
/**
|
||||
* Einstellungsseite: Parameter, die von Hand gepflegt werden.
|
||||
*
|
||||
* Bisher sind das die Preistabellen aus restricted/costs.php. Die Seite ist
|
||||
* als Sammlung von Karten angelegt - eine Karte je Thema. Kommt spaeter ein
|
||||
* weiterer Parameter dazu, kommt er als eigene Karte hinzu, ohne dass die
|
||||
* vorhandenen etwas davon merken.
|
||||
* Gegliedert in Reiter, einer je Thema - Preise und die Anzeigewerte der
|
||||
* Home-Kacheln. Die Liste $reiter unten ist die einzige Stelle, die davon
|
||||
* weiss: ein neues Thema ist ein Eintrag dort plus der Inhalt, alles Uebrige
|
||||
* (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
|
||||
* eingebettet wird - dieselbe Arbeitsteilung wie im Automatik-Editor. Sonst
|
||||
@@ -15,6 +20,22 @@
|
||||
* die Felder dort muessen dieselben sein.
|
||||
*/
|
||||
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();
|
||||
$daten = [];
|
||||
@@ -50,6 +71,13 @@ try {
|
||||
<?php if ($fehler): ?>
|
||||
<div class="alert alert-danger">Die Einstellungen sind nicht erreichbar: <?= htmlspecialchars($fehler) ?></div>
|
||||
<?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 $breiten = preisSpalten($reihe["felder"]); ?>
|
||||
<div class="row">
|
||||
@@ -94,6 +122,78 @@ try {
|
||||
</div>
|
||||
</div>
|
||||
<?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>
|
||||
<!--end::App Content-->
|
||||
|
||||
+375
-180
@@ -19,10 +19,16 @@
|
||||
* 4648 x 1877 mm, Radstand 2765 mm, daraus das Seitenverhältnis 2,48:1 und
|
||||
* die Lage der Achsen. Kein Foto, sondern ein Schema - die Teile sollen
|
||||
* ihren Zustand zeigen, nicht das Auto porträtieren.
|
||||
*
|
||||
* 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-->
|
||||
<main class="app-main">
|
||||
<main class="app-main skoda-seite">
|
||||
<div class="app-content-header">
|
||||
<div class="container-fluid">
|
||||
<div class="row align-items-center">
|
||||
@@ -39,208 +45,397 @@
|
||||
<div class="app-content">
|
||||
<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">
|
||||
|
||||
<!-- 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="col-12">
|
||||
<div class="card mb-4 p-0 w-100">
|
||||
<div class="card-header pb-0 pt-1">
|
||||
<h3 class="card-title">Fahrzeug</h3>
|
||||
<div class="card-tools">
|
||||
<h3 class="card-title">Übersicht</h3>
|
||||
<div class="card-tools d-flex align-items-center gap-2">
|
||||
<span class="badge text-bg-secondary" id="skodaSchloss">–</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column align-items-center pt-2">
|
||||
|
||||
<svg id="skodaAuto" class="skoda-auto" viewBox="0 0 224 500"
|
||||
role="img" aria-label="Draufsicht des Fahrzeugs mit dem Zustand von Türen, Fenstern, Klappen und Licht">
|
||||
|
||||
<!-- Räder. Vorderachse bei 860 mm, Hinterachse bei 3625 mm
|
||||
ab Bugspitze - der Radstand von 2765 mm maßstäblich. -->
|
||||
<g class="rad">
|
||||
<rect x="5" y="68" width="21" height="58" rx="9"/>
|
||||
<rect x="198" y="68" width="21" height="58" rx="9"/>
|
||||
<rect x="5" y="362" width="21" height="58" rx="9"/>
|
||||
<rect x="198" y="362" width="21" height="58" rx="9"/>
|
||||
</g>
|
||||
|
||||
<!-- Karosserie -->
|
||||
<path class="karosserie" d="M112 6
|
||||
C150 6, 198 16, 204 50 C209 90, 212 160, 212 250
|
||||
C212 340, 209 410, 204 450 C198 484, 150 494, 112 494
|
||||
C74 494, 26 484, 20 450 C15 410, 12 340, 12 250
|
||||
C12 160, 15 90, 20 50 C26 16, 74 6, 112 6 Z"/>
|
||||
|
||||
<!-- Tech-Deck Face: die glänzend schwarze Blende über die
|
||||
volle Bugbreite, die den Enyaq seit der Überarbeitung
|
||||
kennzeichnet. Von oben sieht man ihre Oberkante. -->
|
||||
<path class="techdeck" d="M112 7
|
||||
C150 7, 197 17, 203 49 L194 53 C187 27, 148 18, 112 18
|
||||
C76 18, 37 27, 30 53 L21 49 C27 17, 74 7, 112 7 Z"/>
|
||||
|
||||
<!-- Scheinwerfer, vier Elemente je Seite wie beim Original,
|
||||
dazu die Rückleuchten als durchgehendes Band. -->
|
||||
<g id="teil-lights" class="teil teil-licht" data-teil="lights">
|
||||
<rect x="36" y="30" width="9" height="13" rx="4"/>
|
||||
<rect x="48" y="27" width="9" height="13" rx="4"/>
|
||||
<rect x="60" y="25" width="9" height="13" rx="4"/>
|
||||
<rect x="72" y="24" width="9" height="13" rx="4"/>
|
||||
<rect x="143" y="24" width="9" height="13" rx="4"/>
|
||||
<rect x="155" y="25" width="9" height="13" rx="4"/>
|
||||
<rect x="167" y="27" width="9" height="13" rx="4"/>
|
||||
<rect x="179" y="30" width="9" height="13" rx="4"/>
|
||||
<rect class="rueck" x="40" y="462" width="52" height="13" rx="6"/>
|
||||
<rect class="rueck" x="132" y="462" width="52" height="13" rx="6"/>
|
||||
<title>Beleuchtung</title>
|
||||
</g>
|
||||
|
||||
<!-- Motorhaube mit angedeuteter Mittelsicke -->
|
||||
<g id="teil-bonnet" class="teil" tabindex="0" data-teil="bonnet">
|
||||
<path d="M31 152 L193 152 L189 62 C169 40, 55 40, 35 62 Z"/>
|
||||
<title>Motorhaube</title>
|
||||
</g>
|
||||
<path class="sicke" d="M112 60 L112 150"/>
|
||||
|
||||
<!-- Windschutzscheibe, Dach, Heckscheibe -->
|
||||
<path class="scheibe" d="M34 156 L190 156 L178 200 L46 200 Z"/>
|
||||
<rect class="dach" x="46" y="200" width="132" height="136" rx="10"/>
|
||||
|
||||
<!-- Panoramaglasdach: beim Enyaq nahezu die ganze Dachfläche -->
|
||||
<g id="teil-sunroof" class="teil" tabindex="0" data-teil="sunroof">
|
||||
<rect x="62" y="206" width="100" height="120" rx="8"/>
|
||||
<title>Panoramadach</title>
|
||||
</g>
|
||||
|
||||
<!-- Dachreling -->
|
||||
<g class="reling">
|
||||
<rect x="48" y="204" width="9" height="134" rx="4"/>
|
||||
<rect x="167" y="204" width="9" height="134" rx="4"/>
|
||||
</g>
|
||||
|
||||
<path class="scheibe" d="M46 336 L178 336 L190 382 L34 382 Z"/>
|
||||
<rect class="spoiler" x="52" y="330" width="120" height="9" rx="4"/>
|
||||
|
||||
<!-- Heckklappe -->
|
||||
<g id="teil-trunk" class="teil" tabindex="0" data-teil="trunk">
|
||||
<path d="M34 384 L190 384 L186 458 C168 478, 56 478, 38 458 Z"/>
|
||||
<title>Heckklappe</title>
|
||||
</g>
|
||||
|
||||
<!-- Türen, jeweils mit Seitenfenster darin -->
|
||||
<g id="teil-doors" class="teil" data-teil="doors">
|
||||
<g tabindex="0"><rect x="12" y="202" width="23" height="74" rx="6"/><title>Tür vorn links</title></g>
|
||||
<g tabindex="0"><rect x="12" y="278" width="23" height="66" rx="6"/><title>Tür hinten links</title></g>
|
||||
<g tabindex="0"><rect x="189" y="202" width="23" height="74" rx="6"/><title>Tür vorn rechts</title></g>
|
||||
<g tabindex="0"><rect x="189" y="278" width="23" height="66" rx="6"/><title>Tür hinten rechts</title></g>
|
||||
</g>
|
||||
<g id="teil-windows" class="teil teil-fenster" data-teil="windows">
|
||||
<rect x="17" y="210" width="13" height="58" rx="4"/>
|
||||
<rect x="17" y="286" width="13" height="50" rx="4"/>
|
||||
<rect x="194" y="210" width="13" height="58" rx="4"/>
|
||||
<rect x="194" y="286" width="13" height="50" rx="4"/>
|
||||
<title>Seitenfenster</title>
|
||||
</g>
|
||||
|
||||
<!-- Außenspiegel -->
|
||||
<g class="spiegel">
|
||||
<rect x="1" y="194" width="16" height="10" rx="5"/>
|
||||
<rect x="207" y="194" width="16" height="10" rx="5"/>
|
||||
</g>
|
||||
|
||||
<!-- ============================================================
|
||||
Steuerung: die beiden anklickbaren Stellen.
|
||||
|
||||
Bedient wird das, worum es geht - der Ladeanschluss für
|
||||
das Laden, der Innenraum für Klima und Lüftung. Beide
|
||||
tragen einen Ring als Hinweis darauf, dass sie mehr sind
|
||||
als Anzeige, und öffnen ein Modal mit den Befehlen.
|
||||
|
||||
Sie stehen bewusst NICHT in der Zustandszeile darunter:
|
||||
die zeigt, was ist. Diese beiden ändern etwas.
|
||||
============================================================ -->
|
||||
|
||||
<!-- Ladeanschluss. Beim Enyaq sitzt die Klappe am hinteren
|
||||
rechten Kotflügel; sie pulst, solange geladen wird. -->
|
||||
<g id="teil-plug" class="teil teil-stecker steuer" tabindex="0"
|
||||
role="button" data-steuer="laden" aria-label="Laden steuern">
|
||||
<circle class="ring" cx="203" cy="408" r="17"/>
|
||||
<circle cx="203" cy="408" r="11"/>
|
||||
<path d="M198 402 l6 5 -4 0 4 6"/>
|
||||
<title>Laden steuern</title>
|
||||
</g>
|
||||
|
||||
<!-- Innenraum, auf der Windschutzscheibe: Klima und Lüftung. -->
|
||||
<g id="steuer-klima" class="steuer" tabindex="0"
|
||||
role="button" data-steuer="klima" aria-label="Klima und Lüftung steuern">
|
||||
<circle class="ring" cx="112" cy="178" r="19"/>
|
||||
<circle class="flaeche" cx="112" cy="178" r="13"/>
|
||||
<path class="glyphe" d="M112 170 v10 M108 174 h8 M105 185 q7 -4 14 0"/>
|
||||
<title>Klima und Lüftung steuern</title>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="skoda-teile" id="skodaTeile"></div>
|
||||
<p class="skoda-hinweis">
|
||||
Ladeanschluss und Innenraum antippen, um zu steuern
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batterie, Reichweite, Ladung -->
|
||||
<div class="col-xxl-8 col-lg-7 d-flex align-items-stretch">
|
||||
<div class="card mb-4 p-0 w-100">
|
||||
<div class="card-header pb-0 pt-1">
|
||||
<h3 class="card-title">Batterie und Ladung</h3>
|
||||
<div class="card-tools">
|
||||
<span class="badge text-bg-secondary" id="skodaLadeZustand">–</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center g-3">
|
||||
<div class="skoda-fahrzeug">
|
||||
|
||||
<!-- Ladestandsring. Der Strich auf dem Ring ist das
|
||||
eingestellte Ladeziel - eine Ladung, die dort endet, ist
|
||||
kein Fehler, sondern Absicht. -->
|
||||
<div class="col-md-5 text-center">
|
||||
<svg class="skoda-ring" viewBox="0 0 200 200" role="img" aria-labelledby="skodaSocText">
|
||||
<circle class="ring-bahn" cx="100" cy="100" r="82"/>
|
||||
<circle class="ring-wert" id="skodaRing" cx="100" cy="100" r="82"/>
|
||||
<line class="ring-ziel" id="skodaZiel" x1="100" y1="8" x2="100" y2="26"/>
|
||||
<text id="skodaSocText" class="ring-zahl" x="100" y="98">–</text>
|
||||
<text class="ring-einheit" x="100" y="126">Prozent</text>
|
||||
<!-- 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 Zustandszeile rechts benennt dasselbe in Worten und
|
||||
hebt beim Überfahren das zugehörige Teil hervor - Farbe
|
||||
allein soll die Aussage nicht tragen. -->
|
||||
<div class="skoda-bild">
|
||||
<svg id="skodaAuto" class="skoda-auto" viewBox="-16 -8 256 516"
|
||||
role="img" aria-label="Draufsicht des Fahrzeugs mit Ladestand der Batterie und dem Zustand von Türen, Fenstern, Klappen und Licht">
|
||||
|
||||
<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>
|
||||
|
||||
<p class="skoda-hinweis">
|
||||
Ladeanschluss und Innenraum antippen, um zu steuern
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-md-7">
|
||||
<div class="skoda-werte">
|
||||
<div class="skoda-wert"><span class="k">Reichweite</span><span class="v" id="skodaReichweite">–</span></div>
|
||||
<div class="skoda-wert"><span class="k">Ladeleistung</span><span class="v" id="skodaLeistung">–</span></div>
|
||||
<div class="skoda-wert"><span class="k">Restzeit</span><span class="v" id="skodaRestzeit">–</span></div>
|
||||
<div class="skoda-wert"><span class="k">Ladeziel</span><span class="v" id="skodaZielText">–</span></div>
|
||||
<div class="skoda-wert"><span class="k">Kilometerstand</span><span class="v" id="skodaKm">–</span></div>
|
||||
<div class="skoda-wert"><span class="k">Klima</span><span class="v" id="skodaKlima">–</span></div>
|
||||
<!-- Alles, was sich nicht ins Bild zeichnen lässt. -->
|
||||
<div class="skoda-daten">
|
||||
|
||||
<div class="skoda-kennzahlen">
|
||||
<div class="kennzahl" id="skodaKachelLeistung">
|
||||
<span class="zeichen"><i class="bi bi-lightning-charge-fill" aria-hidden="true"></i></span>
|
||||
<span class="text">
|
||||
<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>
|
||||
|
||||
<!-- Woher der Strom gerade kommt: der Balken teilt die
|
||||
Ladeleistung in den Anteil, den die PV deckt, und den
|
||||
Rest aus dem Netz. -->
|
||||
<div class="skoda-quelle mt-3" id="skodaQuelleBox">
|
||||
<div class="d-flex justify-content-between small text-secondary mb-1">
|
||||
<span>Ladestrom</span><span id="skodaQuelleText">–</span>
|
||||
Rest aus dem Netz. Steht nur da, solange geladen wird -
|
||||
ohne Ladung hat der Balken nichts zu teilen. -->
|
||||
<div class="skoda-quelle" id="skodaQuelleBox" hidden>
|
||||
<div class="kopf">
|
||||
<span class="skoda-marke">Ladestrom</span>
|
||||
<span class="summe" id="skodaQuelleText">–</span>
|
||||
</div>
|
||||
<div class="skoda-balken">
|
||||
<div class="teil-pv" id="skodaAnteilPV"></div>
|
||||
<div class="teil-netz" id="skodaAnteilNetz"></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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
+16
-17
@@ -64,23 +64,6 @@
|
||||
<!-- /.card -->
|
||||
</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="card p-0 mb-4" style="width:100%">
|
||||
<div class="card-header pb-0 pt-1">
|
||||
@@ -118,6 +101,22 @@
|
||||
</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-->
|
||||
<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