Compare commits

...
7 Commits
Author SHA1 Message Date
adminandClaude Opus 5 060336c891 Kachelwerte koennen das Vorzeichen umdrehen
Ein Zaehler zaehlt in der Richtung, in der er eingebaut ist - einer der
beiden Shelly-Zaehler meldet deshalb mit umgekehrtem Vorzeichen, und auf der
Kachel stand der Verbrauch als Minusleistung.

Neu gibt es dafuer den Schalter "negativ" an jedem Kachelwert. Er sitzt in
der Anzeige und nicht am Zaehler: Automatiken und Statistik lesen dieselbe
Nachricht und rechnen mit dem Vorzeichen, das dort steht - wer es an der
Quelle drehte, verschoebe die Rechnung an einer Stelle, an der niemand
danach sucht.

Gedreht wird vor dem Formatieren, also auch vor der Skalierung zwischen W
und kW und vor den Nachkommastellen. Aus -0 wird wieder 0. Was keine Zahl
ist, bleibt unangetastet und die Kachel zeigt weiter ihren Strich.

In der eingeklappten Kurzfassung steht das Minus vor dem Namen, damit man
ohne Aufklappen sieht, welcher Wert gedreht ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:41:18 +02:00
adminandClaude Opus 5 cd398bc800 Kachelsymbole: jedes Bootstrap-Icon statt einer Handvoll
Fuer die Symbole vor den Zahlen standen drei Codepunkte von Hand in
rooms.php, und die Maske bot genau diese drei an. Jedes weitere Symbol waere
eine Codeaenderung gewesen.

Die Codepunkte stehen aber schon im Haus: css/bootstrap-icons.min.css fuehrt
sie unter .bi-<name>::before - dieselbe Quelle, aus der auch der Browser
liest. bootstrapIcons() liest sie von dort (2078 Stueck, einmal je Anfrage),
kachelIcon() schlaegt darin nach. Damit ist jedes Symbol moeglich, und ein
Versionswechsel der Icons zieht von selbst mit.

In der Maske steht deshalb kein Auswahlfeld mehr, sondern der Name mit einer
Vorschau davor - "moisture" sagt nichts, das Symbol daneben schon. Die
Vorschlagsliste beginnt mit gut dreissig ueblichen Symbolen unter deutscher
Beschriftung (Temperatur, Feuchte, Leistung, Wallbox, Regen ...), danach folgt
der ganze Rest alphabetisch. Ein Name, den es nicht gibt, zeigt sofort ein
rotes Fragezeichen und wird beim Speichern mit Begruendung abgelehnt.

In der eingeklappten Kurzfassung einer Kachel steht das Symbol jetzt mit -
es ist das schnellste Erkennungszeichen dafuer, welcher der drei Werte
gemeint ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:24:14 +02:00
adminandClaude Opus 5 eb1aa8c760 Geraetetafel ohne eigenen Rollbalken
Die Tafel stand in einem Kasten mit fester Hoehe und eigenem Balken - in einer
Karte, die selbst in einer scrollenden Seite steht. Beim Wischen wusste man
nie, welcher der beiden Balken gerade dran ist. Jetzt ist die Tafel so lang,
wie sie ist, und die Seite scrollt als Ganzes.

Der Tabellenkopf klebt weiterhin oben: gescrollt wird bei AdminLTE in
.app-main, dort sitzt oben die Navigation, und daran haelt er sich fest. Dafuer
muss die Tafel selbst frei von overflow bleiben - sonst waere sie wieder ein
eigener Scroll-Kasten, und der Kopf hinge an ihr statt an der Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:56:19 +02:00
adminandClaude Opus 5 07bca5104e Einstellungsseite: Reiter, Kachelwerte und Geraeteliste
Die Einstellungen bestanden aus einer einzigen langen Spalte mit den
Preistabellen. Sie bekommt Reiter, und darin zwei neue Flaechen: was die
Kacheln der Startseite anzeigen, und welche Geraete es ueberhaupt gibt.

Reiter gab es vorher dreimal in drei Ausfuehrungen - die Etagen ueber den
Automatismen mit eigenem switchTab(), die Geraetegruppen im Raum-Modal mit
eigener CSS-Klasse, und die Einstellungsseite noch einmal anders. Jetzt baut
restricted/reiter.php alle drei; umgeschaltet wird von Bootstrap, den offenen
Reiter merkt reiterMerken() in der Adresse.

Kachelwerte: was auf einer Kachel steht, stand fest in rooms.php. Die neue
Tabelle kachelWerte (solarLog_kacheln.sql) ueberschreibt das je Raum, die
Maske waehlt die Messwerte aus demselben Katalog wie der Automatik-Editor -
nach Etage sortiert, die Geraete des eigenen Raums oben. Fehlt die Tabelle,
zeigt jede Kachel weiter ihre Vorgabe.

Geraete: die Zuordnung Geraet -> Raum war ein Modal, erreichbar ueber ein
Zahnrad neben den Automatismen. Sie ist eine Einstellung und steht jetzt dort,
zusammen mit dem, was fehlte: seit wann ein Geraet nichts mehr meldet, in
welchen Automatiken es steckt, und ein Loeschknopf. Letzterer ist noetig, weil
der Suchlauf nichts loescht - er legt an und frischt auf, damit die Automatiken
ihre Zustands-Ids behalten; ein abgebautes Geraet bliebe sonst fuer immer
stehen. Geloescht wird nur, was in keiner Automatik vorkommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:45:24 +02:00
adminandClaude Opus 5 1b58579c22 Neue Shellys ueber MQTT statt ueber HTTP erkennen
Die Shelly-Erkennung schrieb bisher fuer jedes Geraet eine HTTP-Adresse in
actor_states.url; der Automatik-Runner musste die Werte deshalb im Takt
abfragen, obwohl die Gen2-Geraete ihren Zustand ohnehin von sich aus an den
Broker melden. Sichtbar wurde das an den beiden Stromzaehlern: ihre Messwerte
standen nirgends als Topic, weshalb sie weder in den Kachelwerten noch im
Automatik-Editor auftauchten.

Gen2-Geraete werden jetzt nach ihrer MQTT-Einrichtung gefragt
(/rpc/Mqtt.GetConfig). Ist sie eingeschaltet, meldet das Geraet Statusaenderungen
und hat ein Topic-Praefix, so steht in url das Topic
(<praefix>/status/<komponente>:<index>) und in value_path der Schluessel in der
Nachricht. Fehlt eines davon - und bei allen Gen1-Geraeten, die kein MQTT
koennen - bleibt alles beim HTTP-Weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:45:06 +02:00
adminandClaude Opus 5 cca452806f Vorschaubild-Ordner der Synology ignorieren
Sie legt @eaDir neben jedem Bild an, in assets/img also bei jedem Symbol.
Das sind ihre Miniaturen und kein Inhalt des Projekts - sie tauchten bei
jedem git status als unversionierte Ordner auf. @SynoResource kommt aus
derselben Ecke und ist gleich mit aufgenommen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:11:07 +02:00
adminandClaude Opus 5 0cb0d4c3ae Ladestand ins Fahrzeugbild, Jahresstatistik als Vergleichstabelle
Drei Teile, die zusammengehoeren: die Fahrzeugkachel zeigt den Ladestand
jetzt dort, wo er im Auto auch sitzt, die Jahresstatistik wird zur
Tabelle, und beide holen ihr Aussehen aus einer gemeinsamen Farbwelt.

Fahrzeugkachel. Die Kachel "Batterie und Ladung" ist aufgeloest; ihr
Inhalt steckt in der Draufsicht. Die Akkuplatte im Fahrzeugboden fuellt
sich von hinten nach vorn, traegt die Zielmarke und nennt Prozent und
Reichweite in ihrer Mitte - auf dem Goldenen Schnitt der Plattenhoehe,
damit sie nicht am Klimaknopf klebt. Beim Laden wandern Streifen im
gefuellten Teil nach vorn, und vom Anschluss fliesst eine Linie in die
Platte. Was sich nicht zeichnen laesst - Ladeleistung, Restzeit,
Kilometerstand, Klima, Herkunft des Ladestroms - steht als Kennzahl
daneben, dazu die gefahrenen Kilometer aus Woche, Monat und Jahr
(?was=strecke, gerechnet aus dem Zaehlerstand statt aus erkannten
Fahrten - faellt eine Zeile aus, fehlt die Fahrt in der Liste, im
Zaehlerstand aber nicht).

Am Fahrzeug selbst: kein Panoramadach, das hat unseres nicht. Reling und
Spoiler neu gezeichnet, weil sie mit der Dachplatte bisher ein
geschlossenes Rechteck bildeten und das wie ein Fensterrahmen aussah. Die
Blende am Bug ist schmaler und heller - von oben sieht man von einer
senkrechten Flaeche nur einen Streifen, und fast schwarz las sie sich wie
ein Loch. Die Ladeklappe ist gebaut wie der Klimaknopf; den Ladezustand
traegt jetzt die Farbe statt einer zweiten Form.

Jahresvergleich. getStats.php liefert kein fertiges HTML mehr, sondern
JSON, das sich selbst beschreibt: Einheit, Nachkommastellen, Richtung und
Zugehoerigkeit stehen als Metadaten je Kennzahl, nicht mehr als zwei
Arrays, die ueber den Spaltenindex mit der Abfrage synchron gehalten
werden mussten. Zwei Abfragen holen alles - Monatswerte, daraus die
ganzen Jahre und die Summe seit Aufzeichnungsbeginn, dazu der gleiche
Zeitraum jedes Jahres bis zum heutigen Kalendertag. Verhaeltniszahlen
sind auf jeder Ebene neu aus den Summen gerechnet; ein Jahresmittel ist
nicht das Mittel der Monatsmittel.

Gezeichnet wird das von einem Baustein, den sich Historien- und
Solarseite teilen: dort drei Jahresspalten mit verschiebbarem Fenster bis
zurueck zum ersten Jahr, hier eine Spalte ohne Werkzeuge. Zeilen, die
sich aufteilen, stehen als "davon" darunter; was bereits in einer anderen
Zeile steckt, als "darin" samt Namen der Zeile - der Beitrag der Batterie
und die Solarladung des Autos duerfen nicht zum Ertrag addiert werden.
Zusammengefasste Zeilen sind zunaechst zu, damit die Tabelle mit zehn
Zeilen aufmacht statt mit siebzehn.

Farbwelt. Statt Regel fuer Regel zu ueberschreiben, ist die Palette
ausgetauscht: ein paar Variablen am Wurzelelement, an die Bootstrap und
AdminLTE ohnehin gehen. Anthrazit als Grund, eine Spur hellere Flaeche
fuer Karten, Haarlinien statt Rahmen und Schatten, Bernstein als einzige
Akzentfarbe - auf dieser Seite die Farbe der eigenen Anlage. Gruen, Rot
und Gelb bleiben den Zustaenden vorbehalten.

Erklaerungen, die bisher nur im title standen, sind antippbar: am Finger
gibt es kein Ueberfahren, und ein title erscheint dort nie.

Die beiden neuen Abfragen sind gegen solarLog gelaufen, die Monatswerte
decken sich mit einer unabhaengigen Zerlegung. grundpreisImJahr() ruft
jetzt grundpreisImZeitraum() auf - dieselbe Rechnung, nur auch fuer einen
Teil des Jahres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:08:51 +02:00
28 changed files with 5086 additions and 827 deletions
+5
View File
@@ -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
-71
View File
@@ -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
View File
@@ -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
View File
@@ -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"] ?? "")]);
+47
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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,
-52
View File
@@ -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
View File
@@ -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);
+2 -3
View File
@@ -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
View File
@@ -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)
+465
View File
@@ -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 };
}
+878
View File
@@ -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, "&quot;");
}
/* --- 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
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+432
View File
@@ -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;
}
+128
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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&shy;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
View File
@@ -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">
+26
View File
@@ -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;