Compare commits

...
32 Commits
Author SHA1 Message Date
adminandClaude Opus 5 59d2a4a55e Automatiken: Rahmen am Vorabend fuer den folgenden Tag
Neuer Schalter "Am Vorabend" im Wann-Band (Spalte next_day). Wochentage,
Ferien und Feiertage gelten dann fuer morgen: "Kinderrollos zu, wenn morgen
Schule ist" ist Mo-Fr, Ferien nie, Feiertage nie - wie der Wecker. Mit dem
heutigen Tag ging das am letzten Ferientag, am Abend vor einem Feiertag und
am Abend eines Feiertags daneben.

Editor-Satz, Kurzfassung und Uebersicht nennen den Vorabend vorne, damit
auch "nicht an Feiertagen" als Folgetag gelesen wird. Die Spalte legt
vorabend.sql im SolarManager an, ausgewertet wird sie im Runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:33:59 +02:00
adminandClaude Opus 5 deb81045d5 Skoda: Schluessel in den Einstellungen, Ladungen am Stueck, Wallbox in kW
- neuer Reiter "Fahrzeug": API-Schluessel eintragen und pruefen, ohne dass
  ein Wert je an den Browser zurueckgeht (restricted/skodaKeys.php)
- ajax/skoda.php trennt Ladungen am Zustand statt an 30 Minuten Pause und
  rahmt sie mit der Zeile davor und danach ein
- solarLog_skoda_ladepunkte.sql: Wallbox-Verlauf waehrend einer Ladung
- Kachelformat "leistung_kw" fuer Quellen, die schon Kilowatt melden
- Steckersymbol versteht evPlug als 1/0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:30:18 +02:00
adminandClaude Opus 5 b01e938905 Automatik-Editor als drei Baender, mit laufenden Werten
Das Akkordeon schloss beim Oeffnen die anderen Faecher - Ausloeser und
Aktion waren also nie gleichzeitig zu sehen, ausgerechnet bei einer
Regel, die genau daraus besteht. Und das mittlere Fach hiess
"Bedingungen", enthielt aber die Rahmenbedingungen; die echten standen
im ersten. Jetzt drei sichtbare Baender: Wenn, Wann, Dann, gebaut aus
.card mit farbigem Rand und .card-header.

Die Bedingungszeile magert von fuenf gleich schweren Kaesten auf zwei
ab. Das Geraet steht klein und matt ueber dem Messwert - es beantwortet
"woher", nicht "was" -, der Messwert ist Text mit gepunkteter Linie,
und nur Vergleich und Wert sehen noch wie Eingabefelder aus. Dort
steckt die Aussage der Bedingung, dort tippt man auch. Die Einheit
sitzt im Wertfeld statt in einem sechsten Kasten, der Papierkorb
erscheint erst beim Ueberfahren.

Neben jeder Bedingung laeuft ihr aktueller Wert mit. ?action=werte
liefert alle acht Sekunden {state_id: {value, unit}} aus actor_states -
bewusst von dort und nicht ueber MQTT, weil dort nur ein Teil der
Geraete auftaucht: die Jalousien haengen an der Tahoma-Box, die
gerechneten Werte an gar nichts. Das ersetzt den Satz, der frueher am
Ende stand und die Regel Wort fuer Wort nacherzaehlte: statt zu
wiederholen, was darueber steht, beantwortet die Zeile die Frage, die
man wirklich hat - warum laeuft die Automatik gerade nicht?

Bei time, date, datetime, deltatime und elapsed faellt der Browser
bewusst kein Urteil. Ob "Uhrzeit um 07:30" zutrifft, haengt am
Nachholfenster, am Tagesrand und bei elapsed am echten Abstand seit der
letzten Ausloesung - das steht im Runner. Es hier nachzubauen hiesse,
eine zweite Wahrheit zu pflegen, die auseinanderlaeuft. Diese Zeilen
bekommen einen gestrichelten Punkt statt einer Aussage.

Der Takt malt nur nach, statt neu zu bauen: renderConditions() ersetzt
den ganzen Block, damit waeren alle acht Sekunden eine offene
Geraeteliste weg und der Cursor aus einem Wertfeld, in das man gerade
tippt.

Der Rahmen steht zusammengefaltet da, solange nichts von der Vorgabe
abweicht - und das ist der Normalfall; vorher nahm er die meiste
Flaeche fuer den seltensten Inhalt. Aufgeklappt tauschen die drei
Vorlagen (taeglich, werktags, Wochenende) mit der Kurzfassung den
Platz. Sie stehen ausserhalb des Elements mit data-bs-toggle, und das
ist kein Zufall: Bootstrap haelt seinen Umschalter am Dokument in der
Capture-Phase, ein stopPropagation() im Knopf kaeme grundsaetzlich zu
spaet.

Getragen wird das von vier Variablen in solar.css, damit der Rest der
Seite mitzieht statt hinterherzuhinken: --ton-radius auf .875rem,
--ton-radius-klein neu auf .5rem (beide an Bootstraps
--bs-border-radius und -sm), --font-anzeige auf Poppins fuer alle
Ueberschriften. Poppins lag seit jeher unter assets/fonts und trug
bisher die vier Etagenknoepfe im Aussenplan - eine ganze Schrift fuer
vier Buchstaben. Fliesstext und Zahlenkolonnen bleiben beim Theme, weil
Poppins keine Tabellenziffern mitbringt.

Drei neue Klassen stehen absichtlich in solar.css und nicht im Editor.
.feld-als-text ist ein Auswahlfeld, das wie Text aussieht, bis man es
anfaehrt - dieselbe Not haben das Raum-Modal und die
Kachel-Einstellungen. .punkt ist an oder aus. .wert-jetzt ist die eine
echte Ausnahme und als einzelne Klasse auch als solche erkennbar. Der
Satz im Wann-Band braucht gar keine: .callout gibt es in AdminLTE, und
seine Toene kommen aus Bootstraps *-bg-subtle-Variablen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:08:50 +02:00
adminandClaude Opus 5 38e6c3f96b Wetterstation erkennen, Einheiten nicht mehr ueberschreiben
Die Station haengt laengst am Websocket und wsMQTTbridge.py legt ihre
Felder unter weatherStation/# ab - nur wusste die Automatikdatenbank
nichts davon. Das neue Modul macht daraus ein Geraet mit elf
Messwerten: Aussentemperatur, Taupunkt, Feuchte, Druck, Wind, Boe und
die beiden Richtungen. Wie das Gartenwasser- und das
SolarManager-Modul sucht es nichts, sondern schreibt ein festes Geraet
hin; die Station meldet sich nicht per Home-Assistant-Discovery.

Gebraucht wird sie fuer den Hitzeschutz: dafuer ist die
Aussentemperatur die bessere Bedingung als die Raumtemperatur - ist es
drinnen schon warm, ist es zum Verschatten zu spaet. Und Wind gab es
hier bisher ueberhaupt nicht; ohne ihn laesst sich fuer Markise und
Sonnensegel kein Sturmschutz bauen.

Nicht dabei sind zwei Topics. IP ist keine Messung. Und QNH steht bei
uns gut hundert Hektopascal neben qff (1133 gegen 1023) und ist
offenbar nicht auf die Stationshoehe eingestellt - zwei Druecke
nebeneinander, von denen einer falsch ist, waeren im Editor eine Falle.

Dabei aufgefallen: der UPSERT schrieb unit = VALUES(unit)
bedingungslos. Ein Modul, das keine Einheit mitliefert, meint aber
"weiss ich nicht" und nicht "loesch die vorhandene" - am 10.09. hat ein
Suchlauf den drei Sonnensensoren ihr "Lux" genommen, weil das
Tahoma-Modul keine Einheiten kennt. Jetzt COALESCE, wie bei
current_value schon laenger.

config.ini liegt nicht im Git; der Abschnitt [wetterstation] muss dort
von Hand stehen, sonst bleibt das Modul aus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:08:10 +02:00
adminandClaude Opus 5 b607d4a064 Automatiken als Ausloeser: Editor, Uebersicht, Loeschen
Gegenstueck zur Runner-Seite. Der Editor selbst braucht nichts: das
gerechnete Geraet "Automatiken" fuehrt jede Automatik als Messwert, und
der Geraetewaehler listet ohnehin Geraete und deren Messwerte. Neu sind
nur die Operatoren des Datentyps `elapsed` ("+", "ab +", "vor +") und
sein Eingabefeld.

Drei Stellen wissen trotzdem davon:

pruefeKreis() lehnt beim Speichern ab, was sich mittelbar selbst
ausloesen wuerde. Im Betrieb waere ein Kreis kaum zu bemerken - die
Sperrzeit begrenzt ihn auf eine Ausloesung je lockout_secs, und im
Protokoll sieht das aus wie eine Automatik, die halt oft laeuft.

deleteAutomation() fragt vorher. fk_cond_state steht auf ON DELETE
CASCADE, mit dem Messwert verschwaende also still die Bedingung des
Nachfolgers: aus "zehn Minuten nach dem Wecker UND es ist hell" wuerde
ein blosses "es ist hell", und der Rollladen fuehre ab morgen jeden Tag
bei Sonnenaufgang hoch. Bei der letzten Bedingung faengt der Runner das
ab, bei einer von zweien niemand. Zur Wahl stehen Abhaengen (Bedingung
raus, Nachfolger pausiert) und Mitloeschen; automatisch mitzuloeschen
waere die falsche Vorgabe.

Die Uebersicht rueckt Nachfolger unter ihren Ausloeser ein und zeigt sie
eingeklappt. Das ist nur die Darstellung - jeder Nachfolger bleibt eine
vollwertige Automatik mit eigener Pause, eigenen Rahmenbedingungen und
eigener Zeile im Protokoll. Sobald "gruppiert" auch "gehoert dazu"
hiesse, waere zu klaeren, wem die Pause gehoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:51:42 +02:00
adminandClaude Opus 5 c6a879b424 Spoiler ueber die Heckscheibe, Reifen wieder schwarz
Der Spoiler steht jetzt hinter der Heckklappe im Markup und liegt damit
ueber deren Scheibe - am Auto sitzt er auf der Dachkante und damit vor
dem Glas. Die Reling bleibt beim Dach, wo sie hingehoert.

Die Reifen sind wieder schwarz. Unter zwei getoenten Flaechen bleibt
davon wenig uebrig, vorn unter der Haube fast nichts - das ist so
gewollt: von oben saehe man sie ohnehin nicht, sie stehen im Bild,
damit die Achsen einen Ort haben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:55:41 +02:00
adminandClaude Opus 5 01bcbfffc1 Reling, Spoiler, Spiegel, Seitenscheiben und Kupplung nachgezogen
Die Relingholme enden hinten bei 3,79 m statt 3,98 m: am Auto laufen
sie ueber dem Radlauf aus, der Spoiler sitzt ein Stueck dahinter. Der
Spoiler selbst ist an den Enden stumpf abgeschnitten statt spitz - die
Spitzen gaben ihm die Form eines Blattes, am Auto hoert er schlicht auf.

Die Spiegel ruecken von 1,81 m auf 1,62 m, also an den Fuss der
A-Saeule, und die vorderen Seitenscheiben laufen bis 1,66 m nach vorn
mit. Vorher stand dort ein Stueck Blech, das es nicht gibt.

Die Anhaengerkupplung steht wieder hinten heraus - die Kugel rund neun
Zentimeter hinter der Heckkante, so wie am Auto. Dass das nicht wie ein
Loch im Umriss aussieht, liegt jetzt an der Farbe: ganz schwarz, damit
erkennbar ein Anbauteil und kein fehlendes Blech.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:53:45 +02:00
adminandClaude Opus 5 551b727256 Windschutzscheibe vor die Vordertuer, Fahrzeug in Lagen sortiert
Die Scheibe beginnt jetzt bei 1,37 m und damit vor der vorderen
Tuerfuge (1,46 m) - am Auto setzt sie schon ueber dem Kotfluegel an,
die A-Saeule erst weiter hinten. Die Motorhaube endet entsprechend bei
1,33 m statt 1,58 m, die Schnauze wird um einen Vierteilmeter kuerzer.
Der Klimaknopf rueckt auf die neue Scheibenmitte mit.

Das Markup ist ausserdem durchgehend als Schichtung geordnet, von
unten nach oben: Fahrwerk, Blech, Glas und Licht, Akku - die
Antriebsbatterie liegt jetzt ueber allem. Das geht auf, weil sie nur
ueber dem Radstand und nur von x 46 bis 178 liegt; Lichter, Fenster,
Tueren und Heckklappe liegen alle daneben.

Reifen und Anhaengerkupplung sind dadurch die unterste Lage und waren
zunaechst unsichtbar: fast schwarz unter zwei getoenten Flaechen. Sie
sind deshalb heller gezeichnet und tragen eine helle Kontur - dieselbe
Sprache wie die Akkuplatte, die auch nur als Umriss durch den Boden
scheint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:49:10 +02:00
adminandClaude Opus 5 8f040dec19 Fahrzeug auf die Werksmasse gebracht
Alle Masse nachgerechnet und gegen die Werksangaben gestellt. Massstab sind
104,97 Einheiten je Meter (4649 mm auf 488 Einheiten).

                     Zeichnung   Fahrzeug   Abweichung
  Laenge                  4649       4649        0 mm
  Breite Karosserie       1877       1879       -2
  Spiegelbreite           2147       2147        0
  Radstand                2763       2765       -2
  Spurweite               1581       1585       -4
  Reifendurchmesser        743        744       -1
  Ueberhang vorn           905        903       +2
  Ueberhang hinten         981        981        0

Der grosse Fehler war die Spur: die Raeder standen 1820 mm auseinander statt
1585 und ragten damit acht Einheiten ueber die Flanke hinaus. Sie stehen
jetzt innerhalb des Umrisses, wie am Auto - dort ist die Karosserie 1879 mm
breit und Spur plus Reifenbreite 1820 mm.

Damit sie trotzdem zu sehen sind, stehen sie im Markup hinter der Karosserie
statt davor: das Blech ist durchscheinend gezeichnet. Von oben saehe man die
Raeder in Wirklichkeit gar nicht - hier gilt dieselbe Freiheit wie bei der
Akkuplatte, die auch durch den Boden zu sehen ist.

Kleiner dazu: Vorderachse vier Einheiten nach hinten (903 statt 871 mm
Ueberhang), Karosserie um 26 mm schmaler, Spiegel um 32 mm breiter, und die
Bananenschale liegt weiter in der linken Radspur, die jetzt woanders ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:21:36 +02:00
adminandClaude Opus 5 b180e3ef98 Windschutzscheibe endet auf halber Vordertuer
Die A-Saeule stoesst im Foto etwa auf halber Laenge der Vordertuer ans Dach.
Gezeichnet reichte die Scheibe bis fast ans Tuerende - das Dach begann
dadurch einen guten Drittelmeter zu weit hinten und war entsprechend kurz.

Jetzt: Scheibenfuss 1,6 m, Dachanfang 2,15 m (y 232 statt 268), Dachende
unveraendert 4,0 m. Das Dach ist damit 1,87 m lang statt 1,52 m. Reling und
Aussenspiegel sind mitgezogen - die Spiegel sitzen am Fuss der A-Saeule, und
der wandert mit ihr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:14:07 +02:00
adminandClaude Opus 5 1451c5e42b Tueren beginnen dicht hinter dem Vorderrad
Zwischen Rad und Tuer stand eine halbe Handbreit Blech, die es am Auto nicht
gibt. Nachgemessen an der Seitenansicht: die vordere Tuerfuge liegt bei
1,52 m, das Vorderrad endet bei 1,24 m - ein knapper Vierteilmeter dazwischen.
Gezeichnet war die Fuge bei 1,75 m.

Damit verschiebt sich die ganze Staffelung: vordere Tuer 1,52 bis 2,59 m (die
Fuge an der B-Saeule), hintere von dort bis zum Radlauf. Am Auto reicht die
hintere Tuer noch ueber den Radlauf hinaus bis etwa 3,5 m; gezeichnet endet
sie davor, denn weiter hinten laege ihr Blech im Bild auf dem Rad. Das steht
so auch im Quelltext.

Die Seitenfenster sind mitgezogen: vorn von der A-Saeule bis zur B-Saeule,
hinten bis zum Ende der Tuer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:09:10 +02:00
adminandClaude Opus 5 df55a77d97 Reifen kantiger, Luft zwischen Klimaknopf und Ladestand
Die Raeder hatten fast halbrunde Enden (rx 11 bei 25 Einheiten Breite) und
sahen dadurch aus wie Kapseln. Von oben ist eine Lauffläche ein Rechteck mit
leicht gebrochenen Ecken - rx 5.

Der Klimaknopf sass in der Mitte der Windschutzscheibe und damit dicht ueber
der Prozentzahl des Ladestands; beide lasen sich als ein Block. Er sitzt
jetzt im oberen Drittel der Scheibe (cy 206 statt 220). Die Zahl bleibt, wo
sie ist: sie steht auf dem Goldenen Schnitt der Akkuplatte (108 + 270 * 0,618
= 275), und das war Absicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:05:28 +02:00
adminandClaude Opus 5 29742bf77c Raeder in echter Groesse, Tueren gestaffelt, Heckklappe mit Scheibe
Drei Sachen, alle aus dem Vergleich mit der Seitenansicht:

Die Raeder waren ein Viertel zu klein: gezeichnet 58 auf 21 Einheiten, am
Fahrzeug sind es 235/50 R20, also 744 mm Durchmesser auf 235 mm Breite oder
78 auf 25 Einheiten. Das Rad ist im Bild der einzige Gegenstand mit bekanntem
Mass - ist es zu klein, wirkt alles andere zu gross, allen voran die
Schnauze. Die ist naemlich richtig: bis zum Scheibenfuss sind es 1,6 m von
4,65 m Gesamtlaenge, am Auto knapp 1,8 m.

Die Tueren waren gleich lang. Sind sie nicht: die Trennfuge liegt bei 2,60 m,
die hintere Fuge bei 3,35 m, macht 85 zu 75 Zentimeter (89 zu 79 Einheiten).
Vorher waren beide 104 Einheiten lang und zusammen einen halben Meter zu
lang.

Heckscheibe und Heckklappe sind eine Schaltflaeche - die Scheibe sitzt in der
Klappe und geht mit ihr auf. Sie behaelt nur ihr Aussehen (.teil > .scheibe
im Stylesheet, und beim Oeffnen faerbt sie sich durchsichtiger als das
Blech). Erst dadurch konnte das Dach dorthin, wo es hingehoert: es endet
jetzt bei 4,0 m statt 3,8 m, am Auto sind es 4,15 m. Als getrennte Flaeche
waere die Klappe dabei auf drei Zentimeter geschrumpft und nicht mehr zu
treffen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:00:59 +02:00
adminandClaude Opus 5 5210ea242d Glashaus neu vermessen: Dach, Reling und Spoiler sassen zu weit vorn
Die Frage war berechtigt. Nachgemessen an der Seitenansicht, umgerechnet mit
105 Einheiten je Meter (der Wagen ist 4,65 m lang und 488 Einheiten):

                        vorher          jetzt       Fahrzeug
  Scheibenfuss          1,39 m          1,62 m      ~1,75 m
  Dachanfang            1,85 m          2,37 m      ~2,55 m
  Dachende              3,14 m          3,81 m      ~4,15 m
  Dachlaenge            1,30 m          1,44 m      ~1,55 m

Das ganze Glashaus sass also gut einen halben Meter zu weit vorn und war zu
kurz - und mit ihm Reling und Spoiler. Die Reling lief ausserdem nur ueber
zwei Drittel des Daches; sie geht jetzt von 262 bis 402, also fast von der
Windschutzscheibe bis zum Spoiler, wie am Auto.

Mitgezogen sind alle Teile, die am Glashaus haengen: Motorhaube, beide
Scheiben, die Tueren (jetzt 104 statt 74 und 66 Einheiten lang, nach den
echten Tuerbreiten), die Seitenfenster, die Aussenspiegel an den Fuss der
A-Saeule und der Klimaknopf in die Mitte der Windschutzscheibe.

Ganz massstaeblich ist es weiterhin nicht: das Dach endet am Auto erst bei
4,15 m. Dahinter blieben fuer Heckscheibe, Klappe und Stossfaenger zusammen
fuenf Zentimeter Bild, und die Heckklappe waere als Schaltflaeche nicht mehr
zu treffen. Die letzten vierzig Zentimeter sind deshalb gedehnt - eine
bewusste Abweichung, die im Quelltext auch so dasteht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:47:57 +02:00
adminandClaude Opus 5 a57ffe41cc Front stumpf, Kupplung innerhalb der Kante, Dachteile nach oben
Drei Dinge aus dem Abgleich mit dem Foto:

Die Front lief spitz zu wie das Heck vorher. Der Enyaq ist auch vorn stumpf:
eine Kante von 70 bis 154, davor die Tech-Deck-Blende als Band statt als
Bogen, die acht Leuchtelemente flacher und weiter aussen, die Motorhaube an
die neue Bugkante gezogen.

Die Anhaengerkupplung stand ueber die Heckkante hinaus und las sich wie eine
Unterbrechung des Umrisses. Sie sitzt jetzt vollstaendig innerhalb.

Dachreling und Heckspoiler lagen im Markup vor der Akkuplatte - also unter
ihr, und die Platte ist halbdurchsichtig: von den Holmen war fast nichts mehr
zu sehen. Sie stehen jetzt dahinter, wo sie hingehoeren (Reling und Spoiler
liegen auf dem Dach, die Batterie im Boden), und sind schwarz statt in
Blechfarbe - an diesem Fahrzeug sind sie es auch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:39:16 +02:00
adminandClaude Opus 5 b53e4fe7e3 Heck vom Fliessheck zum SUV
Das Fahrzeug lief hinten spiegelbildlich zur Front spitz zusammen - von oben
genau die Form eines Coupes. Der Enyaq ist aber der SUV:

  Karosserie   Die Flanken bleiben bis kurz vor den Schluss auf voller Breite
               (212 bei y=250, noch 206 bei y=452), dann runden zwei Ecken auf
               eine gerade Heckkante von 56 bis 168.
  Heckscheibe  steiler (336 bis 376 statt 382) und nach unten schmaler statt
               breiter - das Auslaufen nach hinten war das Fliessheck-Merkmal.
  Heckklappe   folgt der neuen Kontur, innen abgesetzt wie am Auto.
  Rueckleuchten sitzen in den Ecken und greifen von der Klappe auf die Flanke
               ueber, so wie das Band am Original um die Ecke laeuft. Weiter
               innen sahen sie aus wie zwei Griffe in der Klappe.

Dazu die Anhaengerkupplung, die dieses Fahrzeug hat: Hals und Kugel, das
einzige Teil, das ueber die Heckkante hinaussteht - Platz dafuer war im
viewBox unten schon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:31:00 +02:00
adminandClaude Opus 5 ea98d60435 Breitere Fahrbahn, Schale in der Radspur
Drei Anpassungen am Fahrbild:

Die Zeichenflaeche ist 15 % breiter (viewBox 294,4 statt 256, mittig um den
Wagen), und die max-width waechst mit auf 297 px - das Fahrzeug bleibt also
gleich gross, es kommt nur Platz daneben dazu. Die beiden Markierungen
ruecken um dieselben 15 % nach aussen (x -23,7 und 247,7), damit zwischen Rad
und Fahrbahnrand Luft fuer den Fahrtwind bleibt.

Die Markierung ist 4,2 statt 3,15 breit und etwas heller. Massstaeblich waere
3,15; so hebt sie sich aber deutlich vom Fahrtwind ab, der mit 2,5 daneben
laeuft - und darum ging es.

Die Bananenschale liegt jetzt in der linken Radspur (x 16, die Raeder stehen
zwischen 5 und 26) statt frei auf der Fahrbahn. Unter dem Reifen hat sie ihre
Wirkung, und dass der Wagen danach nach rechts ausbricht, passt zur Seite, auf
der er den Halt verliert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:23:29 +02:00
adminandClaude Opus 5 9cfc0107df Ostergruss: eine Bananenschale auf der Fahrbahn
Ganz selten liegt waehrend der Fahrt eine Bananenschale auf der Strasse, und
der Wagen kommt kurz ins Schleudern - erst bricht er nach rechts aus, dann
faengt ihn das Gegenlenken, dann trudelt er aus.

Der Vorlauf der zweiten Bewegung ist gerechnet, nicht geschaetzt: die Schale
startet 40 Einheiten ueber dem Bild, die Vorderachse liegt bei 97, macht 137
Einheiten - bei 437 Einheiten je Sekunde sind das 0,31 s. Genau dann setzt
das Schleudern ein.

Damit das Fahrzeug sich drehen kann, ohne die Strasse mitzunehmen, liegt es
jetzt in einer eigenen Gruppe (#wagen); die Schale sitzt im Markup davor und
verschwindet unter der Karosserie, sobald der Wagen darueberrollt.

Ausgeloest wird alle 20 Sekunden mit zwei Prozent Wahrscheinlichkeit und nur
waehrend der Fahrt - im Mittel einmal in gut sechzehn Minuten. Selten genug,
dass man es nicht erwartet; haeufig genug, dass es irgendwann jemand sieht.
Wer Bewegungen abbestellt hat, bekommt den Gruss gar nicht erst: ein
schleuderndes Auto ist genau das, was damit gemeint ist. Haelt der Wagen
mitten im Ausrutscher an, wird die Schale eingesammelt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:13:45 +02:00
adminandClaude Opus 5 62c9b49073 Fahrtwind schmaler und weg von der Fahrbahnmarkierung
Die seitlichen Streifen liefen genau auf der Linie der Markierung und sahen
dort aus wie eine zweite, gestrichelte Begrenzung statt wie Luft. Sie sitzen
jetzt zwischen Rad und Markierung (x 0 und 224 statt -6 und 230), und alle
vier sind schmaler: 2,5 statt 4 neben dem Wagen, 2 statt 3 darueber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:07:29 +02:00
adminandClaude Opus 5 e525731264 Strassenmarkierung und Tempo noch einmal halbiert
Die Striche waren immer noch zu praesent. Jetzt liegt die Strasse im Viertel
des Fahrzeugmassstabs: Leitlinie 157,5 auf 315 Luecke, 3,15 breit, rechts
weiterhin durchgezogen. Das Verhaeltnis 1:2 bleibt, die Markierung ist
Beiwerk statt Hauptsache.

Das Tempo ist mit halbiert - 437 statt 875 Einheiten je Sekunde. Weil auch
die Strichlaenge halbiert ist, ziehen die Striche im selben Takt vorbei wie
zuvor; die Bewegung selbst ist ruhiger. Der Fahrtwind laeuft weiterhin genau
so schnell wie die Fahrbahn (nachgemessen: 437 gegen 436 Einheiten je
Sekunde).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:05:02 +02:00
adminandClaude Opus 5 592a134a1e Strassenmarkierung im halben Massstab
Massstaeblich waren die Striche zu gross fuers Bild: 12,6 Einheiten breit und
630 lang, das fuellte die Raender und zog den Blick vom Fahrzeug weg.

Jetzt ist die Strasse im halben Massstab des Wagens gezeichnet - Leitlinie
315 auf 630 Luecke, 6,3 breit. Das Verhaeltnis 1:2 bleibt, die Markierung
liest sich weiter als Leitlinie, und das Fahrzeug ist wieder das Groesste im
Bild. Rechts bleibt die Fahrbahnbegrenzung durchgezogen.

Die Geschwindigkeit bleibt bei 875 Einheiten je Sekunde. Im halben Massstab
sind das gut 60 km/h statt der 30 zuvor - fuer eine Landstrasse die
passendere Zahl, und der Takt der vorbeiziehenden Striche bleibt derselbe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:01:34 +02:00
adminandClaude Opus 5 b7c2bc82c7 Strassenmarkierung massstaeblich statt Baumreihe
Die Baumkronen neben der Fahrbahn sahen aus wie Erbsen und nicht wie eine
Allee - sie sind raus. Stattdessen ist die Markierung jetzt richtig.

Massstab: der Wagen ist 4,65 m lang und misst im Bild 488 Einheiten, eine
Einheit ist also knapp ein Zentimeter (105 je Meter). Danach richten sich
alle Masse:

  rechts  Fahrbahnbegrenzung, durchgezogen
  links   Leitlinie, 6 m Strich auf 12 m Luecke (630 auf 1260 Einheiten)
  beide   0,12 m breit, also 12,6 Einheiten

Der Strich ist damit laenger als das Bild hoch ist, und zwischen zwei
Strichen liegt mehr als eine Bildhoehe - genau so sieht eine Landstrasse aus
dem Auto auch aus. Sichtbar ist eine Markierung dadurch etwa zwei Drittel der
Zeit.

Die Geschwindigkeit ist ebenfalls abgeleitet und nicht geraten: 875 Einheiten
je Sekunde sind gut 30 km/h. Der Fahrtwind laeuft jetzt genauso schnell - ein
Streifen, der die Fahrbahnmarkierung ueberholt, faellt sofort auf.

Nicht massstaeblich bleibt die Breite der Fahrbahn: neben dem Wagen sind
zwanzig Zentimeter Platz statt der drei Viertel Meter einer echten Spur.
Dafuer muesste das Fahrzeug halb so gross gezeichnet werden, und darum geht
es auf dieser Kachel nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:57:55 +02:00
adminandClaude Opus 5 b0596721fa Fahrzeugbild zeigt die Fahrt: Strasse, Baeume, etwas Fahrtwind
Ob das Auto gerade rollt, stand bisher nur klein unter der Karte
("unterwegs - zuletzt: ..."). Im Bild selbst, wo man zuerst hinsieht, war es
nicht zu erkennen.

Waehrend der Fahrt liegt jetzt eine Strasse unter dem Wagen: dunkler Belag,
gestrichelte Markierungen in den Randstreifen neben den Raedern, zwei Reihen
Baumkronen, die aussen vorbeiziehen. Dazu vier Windstreifen - zwei neben dem
Wagen, zwei ueber ihn hinweg, heller und duenner, damit sie wie Luft wirken
und nicht wie ein Riss im Lack.

Beide Schleifen treffen sich selbst: die Striche wandern um eine
Strich-Lueckenlaenge (46), die Baumreihen um ihren Abstand (160) - danach
steht das Muster wieder wie zuvor, und man sieht keinen Sprung. Markierungen
und Baeume laufen mit derselben Geschwindigkeit, der Wind etwa anderthalbmal
so schnell; groessere Unterschiede lassen die Bewegungen auseinanderfallen.

Dazu ein Abzeichen "unterwegs" im Kopf der Kachel - fuer Vorleseprogramme und
fuer alle, die Bewegungen abgeschaltet haben; dann steht die Landschaft still
(prefers-reduced-motion).

Der Zustand kommt aus park_state der juengsten Zeile; der Broker kennt ihn
nicht, unter solarManager/ev* stehen nur Ladestand, Reichweite und Ladung.
Unterwegs fragt der Sammler alle vier Minuten, die Seite laedt jede Minute
nach - die Anzeige hinkt also um wenige Minuten hinterher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:50:13 +02:00
adminandClaude Opus 5 20e084bbc8 Shelly: nur Komponenten ueber MQTT, die auch senden
"status_ntf" am Geraet heisst nur, dass gesendet werden darf - nicht, dass
jede Komponente es tut. Der erste Suchlauf hat deshalb auch die
Temperaturkomponente der beiden Pro 3EM auf ein Topic gelegt
(Power_EG/status/temperature:0), und dort kam nie etwas an: unter Power_EG/#
sendet das Geraet ausschliesslich em:0, emdata:0 und online. Die beiden
Temperaturmesswerte waeren still auf ihrem letzten Wert stehengeblieben.

Jetzt entscheidet die Liste MQTT_KOMPONENTEN, welche Komponente ein Topic
bekommt - heute em und switch. Alles andere bleibt beim Abfragen: langsamer,
aber richtig. Nachgetragen wird per Hand, nachdem man mitgehoert hat; eine
automatische Erkennung waere unzuverlaessig, weil eine Komponente, die nur
bei Aenderung sendet, waehrend eines kurzen Lauschens nichts sagt.

Nach dem erneuten Suchlauf: die beiden EM-Zaehler kommen ueber den Broker,
die beiden Temperaturen und der Gen1-Handtuchtrockner werden abgefragt -
HTTP: 3 Messwerte an 3 Endpunkten statt vorher 35.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:15:16 +02:00
adminandClaude Opus 5 0fd73499d9 README: Aufbau und Datenwege des Dashboards
Das Repo hatte keine. Wer hier etwas suchte, musste sich von index.php aus
durchhangeln - und die Frage "welches Skript laedt eigentlich welche Daten"
war nur zu beantworten, indem man alle elf durchsah.

Die Datei beschreibt den Weg einer Anfrage (Router, Anmeldung, Vorlage,
Skripte), die Trennung der beiden Datenwege (laufende Werte ueber den Broker,
alles mit Verlauf ueber ajax/), die Seitenkarte mit Skripten, Endpunkten und
Topics je Seite, die JavaScript- und PHP-Schicht mit ihren Zustaendigkeiten,
die drei Datenbanken und vier Bausteine im Detail: Home-Kacheln, Raum-Modal,
Automatiken, Geraete-Erkennung. Dazu sieben Mermaid-Diagramme - Ablauf,
Datenwege, Abhaengigkeiten, Include-Graph, Tabellen, Kachelaufloesung,
Kommandoweg.

Am Ende zwei Tabellen fuer den taeglichen Gebrauch: "wo fange ich an, wenn
ich X aendern will" und die Pruefbefehle (php82 -l, node --check, readDB).

Gegenstueck ist die README im SolarManager-Repo; beide verweisen aufeinander,
damit man von jeder Seite aus zur anderen findet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:28:42 +02:00
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
35 changed files with 9549 additions and 1173 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
+786
View File
@@ -0,0 +1,786 @@
# Smart-Dashboard
Die Anzeige und Bedienung des Hauses: Grundrisse mit Messwerten, Solar- und
Verbrauchsverläufe, das Fahrzeug, die Automatiken, die Einstellungen. Alles,
was einen offenen Browser braucht, liegt hier.
Gegenstück ist das Repo **`admin/SolarManager`** unter
`/volume1/homes/wagner/SolarManager` — dort laufen die Hintergrundprozesse,
die Messwerte einsammeln und die Automatiken ausführen. Beide Seiten reden
über zwei Dinge miteinander und nie direkt: den **MQTT-Broker** für alles
Aktuelle, die **Datenbanken** für alles, was bleiben soll. Wer wissen will,
woher eine Zahl ursprünglich kommt, liest die README dort; hier steht, was
mit ihr im Browser passiert.
Dieses Verzeichnis ist zugleich der Web-Root der NAS
(`/volume1/web/smart`, ausgeliefert unter `https://nas.el-wa.org/smart`).
Der Arbeitsordner *ist* die laufende Anwendung — jede gespeicherte Datei ist
sofort live.
---
## Inhalt
1. [Auf einen Blick](#auf-einen-blick)
2. [Wie eine Seite entsteht](#wie-eine-seite-entsteht)
3. [Zwei Wege für Daten](#zwei-wege-für-daten)
4. [Die Seitenkarte](#die-seitenkarte)
5. [Die JavaScript-Schicht](#die-javascript-schicht)
6. [Die PHP-Schicht](#die-php-schicht)
7. [Die Datenbanken](#die-datenbanken)
8. [Vier Bausteine im Detail](#vier-bausteine-im-detail)
9. [Wiederkehrende Bauteile und Konventionen](#wiederkehrende-bauteile-und-konventionen)
10. [Wo fange ich an, wenn ich … ändern will](#wo-fange-ich-an-wenn-ich--ändern-will)
11. [Entwickeln und Prüfen](#entwickeln-und-prüfen)
---
## Auf einen Blick
```
smart/
├── index.php Router: Anmeldung prüfen, Seite auswählen, drei Includes
├── helper.php Session, Heimnetzerkennung, checkLogin(), httpGetJson()
├── authServer.php Passkeys (WebAuthn): Registrierung und Anmeldung
├── addUser.php Einmal-Link zum Anlegen eines neuen Schlüssels
├── .htaccess sperrt .git, *.py, *.sql, *.ini … für den Browser
├── restricted/ Vorlagen (was man sieht) und Modelle (was es weiß)
│ ├── header.php footer.php Rahmen jeder Seite, Menü, Skript-Einbindung
│ ├── home.php solar.php heat.php history.php
│ ├── skoda.php weather.php settings.php logs.php ← die acht Seitenvorlagen
│ ├── rooms.php Räume, Etagen, Kachelwerte, Symbole
│ ├── costs.php Preistabellen + solarDb()
│ ├── automations.php Automatiken, Gerätekatalog + meshDb()
│ ├── kacheln.php Anzeigewerte der Home-Kacheln (Modell zur Maske)
│ ├── roomControls.php Bedienelemente des Raum-Modals
│ ├── commands.php ein Kommando tatsächlich abschicken
│ ├── wallboxen.php beide Wallboxen über MQTT
│ ├── logdateien.php Logdateien lesen
│ ├── aussenplan.php Schrägbild des Außengeländes (SVG, gerechnet)
│ ├── reiter.php das eine Reiter-Bauteil der ganzen Seite
│ ├── config.php Zahlen, die an der Wirklichkeit hängen
│ ├── mysql.php Zugangsdaten — NICHT im Git (mysql.php.example)
│ └── deviceDiscovery/ Python: Geräte suchen und in homeMesh eintragen
├── ajax/ Endpunkte. Verteiler, keine Logik (Ausnahmen unten)
├── js/solar/ der eigene JavaScript-Code
├── js/ Fremdbibliotheken, alle lokal (kein CDN)
├── css/solar.css das eigene Stylesheet (Theme + alle Bauteile)
├── assets/img/ Grundriss-Renderings OG/EG/UG/AG, Icons
├── tiles/ Kartenkacheln-Zwischenspeicher (nicht im Git)
└── *.sql Schema-Dateien zum Nachziehen von Hand
```
---
## Wie eine Seite entsteht
```mermaid
sequenceDiagram
autonumber
participant B as Browser
participant I as index.php
participant H as helper.php
participant T as Vorlage + Modelle
participant F as footer.php
B->>I: GET index.php?action=home&floor=OG
I->>H: checkLogin()
H-->>I: Heimnetz? sonst Passkey-Sitzung
Note over I: $pages[...] wählt Vorlage,<br/>Bibliotheken und Seitenskripte
I->>T: include header.php
T-->>B: Kopf und Menü, gebaut aus $floors
I->>T: include restricted/(seite).php
T-->>B: Karten, SVG, eingebettete JSON-Daten
I->>F: include footer.php
F-->>B: Bootstrap, ggf. mqtt.js/Chart.js/Leaflet,<br/>common.js, dann die Seitenskripte
B->>B: Skripte verbinden sich mit Broker und holen per fetch nach
```
**Anmeldung.** `helper.php` kennt zwei Wege. Aus dem Heimnetz
(`LOCAL_NETWORKS`, geprüft binär über `inet_pton`, ausschließlich anhand von
`REMOTE_ADDR` — Weiterleitungs-Header sind fälschbar und werden bewusst
ignoriert) ist man ohne Anmeldung drin. Von außen zählt ein Passkey:
`authServer.php` mit der WebAuthn-Bibliothek unter `restricted/WebAuthn`,
Schlüssel in der Datenbank `Logins`. Ein neuer Schlüssel entsteht nur über
einen Einmal-Link (`addUser.php`, Tabelle `addUser`, eine Minute gültig).
**Die Seitentabelle** in `index.php` ist die einzige Stelle, an der steht,
welche Seite es gibt und was sie braucht:
```php
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false,
"meteogram" => false, "leaflet" => false,
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
```
`footer.php` lädt daraus die Bibliotheken — Chart.js nur wo gezeichnet wird,
mqtt.js nur wo Livewerte laufen, Leaflet nur auf der Fahrzeugseite. Danach
kommt immer `common.js` und dann die Seitenskripte, jeweils mit
`?v=<filemtime>`, damit ein Browser nie eine alte Fassung behält.
**Vorlage und Modell sind getrennt.** Eine Seitenvorlage baut Markup und ruft
dafür Funktionen aus den Modelldateien; die Modelldateien enthalten kein
Markup außer dort, wo sie ein Bedienelement zeichnen (`roomControls.php`,
`reiter.php`). Die Endpunkte in `ajax/` benutzen dieselben Modellfunktionen —
so kann eine Maske nicht etwas anderes behaupten als der Endpunkt, der sie
speichert.
---
## Zwei Wege für Daten
```mermaid
flowchart LR
subgraph Browser
JS["js/solar/*.js"]
SVG["SVG-Grundriss<br/>Diagramme<br/>Masken"]
end
BROKER{{"MQTT-Broker<br/><small>wss://mqtt.nas.el-wa.org:443</small>"}}
AJAX["ajax/*.php"]
MODELL["restricted/*.php<br/><small>Modell-Schicht</small>"]
SOLARLOG[("solarLog<br/><small>Verlauf</small>")]
HOMEMESH[("homeMesh<br/><small>Geräte, Automatiken</small>")]
GERAETE["Geräte<br/><small>Tahoma · WLED · Shelly · Wallboxen</small>"]
BROKER -->|"laufende Werte"| JS
JS --> SVG
JS <-->|"fetch: Verlauf, Struktur, Masken"| AJAX
AJAX --> MODELL
MODELL --> SOLARLOG
MODELL --> HOMEMESH
MODELL -.->|"Kommandos"| BROKER
MODELL -.->|"HTTP · Tahoma · WLED"| GERAETE
classDef speicher fill:#eaf5ee,stroke:#6fa981
classDef code fill:#fff6e5,stroke:#d0a548
class SOLARLOG,HOMEMESH,BROKER speicher
class JS,AJAX,MODELL code
```
Die Regel dahinter, und sie gilt ohne Ausnahme:
| Was | Weg | Warum |
|---|---|---|
| Aktueller Messwert (Leistung, Temperatur, Ladestand) | **MQTT über `wss`, direkt vom Broker** | Er weiß es ohnehin, und er sagt es von selbst — kein Nachfragen im Takt |
| Verlauf, Statistik, Gerätelisten, Formulare | **`fetch` auf `ajax/*.php`** | Das weiß nur die Datenbank |
| Jeder Knopfdruck (schalten, speichern, Fahrzeugbefehl) | **immer über den Server** | Zugangsdaten dürfen die Seite nicht verlassen, und der Broker ist von außen nicht erreichbar |
Der Browser abonniert je nach Seite `solarManager/#`, `weatherStation/#`,
`wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` und — auf der Home-Seite —
genau die Topics, die auf den Kacheln stehen (`tileTopics()` in `rooms.php`).
---
## Die Seitenkarte
| Seite (`?action=`) | Vorlage | Seitenskripte | holt per fetch | hört auf MQTT |
|---|---|---|---|---|
| `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `solarMQTT.js` | `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` |
| `home` | `home.php` | `autoActionFuncs.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` |
| `heat` | `heat.php` | `heatMQTT.js` | `getHeaterData`, `getWaterData`, `getSunrise` | `solarManager/#`, `weatherStation/#`, Wallbox-Topics |
| `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — |
| `skoda` | `skoda.php` | `skodaMQTT.js` | `skoda.php?was=…`, `skodaCmd.php`, `tile.php` | `solarManager/#` |
| `weather` | `weather.php` | `weatherMQTT.js` | nichts — das Meteogramm ist ein fremdes Dokument im `<iframe>` (`js/meteogram.js`) | `weatherStation/#` |
| `settings` | `settings.php` | `settings.js` | `settings.php?action=…` | — |
| `logs` | `logs.php` | `logs.js` | `logs.php?action=…` | — |
Und dasselbe als Abhängigkeitsbild — von der Seite bis in den Speicher:
```mermaid
flowchart TB
subgraph S["Seiten"]
HOME["home"]; SOLAR["solar"]; HIST["history"]; HEAT["heat"]
SKODA["skoda"]; SET["settings"]; LOGS["logs"]; WEA["weather"]
end
subgraph J["js/solar"]
COMMON["common.js<br/><small>immer geladen</small>"]
HOMEJS["homeMQTT.js"]; AUTOJS["autoActionFuncs.js"]; SOLJS["solarMQTT.js"]
HISTJS["historyMQTT.js"]; STATJS["jahresstatistik.js"]; HEATJS["heatMQTT.js"]
SKODAJS["skodaMQTT.js"]; SETJS["settings.js"]; LOGJS["logs.js"]; WEAJS["weatherMQTT.js"]
end
subgraph A["ajax"]
ROOM["room.php"]; AUTOA["AutoAction.php"]; SETA["settings.php"]
STATS["getStats.php"]; EHIST["energyHistory.php"]; PROD["getProdData<br/>getConsData<br/>getForecastData"]
HEATA["getHeaterData<br/>getWaterData"]; SKODAA["skoda.php<br/>skodaCmd.php<br/>tile.php"]; LOGA["logs.php"]
end
subgraph M["restricted (Modell)"]
ROOMS["rooms.php"]; COSTS["costs.php"]; AUTOM["automations.php"]
KACH["kacheln.php"]; RCTRL["roomControls.php"]; CMD["commands.php"]; LOGD["logdateien.php"]
end
SOLARLOG[("solarLog")]; HOMEMESH[("homeMesh")]; DATEIEN[/"Logdateien"/]; BROKER{{"Broker"}}
HOME --> HOMEJS & AUTOJS
SOLAR --> SOLJS & STATJS
HIST --> HISTJS & STATJS
HEAT --> HEATJS
SKODA --> SKODAJS
SET --> SETJS
LOGS --> LOGJS
WEA --> WEAJS
S -.-> COMMON
HOMEJS --> ROOM
AUTOJS --> AUTOA
SOLJS --> PROD
HISTJS --> EHIST
STATJS --> STATS
HEATJS --> HEATA
SKODAJS --> SKODAA
SETJS --> SETA
LOGJS --> LOGA
ROOM --> RCTRL & CMD & AUTOM
AUTOA --> AUTOM
SETA --> COSTS & KACH & AUTOM
STATS --> COSTS
KACH --> ROOMS
ROOMS --> COSTS
LOGA --> LOGD
COSTS --> SOLARLOG
AUTOM --> HOMEMESH
CMD --> HOMEMESH
CMD -.-> BROKER
PROD --> SOLARLOG
EHIST --> SOLARLOG
HEATA --> SOLARLOG
SKODAA --> SOLARLOG
LOGD --> DATEIEN
classDef speicher fill:#eaf5ee,stroke:#6fa981
class SOLARLOG,HOMEMESH,DATEIEN,BROKER speicher
```
---
## Die JavaScript-Schicht
Kein Framework, kein Bundler. Jede Datei ist ein Skript, das der Browser so
lädt, wie es dasteht; geteilt wird über globale Funktionen aus `common.js`.
Die Namen sind deutsch, sobald es um die Sache geht — englisch nur dort, wo
eine Bibliothek es vorgibt.
### `common.js` — immer geladen
| Funktion | wofür |
|---|---|
| `powerToString(w)` | Leistung als `W` oder `kW`, dieselbe Rundung überall |
| `getData(chart, url, opt)` | Diagrammdaten holen, eintragen, alle 5 min auffrischen; optional Sonnenauf-/-untergang aus `getSunrise.php` dazu |
| `submitFormAjax(form)` | Formular abschicken, ohne die Seite zu verlassen |
| `bindModalSlider()` | Schieberegler im Modal an ihre Anzeige koppeln |
| `mountMeteogram()`, `updateWeatherCards()` | Wetterteile der Solar- und Wetterseite |
| `reiterMerken()` | offener Reiter steht in der Adresse und übersteht ein Neuladen |
| `loadingHTML(text)` | der eine Ladezustand für alle Modals |
### Die Seitenskripte
**`homeMQTT.js`** — Grundriss und Kacheln. Zwei Objekte: `homeMQTT` hält die
Verbindung und den Nachrichtenbaum (`mqttData`), `homeSVG` schreibt in die
SVG-Kacheln. Was auf einer Kachel steht, kommt nicht aus dem Skript, sondern
aus `homeRooms` — der Raumtabelle, die `home.php` als JSON in die Seite
schreibt. `homeSVG.kachelWert()` ist die Stelle, an der aus einer Nachricht
Text wird: Topic oder Wechselrichtersumme lesen, ggf. JSON-Pfad
herausgreifen, Vorzeichen drehen (`negativ`), skalieren (`format:
"leistung"`), runden (`stellen`), Einheit anhängen. Klick auf eine Kachel
öffnet das Raum-Modal über `ajax/room.php?room=EG_Bad`.
**`solarMQTT.js`** — die große Anlagenübersicht. `solarSVG` zeichnet den
Energiefluss (Breite der Pfeile aus der Leistung), darunter hängen die drei
Diagramme Prognose, Verbrauch und Erzeugung über `getData()`. Änderungen am SVG laufen über kleine Setter
(`setText`, `setAttr`, `setFlowWidth`) und werden pro Bildrahmen gesammelt
(`solarSvgUpdatePending`), damit ein Schwall MQTT-Nachrichten nicht hundertmal
Layout auslöst.
**`skodaMQTT.js`** — Fahrzeugseite. Holt alles über `ajax/skoda.php?was=…`
(live, ladungen, kurve, gesundheit, fahrten, strecke), zeichnet das Fahrzeug
als SVG-Draufsicht mit Ladestand im Akku, Ladeknopf und Klimaknopf, und die
Fahrtenkarte mit Leaflet über den eigenen Kachelspeicher `ajax/tile.php`.
Befehle gehen per POST an `ajax/skodaCmd.php` — nie direkt an die API.
**`jahresstatistik.js`** — ein Bauteil, zwei Seiten. `baueJahresstatistik()`
baut aus der Antwort von `getStats.php` die Vergleichstabelle (Jahre
nebeneinander, Gesamtspalte, Δ in Prozentpunkten bei Quoten, Sparklines je
Monat). Die Kennzahlen stehen **nicht** im Skript: sie beschreiben sich in der
Antwort selbst.
**`autoActionFuncs.js`** — der Automatik-Editor: Gerätewähler mit Suche,
Bedingungen in UND-/ODER-Gruppen, Aktionen mit Parametern, Klartext-Vorschau.
Er bekommt Gerätekatalog und Datensatz in einem Dokument von
`AutoAction.php?action=editor` — früher kamen je Bedingung zwei weitere
Anfragen dazu, und die Auswahlfelder füllten sich asynchron. Dazu die
Übersicht: Nachfolger einklappen (`toggleAutomationGroup`) und die Rückfrage
vor dem Löschen (`frageNachfolger`).
**`settings.js`** — drei Reiter in einer Datei, deutlich getrennt: Preistabellen,
Home-Kacheln, Geräte. Jeder Abschnitt hat sein Modell (`kachelModell`,
`geraeteModell`), seine `zeichne…()`-Funktion und eine `…Binden()`-Funktion,
die genau einen Ereignisbehandler auf den Container legt (Delegation) —
deshalb überlebt Bedienung jedes Neuzeichnen.
**`heatMQTT.js`, `historyMQTT.js`, `weatherMQTT.js`, `logs.js`** — jeweils
klein: Diagramme füllen bzw. Logzeilen nachladen und einfärben.
---
## Die PHP-Schicht
### Endpunkte (`ajax/`)
| Endpunkt | Aufruf | liefert / tut |
|---|---|---|
| `room.php` | `?room=EG_Bad`, `POST ?action=command\|temp` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden |
| `AutoAction.php` | `?action=editor\|list\|followers\|werte`, `POST save\|delete\|toggle` | Automatik-Editor und -Übersicht |
| `settings.php` | `?action=list\|kacheln\|geraete`, `POST save\|kacheln-save\|geraete-raeume\|geraet-loeschen` | Einstellungsseite |
| `getStats.php` | `GET` | Jahresstatistik, alle Jahre, mit Metadaten je Kennzahl |
| `energyHistory.php` | `?series=prod\|cons&range=month\|year\|decade` | Verbrauchs-/Erzeugungsverlauf (Rohdaten oder Stundenarchiv) |
| `getProdData` `getConsData` `getForecastData` `getHeaterData` `getWaterData` | `?FROM=&TO=` | Chart.js-Datensätze für die jeweilige Karte |
| `getSunrise.php` | `?FROM=&TO=` | Sonnenauf- und -untergänge als Diagramm-Markierungen |
| `skoda.php` | `?was=live\|ladungen\|kurve\|gesundheit\|fahrten\|strecke` | Fahrzeugauswertungen aus `solarLog.skoda` |
| `skodaCmd.php` | `POST befehl=…` | Laden/Klima/Lüftung am Fahrzeug |
| `tile.php` | `?z=&x=&y=` | Kartenkachel aus dem eigenen Zwischenspeicher, sonst einmalig von OSM |
| `logs.php` | `?action=list\|read` | Zustand und Zeilen der Logdateien |
| `carEG.php` `carOG.php` `carSteuerung.php` `heater.php` `watering.php` `tahoma.php` | | Wallboxen, Heizstab, Bewässerung, Tahoma-Bedienung |
| `chartData.php` | (kein Endpunkt) | gemeinsame Bausteine der Chart-Endpunkte |
| `phpMQTT.php` | (Bibliothek) | MQTT aus PHP heraus, für Kommandos |
Die meisten Endpunkte sind **Verteiler**: Eingabe prüfen, Modellfunktion
rufen, JSON zurückgeben. Wo Logik in einem Endpunkt steht, hat das einen
Grund, der oben in der Datei erklärt ist (`getStats.php` rechnet die
Kennzahlen, `energyHistory.php` wählt die Quelle nach Zeitraum,
`skoda.php` bildet Fahrten und Ladungen aus einer unregelmäßigen Zeitreihe).
### Modelle (`restricted/`)
| Datei | Zuständig für | Wichtige Funktionen |
|---|---|---|
| `rooms.php` | Räume, Etagen, Kachelwerte, Symbole | `allRooms()`, `roomsWithTile()`, `tileTopics()`, `kachelVorgabe()`, `kachelUeberschreibungen()`, `bootstrapIcons()`, `kachelIcon()` |
| `costs.php` | Preistabellen, Verbindung zu `solarLog` | `solarDb()`, `preiseLaden()`, `preiseSpeichern()`, `grundpreisImZeitraum()` |
| `automations.php` | Automatiken, Gerätekatalog, Verbindung zu `homeMesh` | `meshDb()`, `deviceCatalog()`, `loadAutomation()`, `saveAutomation()`, `deleteAutomation()`, `automationFollowers()`, `pruefeKreis()`, `kalendertagChoices()`, `tagVorlagen()`, `stateValues()`, `geraeteListe()`, `geraetLoeschen()`, `saveRooms()` |
| `kacheln.php` | Anzeigewerte der Home-Kacheln (Maskenseite) | `kachelListe()`, `kachelKatalog()`, `kachelWertPruefen()`, `kachelWerteSpeichern()`, `kachelSymbole()` |
| `roomControls.php` | Welches Gerät welches Bedienelement bekommt | `bedienform()`, `zeichneBeschattung()`, `zeichneLicht()`, `zeichneMesswerte()` |
| `commands.php` | Ein Kommando abschicken (MQTT/WLED/HTTP/Tahoma) | `executeCommand()`, `sendeMqtt()`, `sendeWled()`, `sendeHttp()`, `sendeTahoma()` |
| `wallboxen.php` | Beide Wallboxen über MQTT | `wallboxen()`, `wallboxSetzen()` |
| `logdateien.php` | Logdateien vom Ende her lesen | `logDateien()`, `logLesen()`, `logZerlegen()` |
| `aussenplan.php` | Schrägbild des Außengeländes | Projektion + SVG |
| `reiter.php` | Reiter | `reiterLeiste()`, `reiterFlaecheAuf/Zu()`, `reiterBlock()` |
| `config.php` | Zahlen, die an der Wirklichkeit hängen (welches Auto an welcher Box) | |
| `mysql.php` | Zugangsdaten, **nicht im Git** | Vorlage: `mysql.php.example` |
Include-Graph der Modellschicht — flach gehalten, damit man einer Zeile bis zur
Datenbank folgen kann, ohne zu springen:
```mermaid
flowchart LR
HELPER["helper.php"] --> MYSQL["mysql.php<br/><small>Zugangsdaten</small>"]
ROOMS["rooms.php"] --> COSTS["costs.php"] --> MYSQL
KACH["kacheln.php"] --> ROOMS
KACH --> COSTS
KACH -. "nur wenn vorhanden" .-> AUTOM["automations.php"]
AUTOM --> MYSQL
AUTOM --> ROOMS
CMD["commands.php"] --> MYSQL
CMD --> TAH["tahoma_EG.php"]
CMD --> PHPMQTT["ajax/phpMQTT.php"]
RCTRL["roomControls.php"] --> REITER["reiter.php"]
HOME["home.php"] --> ROOMS
HOME --> REITER
SET["settings.php"] --> COSTS & KACH & REITER
HEADER["header.php"] --> ROOMS
```
---
## Die Datenbanken
Drei Schemata, klar getrennte Zuständigkeit:
```mermaid
erDiagram
actors ||--o{ actor_states : "meldet"
actors ||--o{ actor_commands : "kann"
actor_commands ||--o{ command_parameters : "nimmt"
automations ||--o{ automation_conditions : "wenn"
automations ||--o{ automation_actions : "dann"
automation_conditions }o--|| actor_states : "vergleicht"
automation_actions }o--|| actor_commands : "schickt"
automation_actions ||--o{ automation_action_params : "mit"
```
**`homeMesh`** — Geräte und Automatiken (Schema: `homeMesh_DB-layout.sql`,
`homeMesh_automations.sql`).
| Tabelle | Inhalt | geschrieben von |
|---|---|---|
| `actors` | ein Gerät je Zeile, `url` bestimmt den Weg (`mqtt://`, `http://`, `wled://`, `io://`, `Logic`) | Discovery; `room` von Hand (Einstellungen → Geräte) |
| `actor_states` | Messwerte: `url` = Topic **oder** Feldname, `value_path` = Schlüssel darin, `current_value` | Discovery (Adressen), Runner (Werte) |
| `actor_commands`, `command_parameters` | was ein Gerät kann und welche Parameter dazugehören | Discovery |
| `automations`, `automation_conditions`, `automation_actions`, `automation_action_params` | das Regelwerk | Web-Editor |
| `automation_log` | was wann ausgelöst hat | Runner |
| `calendar_days` | Feiertage und Ferien | `fetch_calendar.py` (SolarManager) |
| `state_types` | Datentypen der Messwerte (Zahl, Text, Auswahl …) | fest |
**`solarLog`** — alles, was einen Verlauf hat.
| Tabelle | Inhalt | geschrieben von |
|---|---|---|
| `EnergyFlow`, `EnergyFlow_hourly` | Leistungen im Rohtakt und als Stundenarchiv | `solarManager.py` |
| `stats_daily` | Tageswerte für die Jahresstatistik | `solarManager.py` |
| `Heater`, `puffertemp`, `wasser`, `zisterne`, `windrad`, `kiga_windrad` | Heizung, Speicher, Wasser, Windrad | `solarManager.py` |
| `skoda`, `skoda_raw` | Fahrzeugzustand im Verlauf | `gatherSkodaData.py` |
| `weatherStation`, `weatherHours`, `weatherDays`, `daylight`, `simPower` | Wetter, Sonnenzeiten, Ertragsprognose | Wetterbrücke, Open-Meteo, Prognose |
| `gridCosts`, `gasCosts`, `fuelCosts` | Preiszeitreihen (Stichtag, kein Enddatum) | Einstellungsseite (`costs.php`) |
| `kachelWerte` | Überschreibungen der Home-Kacheln, JSON je Raum | Einstellungsseite (`kacheln.php`) |
**`Logins`** — `users` (Passkeys) und `addUser` (Einmal-Links).
---
## Vier Bausteine im Detail
### 1. Die Home-Kacheln
Was auf einer Kachel steht, hat drei Stufen: die Vorgabe in `rooms.php`, die
Überschreibung in `kachelWerte`, und die Anzeige im Browser.
```mermaid
flowchart LR
ROOMS["rooms.php<br/><small>$rooms: Vorgabe je Raum</small>"] --> MIT["mitWerten()"]
KW[("kachelWerte<br/><small>je Raum, JSON</small>")] --> MIT
MIT --> RWT["roomsWithTile()"]
RWT -->|"als JSON in die Seite"| HOMEJS["homeMQTT.js<br/><small>homeRooms</small>"]
RWT --> SVG["home.php<br/><small>SVG-Kacheln</small>"]
TT["tileTopics()"] -->|"abonniert genau diese"| BROKER{{Broker}}
BROKER --> HOMEJS
HOMEJS -->|"kachelWert()"| SVG
SETJS["settings.js<br/><small>Reiter Kacheln</small>"] --> SETA["ajax/settings.php"]
SETA --> KACHM["kacheln.php<br/><small>prüfen, speichern</small>"] --> KW
KACHM -->|"Auswahlliste"| KAT["deviceCatalog()<br/><small>automations.php</small>"]
```
Eine Wertdefinition ist ein kleines JSON-Objekt, überall in derselben Form:
```json
{ "topic": "Power_UG/status/em:0", "pfad": "total_act_power",
"einheit": "W", "stellen": 0, "negativ": true,
"icon": "box-arrow-up", "gross": true }
```
`wechselrichter` + `feld` statt `topic` summiert mehrere Wechselrichter;
`format: "leistung"` skaliert selbst zwischen W und kW; `negativ` dreht das
Vorzeichen (für einen Zähler, der verkehrt herum eingebaut ist); `icon` ist
der Name **irgendeines** Bootstrap-Icons — die Codepunkte liest
`bootstrapIcons()` aus `css/bootstrap-icons.min.css`, gezeichnet wird als
SVG-Text in der Icon-Schrift.
Fehlt die Tabelle `kachelWerte`, zeigt jede Kachel ihre Vorgabe: die
Startseite ist keine Einstellung, ohne die man das Haus nicht mehr sieht.
### 2. Das Raum-Modal
```mermaid
sequenceDiagram
participant B as Browser
participant R as ajax/room.php
participant RC as roomControls.php
participant C as commands.php
participant G as Gerät
B->>R: GET ?room=EG_Bad
R->>RC: Geräte des Raums (actors.room) → Bedienformen
RC-->>B: fertiges Markup (Rollladen, Licht, Messwerte …)
B->>R: POST ?action=command {command_id, params}
R->>C: executeCommand()
alt mqtt://
C-->>G: Nutzlast auf das Topic
else wled://
C-->>G: JSON an /json/state
else http://
C-->>G: Abfrageargumente an die Geräte-URL
else Tahoma
C-->>G: exec/apply an die Box
end
```
`roomControls.php` entscheidet **an den Fähigkeiten**, nicht am Typnamen: Hat
das Gerät ein `setClosure`, wird es ein Rollladen mit Auf/Stop/Zu und zwei
Reglern; hat es drei Parameter rot/grün/blau, wird es eine Lampe mit
Farbwähler. Ein neu gefundenes Gerät bekommt so ohne Codeänderung ein
passendes Bedienelement.
`commands.php` ist das Gegenstück zu `transports.py` im SolarManager-Repo —
dieselbe Zuordnung „welche URL gehört zu welchem Weg“, einmal für den Runner,
einmal für den Browser.
### 3. Die Automatiken
Editor (`autoActionFuncs.js` + `AutoAction.php` + `automations.php`) schreibt
das Regelwerk nach `homeMesh`; ausgeführt wird es von
`autoActions/autoaction_runner.py` im anderen Repo. **Die Weboberfläche
schaltet nichts von sich aus** — sie beschreibt nur, was gelten soll.
**Drei Bänder statt eines Akkordeons: Wenn · Wann · Dann.** Das Akkordeon
schloss beim Öffnen die anderen Fächer — Auslöser und Aktion waren also nie
gleichzeitig zu sehen, ausgerechnet bei einer Regel, die genau daraus
besteht. Und das mittlere Fach hieß „Bedingungen", enthielt aber die
*Rahmen*bedingungen. Gebaut aus `.card` mit farbigem Rand und `.card-header`,
kein eigenes Bauteil.
**Der Rahmen steht zusammengefaltet da**, solange nichts von der Vorgabe
abweicht — und das ist der Normalfall. `rahmenKurzText()` schreibt ihn in
eine Zeile („täglich · rund um die Uhr"), `rahmenIstVorgabe()` entscheidet,
ob sie matt oder hell ist. Vorher nahm er die meiste Fläche für den
seltensten Inhalt.
**Neben jeder Bedingung läuft ihr aktueller Wert mit.** `?action=werte`
liefert alle acht Sekunden `{state_id: {value, unit}}` aus `actor_states`
bewusst aus der Tabelle und nicht über MQTT, weil dort nur ein Teil der
Geräte auftaucht (die Jalousien hängen an der Tahoma-Box, die gerechneten
Werte an gar nichts); der Runner schreibt dagegen jeden Messwert zurück.
Das ersetzt den Satz, der früher am Ende stand und die Regel Wort für Wort
nacherzählte: statt zu wiederholen, was darüber steht, beantwortet die Zeile
die Frage, die man wirklich hat — warum läuft die Automatik gerade nicht?
Ein voller Punkt heißt erfüllt, ein leerer noch nicht, ein **gestrichelter
heißt „entscheidet der Runner"**. Bei `time`, `date`, `datetime`,
`deltatime` und `elapsed` fällt der Browser bewusst kein Urteil: ob
„Uhrzeit um 07:30" gerade zutrifft, hängt am Nachholfenster, am Tagesrand
und beim Datentyp `elapsed` am echten Abstand seit der letzten Auslösung —
das alles steht in `bedingung_erfuellt()` im Runner. Es hier nachzubauen
hieße, eine zweite Wahrheit zu pflegen, die irgendwann ausei­nanderläuft.
Lieber keine Aussage als eine, die manchmal falsch ist (`ZEITARTEN`).
Bedingungen mit derselben `group_no` sind mit UND verknüpft, verschiedene
Gruppen mit ODER: ausgewertet wird `any(all(gruppe))`. Eine Bedingung zeigt
auf einen `actor_states`-Eintrag, eine Aktion auf ein `actor_commands` samt
Parametern. Deshalb dürfen Zustands-Ids nicht wandern — siehe die
Geräte-Erkennung im nächsten Abschnitt.
**Eine Automatik kann eine andere auslösen.** Dafür steht im Editor auffallend
wenig Code: das gerechnete Gerät „Automatiken" führt jede Automatik als
Messwert, ihr Wert ist der Zeitpunkt der letzten Auslösung — für den
Gerätewähler ist es damit ein Gerät wie jedes andere. Die Messwerte legt der
Runner an und pflegt sie, `restricted/automations.php` liest sie nur
(`ausloeserStates()`, `automationTriggers()`).
Drei Stellen wissen trotzdem davon:
* `pruefeKreis()` lehnt beim Speichern ab, was sich mittelbar selbst auslösen
würde. Im Betrieb wäre ein Kreis kaum zu bemerken — die Sperrzeit begrenzt
ihn auf eine Auslösung je `lockout_secs`, und im Protokoll sieht das aus wie
eine Automatik, die halt oft läuft.
* `deleteAutomation()` fragt vorher. `fk_cond_state` steht auf
`ON DELETE CASCADE`, mit dem Messwert verschwände also still die Bedingung
des Nachfolgers: aus *zehn Minuten nach dem Wecker UND es ist hell* würde
ein bloßes *es ist hell*. Zur Wahl stehen Abhängen (Bedingung raus,
Nachfolger pausiert) und Mitlöschen.
* Die Übersicht rückt Nachfolger unter ihren Auslöser ein und zeigt sie
eingeklappt (`toggleAutomationGroup()`). Das ist **nur die Darstellung**
jeder Nachfolger bleibt eine vollwertige Automatik mit eigener Pause,
eigenen Rahmenbedingungen und eigener Zeile im Protokoll. Sobald
„gruppiert" auch „gehört dazu" hieße, wäre zu klären, wem die Pause gehört.
**Die Tagesauswahl in vier Stufen, von grob nach fein.** Ganz oben drei
Vorlagen (`tagVorlagen()`): *täglich*, *werktags*, *Wochenende*. Sie setzen den
**ganzen** Rahmen, nicht nur die Wochentagsmaske — „werktags" heißt auch
„nicht an Feiertagen", „Wochenende" auch „dazu alle Feiertage". Wer das aus
Maske und zwei Schaltern von Hand zusammensetzt, vergisst den zweiten Teil.
Darunter sieben Wochentagsschalter, darunter Ferien und Feiertage mit je drei
Stufen — **nie · egal · immer** (`kalendertagChoices()`) —, und ganz unten ein
Satz, der zurückliest, was dabei herausgekommen ist: *„Läuft samstags und
sonntags, dazu an allen Feiertagen."*
Der Satz ist der eigentliche Gewinn: man muss das Bedienelement nicht
entziffern. `rahmenText()` in `autoActionFuncs.js` baut ihn, zieht drei und
mehr aufeinanderfolgende Tage zu „montags bis freitags" zusammen und gibt bei
gar keinem wählbaren Tag `""` zurück — dann steht dort die Warnung *„Kein Tag
ausgewählt"*, genau wie beim fehlenden Auslöser.
„Immer" zählt wie ein angehakter Wochentag. Das ist die einzige Art, *an
Wochenenden und Feiertagen* zu schreiben — für die Maske ist ein Feiertag am
Dienstag eben ein Dienstag —, und mit **gar keinem** Wochentag angehakt ergibt
es *nur an Feiertagen*.
Vorher standen dort zwei Auswahlfelder, deren Einträge ganze Sätze waren
(„zusätzlich, auch am falschen Wochentag"). Ein Auswahlfeld zeigt immer nur
den gewählten Eintrag — man sieht nie, was es sonst noch gibt, und die
Erklärung steht an der Stelle, wo ein Etikett hingehört.
`kalendertagWert()` prüft beim Speichern mit `is_numeric`, weil `intval("")`
sonst 0 wäre — und 0 heißt „nie": eine leere Angabe hätte eine Automatik
stillschweigend an Feiertagen abgeschaltet.
**„Nur einmal am Tag"** (`once_per_day`) sperrt eine Automatik nach dem
Auslösen bis Mitternacht. Für alles, was man hinterher von Hand wieder anders
stellt: ein Rollladen, den man um acht zugezogen hat, soll nicht um neun von
selbst wieder auffahren, nur weil eine Wolke weiterzieht.
Wie der Runner das auswertet — Datentyp `elapsed`, topologische Reihenfolge,
der Rahmen, was beim Pausieren geschieht — steht in
[`autoActions/README.md`](../../homes/wagner/SolarManager/autoActions/README.md)
im SolarManager-Repo.
### 4. Die Geräte-Erkennung
`restricted/deviceDiscovery/device_discovery.py` wird **von Hand** gestartet.
Die Module suchen je eine Geräteart (Tahoma, WLED, Shelly, MQTT/Home-Assistant-
Discovery, Gartenwasser, SolarManager-Werte, Wetterstation, Logic); die
Datenbanklogik liegt allein im Hauptskript.
Drei Eigenschaften, die man kennen muss:
* **Ein Suchlauf löscht nichts.** `clear_tables` steht in `config.ini` auf
`false`, weil die Automatiken auf die Ids von `actor_states` zeigen.
Bestehende Zeilen werden über den Schlüssel `(actor_id, state_name)`
aktualisiert, neue kommen dazu. Abgebaute Geräte verschwinden nur über
**Einstellungen → Geräte → Papierkorb** (und auch dort nur, wenn keine
Automatik mehr auf sie zeigt).
* **Shelly, zwei Wege — und zwar je Komponente.** Alte Shellys können kein
MQTT und bleiben ganz beim HTTP-Weg: in `actor_states.url` steht ein
Feldname der JSON-Antwort. Bei Gen2-Geräten mit eingeschaltetem `status_ntf`
steht dagegen das Topic in `url` (`Power_EG/status/em:0`) und der Schlüssel
in `value_path`.
Das gilt aber nur für Komponenten, die auch wirklich senden — die Liste
steht als `MQTT_KOMPONENTEN` in `modules/shelly_module.py` und enthält
heute `em` und `switch`. Der Pro 3EM etwa hat eine Temperaturkomponente im
RPC-Status, veröffentlicht sie aber nie; ein Messwert auf diesem Topic
bliebe für immer auf seinem letzten Wert stehen. Alles außerhalb der Liste
wird deshalb weiter abgefragt. Wer eine Komponente ergänzen will, hört
vorher mit (`mosquitto_sub -t '<präfix>/#' -v`).
Geschaltet werden auch die neuen Geräte über HTTP. Der Runner teilt seine
Messwerte deshalb je Wert zu, nicht je Gerät.
* **Vier Module suchen gar nichts.** Gartenwasser, SolarManager, Wetterstation
und Logic schreiben feste Geräte hin, weil sich diese Quellen nicht von
selbst melden. Wer einen Messwert vermisst, trägt im jeweiligen Modul eine
Zeile ein — bei der Wetterstation in `MESSWERTE`, beim SolarManager in
`GERAETE`. Bewusst knapp gehalten: `solarManager/#` allein trägt gut 130
Topics, und eine vollständige Liste machte die Auswahl im Automatik-Editor
unbrauchbar.
Die **Wetterstation** hängt am Websocket von `192.168.179.42`;
`wsMQTTbridge.py` im SolarManager legt ihre Felder unter `weatherStation/#`
ab. Von dort kommen Außentemperatur, Wind und Böe — die Außentemperatur ist
für den Hitzeschutz die bessere Bedingung als die Raumtemperatur (ist es
drinnen schon warm, ist es zum Verschatten zu spät), und ohne den Wind gibt
es für Markise und Sonnensegel keinen Sturmschutz. Die trägen Werte
(Temperatur, Feuchte, Druck, Taupunkt) gehen nur bei jeder einundzwanzigsten
Nachricht raus; sie sind retained, für eine Bedingung macht das keinen
Unterschied.
---
## Wiederkehrende Bauteile und Konventionen
**Zwei Radien, eine Anzeigeschrift.** `solar.css` tauscht die Palette über
Variablen aus, statt Regel für Regel zu überschreiben — dasselbe gilt für
Form und Schrift. `--ton-radius` (`.875rem`) gilt für Flächen, `--ton-radius-klein`
(`.5rem`) für alles, was in einer Reihe steht; beide hängen an
`--bs-border-radius` und `--bs-border-radius-sm`, also zieht jede Karte,
jedes Modal und jeder kleine Knopf von allein mit. `--font-anzeige` ist
Poppins — die Schrift lag seit jeher unter `assets/fonts` und trug nur die
vier Etagenknöpfe im Außenplan; jetzt trägt sie alle Überschriften,
`.card-title` und `.modal-title`. Fließtext und Zahlenkolonnen bleiben
bewusst bei der Schrift des Themes: Poppins bringt keine Tabellenziffern
mit, Messwerte würden sonst springen.
**Drei Klassen aus dem Automatik-Editor stehen absichtlich in `solar.css`
und nicht dort.** `.feld-als-text` ist ein Auswahlfeld, das wie Text
aussieht, bis man es anfährt — dieselbe Not haben das Raum-Modal und die
Kachel-Einstellungen, wo ebenfalls jede Kleinigkeit in einem Kasten steht.
`.punkt` ist an oder aus. `.wert-jetzt` ist die eine echte Ausnahme —
Bedingungen gibt es sonst nirgends — und als einzelne Klasse auch als solche
erkennbar.
**Reiter.** Es gibt genau eine Bauart, `restricted/reiter.php`. Umgeschaltet
wird von Bootstrap (`data-bs-toggle="tab"`), gemerkt wird der offene Reiter von
`reiterMerken()` in `common.js`. Weniger als zwei Reiter ergeben keine Leiste.
**Stylesheet-Reihenfolge — die häufigste Falle.** `header.php` lädt
`css/solar.css` **vor** `css/adminlte.min.css`. Eine eigene Regel mit gleicher
Spezifität wie eine AdminLTE-Regel verliert deshalb. Wenn eine Änderung
„nicht wirkt“, ist das fast immer der Grund: Selektor verlängern
(`.nav-tabs.reiter .nav-link` statt `.reiter .nav-link`), nicht `!important`.
**Zwischenspeicher.** Jedes eigene Skript und `solar.css` werden mit
`?v=<filemtime>` eingebunden — gespeichert heißt geladen.
**Sprache.** Bezeichner und Kommentare sind deutsch, wo es um die Sache geht;
englisch bleibt, was von außen vorgegeben ist (`floor`, `topic`, Chart.js-
Optionen, Spaltennamen). Kommentare erklären **warum**, nicht was.
**Fehlertoleranz vor Vollständigkeit.** Was eine Seite nicht zwingend braucht,
wird in `try/catch` geholt: fehlt die Mesh-Datenbank, bleibt die Startseite
sichtbar; fehlt `kachelWerte`, gilt die Vorgabe.
**Zugriffsschutz.** `.htaccess` sperrt Punkt-Dateien (allen voran `.git`) und
alle Dateitypen, die der Browser nie anfordert (`.py`, `.sql`, `.ini`, `.md`
…). `restricted/mysql.php` steht in `.gitignore`; Vorlage ist
`mysql.php.example`. Zugangsdaten gehören nirgendwo sonst hin.
**Zeilenenden.** `.gitattributes` erzwingt LF — der Ordner wird von Linux
ausgeliefert.
---
## Wo fange ich an, wenn ich … ändern will
| Vorhaben | Anlaufstelle |
|---|---|
| Ein Raum kommt dazu oder heißt anders | `restricted/rooms.php`, Tabelle `$rooms` — Kachel, Menü und MQTT-Abo ergeben sich daraus |
| Andere Werte auf einer Kachel | Einstellungen → Kacheln (keine Codeänderung). Vorgabe für neue Räume: `kachelVorgabe()` in `rooms.php` |
| Neues Symbol auf einer Kachel | Name eines Bootstrap-Icons ins Feld tippen. Vorschlagsliste: `kachelSymbole()` in `kacheln.php` |
| Neue Kennzahl in der Jahresstatistik | `KENNZAHLEN` **und** `kennzahlenAus()` in `ajax/getStats.php` — sonst nichts |
| Neues Diagramm auf einer Seite | Karte in der Vorlage, `<canvas>`, dazu ein Endpunkt nach dem Muster von `getProdData.php`, geladen mit `getData()` |
| Neue Seite | Zeile in `$pages` (`index.php`), Vorlage unter `restricted/`, Skript unter `js/solar/` |
| Anderes Bedienelement für ein Gerät | `bedienform()` in `roomControls.php` (nach Fähigkeit, nicht nach Typname) |
| Neue Geräteart erkennen | neues Modul unter `restricted/deviceDiscovery/modules/` + Eintrag in der Liste in `device_discovery.py` |
| Neuer Geräteweg zum Schalten | `commands.php` (Browser) **und** `transports.py` im SolarManager-Repo (Runner) |
| Aussehen der ganzen Seite | `css/solar.css`, oberster Abschnitt: die `--ton-*`-Tokens und die Bootstrap-Zuordnung |
| Reiter irgendwo | `reiterLeiste()`/`reiterBlock()` aus `restricted/reiter.php` benutzen, keine eigene Variante bauen |
| Neue Einstellung | Reiter in `$reiter` (`restricted/settings.php`), Abschnitt in `settings.js`, Aktion in `ajax/settings.php`, Modellfunktion daneben |
---
## Entwickeln und Prüfen
Der Ordner ist die laufende Anwendung — es gibt keinen Build und keine
Staging-Stufe. Entsprechend gilt:
```bash
# PHP-Syntax (auf der NAS; /usr/bin/php ist 8.1 ohne mysqli)
/usr/local/bin/php82 -l restricted/kacheln.php
# JavaScript-Syntax
/var/packages/Node.js_v20/target/usr/local/bin/node --check js/solar/settings.js
# Modellfunktion einzeln ausprobieren, ohne Browser
/usr/local/bin/php82 -r 'require "restricted/automations.php"; print_r(geraeteListe()[0]);'
# Datenbank ansehen (readDB, nur SELECT)
/usr/local/mariadb10/bin/mysql -h 127.0.0.1 -e 'SELECT * FROM homeMesh.actors LIMIT 5;'
```
Für Masken lohnt eine **Probeseite**: eine kleine HTML-Datei, die `solar.css`
und das Skript lädt und `window.fetch` durch eine Attrappe mit echten Daten
ersetzt. Damit lässt sich das Zeichnen prüfen, ohne angemeldet zu sein und
ohne etwas zu speichern.
Zwei Repositories, beide auf Gitea (`gitea.nas.el-wa.org`):
| Repo | Ort | Inhalt |
|---|---|---|
| `admin/Smart-Dashboard` | `/volume1/web/smart` | dieses hier |
| `admin/SolarManager` | `/volume1/homes/wagner/SolarManager` | Sammler, Runner, Wecker |
Rekursive Suchen (`grep -r`) laufen über die SMB-Freigabe in Zeitüberschreitungen —
auf der NAS selbst sind sie sofort fertig.
+291 -142
View File
@@ -9,11 +9,11 @@
* 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}
* GET ?action=followers&id=N JSON: wer haengt an dieser Automatik?
* GET ?action=werte&ids=1,2,3 JSON: was diese Messwerte gerade sagen
* POST ?action=delete {"id": N, "mode": "abhaengen"|"mitloeschen"}
* POST ?action=toggle {"id": N} - pausieren / fortsetzen
* POST ?action=saveRooms {"<actor_id>": "OG/Bad", ...}
*/
require_once("../helper.php");
@@ -47,12 +47,15 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$id = saveAutomation($body);
jsonAntwort(["id" => $id]);
case "delete":
deleteAutomation(intval($body["id"] ?? 0));
// Der Modus kommt aus der Rueckfrage im Browser. Unbekanntes
// faellt auf das schonendere "abhaengen" zurueck - lieber eine
// pausierte Automatik zu viel als eine geloeschte zu viel.
$modus = ($body["mode"] ?? "") === "mitloeschen"
? "mitloeschen" : "abhaengen";
deleteAutomation(intval($body["id"] ?? 0), $modus);
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);
}
@@ -63,6 +66,23 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
}
}
// --- Wer haengt an dieser Automatik? -------------------------------------
// Wird vor dem Loeschen gefragt, damit die Rueckfrage im Browser die Namen
// nennen kann und nicht nur eine Zahl.
if ($action === "followers") {
jsonAntwort(["followers" => automationFollowers(intval($_GET["id"] ?? 0))]);
}
// --- Was sagen diese Messwerte gerade? -----------------------------------
// Der offene Editor fragt das im Takt nach, damit neben jeder Bedingung der
// laufende Wert steht. Absichtlich ein eigener, winziger Endpunkt: den
// ganzen Geraetekatalog neu zu holen waeren je Runde ueber hundert
// Kilobyte fuer ein paar Zahlen.
if ($action === "werte") {
$ids = array_slice(explode(",", $_GET["ids"] ?? ""), 0, 200);
jsonAntwort(["werte" => stateValues($ids)]);
}
// --- Uebersichtstabelle --------------------------------------------------
if ($action === "list") {
$tage = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
@@ -78,7 +98,77 @@ if ($action === "list") {
. "<th class='d-none d-sm-table-cell'>Aktion</th>"
. "<th class='d-none d-md-table-cell'>Wann</th>"
. "<th class='d-none d-md-table-cell'>Zuletzt</th><th></th></tr></thead><tbody>";
/*
* Eine Automatik, die von einer anderen ausgeloest wird, steht unter
* ihr statt alphabetisch irgendwo dazwischen - sonst liest man
* "Rollladen Magdalena morgens" und sieht nicht, woran es haengt.
*
* Eingeklappt, weil die Uebersicht sonst mit jeder Verkettung laenger
* wird, ohne mehr zu sagen: der Ausloeser ist das, was man sucht. Der
* Zaehler am Pfeil verraet, dass da noch etwas ist.
*
* Gruppiert wird nur die Darstellung. Jeder Nachfolger bleibt eine
* vollwertige Automatik mit eigener Pause, eigenen Rahmenbedingungen
* und eigener Zeile im Protokoll - sonst waere zu klaeren, wem die
* Pause gehoert und was das Protokoll zeigt.
*/
$nachFolgern = [];
foreach ($list as $a) {
foreach ($a["triggers"] as $davor) {
$nachFolgern[intval($davor)][] = intval($a["id"]);
}
}
$vorhanden = [];
foreach ($list as $a) {
$vorhanden[intval($a["id"])] = $a;
}
// Wessen Ausloeser nicht in dieser Etage steht, faengt selbst oben an.
$wurzeln = [];
foreach ($list as $a) {
$hatVater = false;
foreach ($a["triggers"] as $davor) {
if (isset($vorhanden[intval($davor)])) {
$hatVater = true;
}
}
if (!$hatVater) {
$wurzeln[] = intval($a["id"]);
}
}
$reihe = [];
$einordnen = function ($id, $tiefe, $vater) use (&$einordnen, &$reihe, $nachFolgern, $vorhanden) {
if (!isset($vorhanden[$id]) || isset($reihe[$id])) {
return;
}
$reihe[$id] = ["auto" => $vorhanden[$id], "tiefe" => $tiefe,
"vater" => $vater, "kinder" => 0];
foreach ($nachFolgern[$id] ?? [] as $kind) {
$einordnen($kind, $tiefe + 1, $id);
}
};
foreach ($wurzeln as $id) {
$einordnen($id, 0, 0);
}
// Was durch einen Kreis nicht erreicht wurde, kommt trotzdem mit.
foreach ($list as $a) {
$einordnen(intval($a["id"]), 0, 0);
}
// Gezaehlt wird erst jetzt, und nur was wirklich eingeordnet wurde:
// wer zwei Ausloeser hat, steht nur unter dem ersten. Aus der rohen
// Nachfolgerliste gezaehlt verspraeche der zweite Pfeil eine Zeile,
// die er nicht aufklappen kann.
foreach ($reihe as $eintrag) {
if ($eintrag["vater"]) {
$reihe[$eintrag["vater"]]["kinder"]++;
}
}
foreach ($reihe as $eintrag) {
$a = $eintrag["auto"];
$tiefe = $eintrag["tiefe"];
$kinder = $eintrag["kinder"];
$vater = $eintrag["vater"];
// Nur die aktiven Tage nennen. Sieben Haekchen untereinander
// haben die Zeile frueher unnoetig hoch gemacht.
$aktiveTage = [];
@@ -87,16 +177,46 @@ if ($action === "list") {
$aktiveTage[] = $tage[$i];
}
}
$wann = count($aktiveTage) === 7 ? "täglich" : implode(" ", $aktiveTage);
// Kalendertage auf "immer" gehoeren zur Tagesangabe, nicht in
// die Nachbemerkung: sie fuegen Tage hinzu. Ohne angehakten
// Wochentag tragen sie die Automatik sogar allein - "nur
// Feiertage" ist damit eine gueltige Einstellung.
$dazu = [];
if ($a["on_vacation"] == 2) {
$dazu[] = "Ferientage";
}
if ($a["on_holiday"] == 2) {
$dazu[] = "Feiertage";
}
if (count($aktiveTage) === 7) {
$wann = "täglich";
} elseif ($aktiveTage) {
$wann = implode(" ", $aktiveTage);
if ($dazu) {
$wann .= " + " . implode(" + ", $dazu);
}
} elseif ($dazu) {
$wann = "nur " . implode(" + ", $dazu);
} else {
$wann = "nie kein Tag ausgewählt";
}
// Vorabend: alle Tagesangaben meinen morgen. Vorne, damit auch
// "nicht an Feiertagen" weiter hinten als Folgetag gelesen wird.
if ($a["next_day"]) {
$wann = "Vorabend von " . $wann;
}
if ($a["window_from"] !== "00:00" || $a["window_to"] !== "23:59") {
$wann .= ", " . $a["window_from"] . "" . $a["window_to"];
}
if (!$a["on_holiday"]) {
if ($a["on_holiday"] == 0) {
$wann .= ", nicht an Feiertagen";
}
if (!$a["on_vacation"]) {
if ($a["on_vacation"] == 0) {
$wann .= ", nicht in den Ferien";
}
if ($a["once_per_day"]) {
$wann .= ", nur einmal am Tag";
}
if ($a["lockout_secs"] > 0) {
$wann .= ", frühestens alle "
. ($a["lockout_secs"] >= 60
@@ -105,13 +225,43 @@ if ($action === "list") {
}
$zuletzt = $a["last_run"] ? date("d.m. H:i", strtotime($a["last_run"])) : "";
$klasse = $a["enabled"] ? "" : " class='opacity-50'";
$pause = $a["enabled"] ? "bi-pause-fill" : "bi-play-fill";
$titel = $a["enabled"] ? "Pausieren" : "Fortsetzen";
$id = intval($a["id"]);
echo "<tr" . $klasse . ">";
echo "<td>" . htmlspecialchars($a["name"]) . "</td>";
$klassen = [];
if (!$a["enabled"]) {
$klassen[] = "opacity-50";
}
if ($tiefe > 0) {
// Eingeklappt starten: die Zeile gehoert dem Ausloeser, und
// der steht direkt darueber.
$klassen[] = "auto-folge";
$klassen[] = "auto-von-" . $vater;
$klassen[] = "d-none";
}
$klasse = $klassen ? " class='" . implode(" ", $klassen) . "'" : "";
// Der Pfeil klappt die Nachfolger auf. Er steht nur da, wo es
// welche gibt - ein Pfeil ohne Inhalt waere eine Einladung ins
// Leere.
$griff = "";
if ($kinder > 0) {
$griff = "<button type='button' class='btn btn-link btn-sm p-0 me-1 text-decoration-none'"
. " title='Abhängige Automatiken zeigen'"
. " data-gruppe='" . $id . "'"
. " onclick='toggleAutomationGroup(" . $id . ", this)'>"
. "<i class='bi bi-chevron-right'></i> " . $kinder . "</button>";
}
$einzug = $tiefe > 0
? "<span class='text-body-secondary me-1'>"
. str_repeat("&nbsp;&nbsp;&nbsp;", $tiefe)
. "<i class='bi bi-arrow-return-right'></i></span> "
: "";
echo "<tr data-auto='" . $id . "'" . $klasse . ">";
echo "<td class='text-nowrap'>" . $einzug . $griff
. htmlspecialchars($a["name"]) . "</td>";
echo "<td>" . htmlspecialchars($a["conditionText"]) . "</td>";
echo "<td class='d-none d-sm-table-cell'>" . htmlspecialchars($a["actionText"]) . "</td>";
echo "<td class='text-nowrap d-none d-md-table-cell'>" . htmlspecialchars($wann) . "</td>";
@@ -130,73 +280,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;
@@ -212,6 +295,9 @@ $nutzdaten = json_encode([
"automation" => $auto,
"devices" => deviceCatalog(),
"lockouts" => lockoutChoices(),
"ferien" => kalendertagChoices("Ferien", "in den Ferien"),
"feiertage" => kalendertagChoices("Feiertage", "an Feiertagen"),
"tagvorlagen" => tagVorlagen(),
// Nur fuer die Reihenfolge der Raum-Gruppen: rooms.php gibt sie vor, und
// Etage fuer Etage liest sich besser als alphabetisch.
"rooms" => raumListe(),
@@ -243,66 +329,134 @@ $nutzdaten = json_encode([
</div>
</div>
<div class="accordion" id="actionAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseOne" aria-expanded="true">
<i class="bi bi-lightning-fill"></i>&nbsp;&nbsp;Auslöser
</button>
</h2>
<div id="collapseOne" class="accordion-collapse collapse show" data-bs-parent="#actionAccordion">
<div class="accordion-body">
<!--
Drei Baender statt eines Akkordeons: Wenn - Wann - Dann.
Das Akkordeon schloss beim Oeffnen die anderen Faecher, Ausloeser und
Aktion waren also nie gleichzeitig zu sehen - ausgerechnet bei einer
Regel, die genau daraus besteht. Und das mittlere Fach hiess
"Bedingungen", enthielt aber die *Rahmen*bedingungen; die echten
Bedingungen standen im ersten.
Gebaut aus .card mit farbigem Rand und .card-header - kein eigenes
Bauteil. Die Ueberschriften tragen .card-title und bekommen darueber die
Anzeigeschrift aus solar.css.
-->
<div class="card border-primary mb-2">
<div class="card-header d-flex flex-wrap align-items-center gap-2 py-2">
<i class="bi bi-lightning-charge-fill text-primary"></i>
<span class="card-title mb-0 fs-5 text-primary">Wenn</span>
<span class="small text-body-secondary">… das alles zusammenkommt</span>
<span class="flex-grow-1"></span>
<!-- Wie viele Bedingungen gerade zutreffen. Fuellt renderConditions(). -->
<span id="condStand" class="d-flex align-items-center gap-2"></span>
</div>
<div class="card-body p-2">
<!-- Ein Block ist eine UND-Gruppe, zwischen den Bloecken steht ODER.
Die Klammerung ist damit gezeichnet und nicht bloss vereinbart. -->
<div id="condBlocks"></div>
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" id="btnAddGroup">
<i class="bi bi-plus-lg"></i> Alternative (oder)
<i class="bi bi-plus-lg"></i> oder stattdessen …
</button>
<div class="alert alert-secondary mt-3 mb-0 py-2 small" id="condSummary"></div>
</div>
</div>
<div class="card mb-2">
<!--
Der Rahmen steht zusammengefaltet da, solange er nur Vorgaben enthaelt -
und das ist der Normalfall. Frueher nahm er die meiste Flaeche fuer den
seltensten Inhalt.
-->
<!--
Zugeklappt liest man hier, was gilt; aufgeklappt greift man zu. Die drei
Vorlagen tauschen deshalb mit der Kurzfassung den Platz, statt eine
eigene Zeile im Rumpf zu bekommen - das war eine Zeile mehr und eine
Ueberschrift ("An welchen Tagen?"), die neben dem Wort "Wann" ohnehin
nichts Neues sagte.
ACHTUNG beim Umbauen: die Vorlagen duerfen NICHT innerhalb des Elements
stehen, das data-bs-toggle traegt. Bootstrap haengt seinen Umschalter
nicht an dieses Element, sondern ans Dokument - und dort in der
Capture-Phase, die vor jedem Klick auf einen Knopf darin laeuft. Ein
stopPropagation() im Knopf kommt deshalb grundsaetzlich zu spaet, und
jede Vorlage waere zugleich ein Zuklappen. Deshalb traegt nur der linke
Teil den Umschalter, die Vorlagen stehen daneben.
-->
<div class="card-header d-flex flex-wrap align-items-center gap-2 py-2">
<div class="d-flex align-items-center gap-2 flex-grow-1" id="rahmenKopf"
role="button" data-bs-toggle="collapse" data-bs-target="#rahmenBody"
aria-expanded="false">
<i class="bi bi-calendar-week text-warning"></i>
<span class="card-title mb-0 fs-5 text-warning">Wann</span>
<span id="rahmenKurz" class="text-body-secondary"></span>
<span class="flex-grow-1"></span>
<span class="small text-body-secondary text-nowrap" id="rahmenAendern">ändern <i class="bi bi-chevron-down"></i></span>
</div>
<div class="btn-group btn-group-sm" role="group" id="tagVorlagen" hidden></div>
<i class="bi bi-chevron-up text-body-secondary" id="rahmenZu" role="button"
data-bs-toggle="collapse" data-bs-target="#rahmenBody" hidden></i>
</div>
<div id="rahmenBody" class="collapse">
<div class="card-body">
<div class="btn-group btn-group-sm flex-wrap mb-2" role="group" id="weekdayGroup"></div>
<div class="row g-2 mb-2">
<div class="col-sm-6 d-flex align-items-center gap-2">
<span class="text-body-secondary small" style="min-width:5rem">Ferien</span>
<div class="btn-group btn-group-sm" role="group" id="ferienGroup"></div>
</div>
<div class="col-sm-6 d-flex align-items-center gap-2">
<span class="text-body-secondary small" style="min-width:5rem">Feiertage</span>
<div class="btn-group btn-group-sm" role="group" id="feiertagGroup"></div>
</div>
</div>
<!-- Vorabend: fuer alles, was abends fuer morgen geschieht - "Kinder
ins Bett, wenn morgen Schule ist" ist dann Mo-Fr, Ferien nie,
Feiertage nie, wie der Wecker. Uhrzeit und Zeitfenster bleiben
beim heutigen Tag. -->
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="nextDay">
<label class="form-check-label" for="nextDay">
Am Vorabend Wochentage, Ferien und Feiertage gelten für den folgenden Tag
</label>
</div>
<!--
.callout ist ein AdminLTE-Bauteil: farbiger Balken links, getoenter
Grund, und die Toene kommen aus Bootstraps *-bg-subtle-Variablen -
es zieht also mit dem Theme mit, ohne dass hier eine Regel steht.
Als .alert stand der Satz vorher in einem eingesunkenen Kasten mit
Rahmen ringsum und las sich wie eine Warnung; er ist aber das
Ergebnis der Schalter darueber. Die Polsterung stellen Hilfsklassen
kleiner - .callout ist auf ganze Absaetze ausgelegt, hier steht ein
Satz.
-->
<div class="callout callout-primary py-2 px-3 mt-0 mb-3" id="rahmenSummary"></div>
<div class="row g-2 mb-3">
<div class="col-sm-6">
<div class="input-group input-group-sm">
<span class="input-group-text">von</span>
<input type="time" class="form-control" id="tSpanFrom" value="00:00">
<span class="input-group-text">bis</span>
<input type="time" class="form-control" id="tSpanTo" value="23:59">
</div>
</div>
<div class="col-sm-6">
<div class="input-group input-group-sm">
<span class="input-group-text">Sperre</span>
<select class="form-select" id="lockoutSecs"></select>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseTwo" aria-expanded="false">
<i class="bi bi-calendar-check-fill"></i>&nbsp;&nbsp;Bedingungen
</button>
</h2>
<div id="collapseTwo" class="accordion-collapse collapse" data-bs-parent="#actionAccordion">
<div class="accordion-body">
<div class="input-group mb-3">
<span class="input-group-text">Aktiver Zeitraum</span>
<div class="form-floating">
<input type="time" class="form-control" id="tSpanFrom" value="00:00">
<label for="tSpanFrom">Von</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="oncePerDay">
<label class="form-check-label" for="oncePerDay">
Nur einmal am Tag danach bis Mitternacht gesperrt
</label>
</div>
<div class="form-floating">
<input type="time" class="form-control" id="tSpanTo" value="23:59">
<label for="tSpanTo">Bis</label>
</div>
</div>
Aktive Wochentage:
<div class="input-group mb-3" id="weekdayGroup"></div>
<div class="input-group mb-3">
<div class="input-group-text">
<input class="form-check-input mt-0 me-2" type="checkbox" id="actFerien">
<label class="form-check-label" for="actFerien">In den Ferien ausführen</label>
</div>
<div class="input-group-text">
<input class="form-check-input mt-0 me-2" type="checkbox" id="actFeier">
<label class="form-check-label" for="actFeier">An Feiertagen ausführen</label>
</div>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Sperrzeit nach dem Auslösen</span>
<select class="form-select" id="lockoutSecs"></select>
</div>
<div class="input-group mb-0">
<div class="input-group-text">
<input class="form-check-input mt-0 me-2" type="checkbox" id="runOnce">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="runOnce">
<label class="form-check-label" for="runOnce">
Am Ende des Zeitraums auf jeden Fall ausführen, falls der Auslöser nie zutraf
</label>
@@ -310,23 +464,18 @@ $nutzdaten = json_encode([
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseThree" aria-expanded="false">
<i class="bi bi-play-fill"></i>&nbsp;&nbsp;Aktionen
</button>
</h2>
<div id="collapseThree" class="accordion-collapse collapse" data-bs-parent="#actionAccordion">
<div class="accordion-body">
<div class="card border-success mb-0">
<div class="card-header d-flex flex-wrap align-items-center gap-2 py-2">
<i class="bi bi-play-fill text-success"></i>
<span class="card-title mb-0 fs-5 text-success">Dann</span>
<span class="small text-body-secondary">… passiert das hier</span>
</div>
<div class="card-body p-2">
<div id="actionList"></div>
<button class="btn btn-outline-success btn-sm mt-2" type="button" id="btnAddActor">
<i class="bi bi-plus-lg"></i> Aktion
<i class="bi bi-plus-lg"></i> und noch etwas tun
</button>
</div>
</div>
</div>
</div>
</form>
+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 />";
/** 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;
}
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++;
}
}
$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);
+95 -2
View File
@@ -4,14 +4,26 @@
*
* 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}
* GET ?action=skoda-keys Stand der Fahrzeug-Schluessel (ohne Werte!)
* POST ?action=skoda-keys-save {"felder": {"API_KEY2": "..."},
* "loeschen": ["API_KEY3"]}
*
* Ein reiner Verteiler: geprueft und geschrieben wird in
* 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");
require_once("../restricted/skodaKeys.php");
if (!checkLogin()) {
http_response_code(403);
@@ -43,16 +55,97 @@ 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()]);
case "skoda-keys-save":
/*
* Neue Schluessel eintragen. Zurueck kommt der neue Stand -
* aber nie ein Wert, siehe restricted/skodaKeys.php.
*/
jsonAntwort(skodaSchluesselSpeichern(
$body["felder"] ?? [], $body["loeschen"] ?? []));
default:
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
}
} catch (InvalidArgumentException $e) {
jsonAntwort(["error" => $e->getMessage()], 400);
} catch (RuntimeException $e) {
// Nicht die Eingabe, sondern die Umgebung - Datei fehlt, Rechte
// fehlen. Die Meldung sagt schon, was zu tun ist; kein Vorspann.
jsonAntwort(["error" => $e->getMessage()], 500);
} catch (Throwable $e) {
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
}
}
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 === "skoda-keys") {
try {
jsonAntwort(skodaStand());
} catch (Throwable $e) {
jsonAntwort(["error" => "Schlüssel 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"] ?? "")]);
+279 -35
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
@@ -41,54 +42,100 @@ if ($mysql->connect_errno) {
$mysql->set_charset("utf8mb4");
/*
* Ladevorgänge aus den Einzelzeilen schneiden. Aufeinanderfolgende Zeilen mit
* chg_state = 'CHARGING' gehören zusammen, eine Pause über 30 Minuten trennt
* zwei Vorgänge. Wird von mehreren Abschnitten gebraucht, deshalb einmal hier.
* Ladevorgänge aus den Einzelzeilen schneiden. Wird von mehreren Abschnitten
* gebraucht, deshalb einmal hier.
*
* Eine Ladung ist eine ununterbrochene Folge von Zeilen mit chg_state =
* 'CHARGING'. Getrennt wird durch eine Zeile mit einem anderen Zustand, nicht
* durch die Zeit dazwischen: gatherSkodaData.py schreibt nur, wenn das
* Fahrzeug etwas Neues meldet, und beim Laden an der Wallbox tut es das
* mitunter nur einmal in der Stunde. Früher trennte eine Pause über 30
* Minuten - eine Ladung von 51 auf 80 % am 13.09.2026 zerfiel dadurch in drei
* Stücke mit 0, 4 und 0 Prozentpunkten. Hört das Fahrzeug auf zu laden,
* steht das in der nächsten Zeile; die Zeit allein trennt nur noch, wenn sie
* so lang ist, dass der Abruf offenbar nicht lief (LADUNG_LUECKE_S).
*
* Dazu gehören die beiden Zeilen, die die Ladung einrahmen. Die davor sagt,
* mit welchem Ladestand und Zählerstand sie begann - die erste CHARGING-Zeile
* kommt oft erst Minuten später und hat dann schon geladen. Die danach
* (CONNECT_CABLE, READY_FOR_CHARGING, ...) trägt den erreichten Ladestand:
* bei einer Ladung bis zum Ziel ist das die einzige Zeile mit 80 %.
*
* Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer
* Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
* Steht der Zähler still, wurde auswärts geladen (Schnellladesäule); dann
* bleibt wh null und die Kapazitätsrechnung lässt die Ladung aus.
* Ein Zählerstand 0 ist kein Stand, sondern ein ausgefallener Abruf der
* Wallbox, und bleibt außen vor. Steht der Zähler still, wurde auswärts
* geladen (Schnellladesäule); dann bleibt wh null und die Kapazitätsrechnung
* lässt die Ladung aus.
*/
const LADUNG_LUECKE_S = 3 * 3600;
function sitzungenSQL($seit)
{
$luecke = LADUNG_LUECKE_S;
return "
SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
MIN(soc) AS soc_von, MAX(soc) AS soc_bis,
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc) AS SIGNED) AS zuwachs,
MIN(soc_start) AS soc_von, MAX(soc) AS soc_bis,
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc_start) AS SIGNED) AS zuwachs,
MAX(chg_type) AS art, MAX(target_soc) AS ziel,
ROUND(AVG(chg_kw), 2) AS kw_mittel, ROUND(MAX(chg_kw), 2) AS kw_spitze,
MAX(wb_wh_total) - MIN(wb_wh_total) AS wh,
ROUND(SUM(kwh_teil), 3) AS kwh_fahrzeug,
ROUND(AVG(pv_kw), 2) AS pv_kw, COUNT(*) AS punkte
ROUND(AVG(CASE WHEN laedt THEN chg_kw END), 2) AS kw_mittel,
ROUND(MAX(CASE WHEN laedt THEN chg_kw END), 2) AS kw_spitze,
COALESCE(MAX(NULLIF(wb_wh_total, 0)) - MIN(wh_start), 0) AS wh,
/*
* Fahrzeugseitige Energie nur, wenn sie lückenlos integriert werden
* konnte - siehe kwh_teil. Sonst NULL: eine halbe Summe sähe aus
* wie eine kleine Ladung und verfälschte die Kapazitätsrechnung.
*/
CASE WHEN SUM(zu_lang) = 0 THEN ROUND(SUM(kwh_teil), 3) END AS kwh_fahrzeug,
ROUND(AVG(CASE WHEN laedt THEN pv_kw END), 2) AS pv_kw,
COUNT(*) AS punkte
FROM (
SELECT b.*, SUM(b.neu) OVER (ORDER BY b.datetime) AS sitzung FROM (
SELECT a.*,
a.chg_state = 'CHARGING' AS laedt,
(a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke) AS weiter,
CASE WHEN a.chg_state = 'CHARGING'
AND NOT (a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke)
THEN 1 ELSE 0 END AS neu,
-- Beginn aus der Zeile davor, wenn sie nah genug liegt.
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
AND a.dt_vor <= $luecke
THEN LEAST(a.soc, COALESCE(a.soc_vor, a.soc))
ELSE a.soc END AS soc_start,
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
AND a.dt_vor <= $luecke
THEN COALESCE(NULLIF(a.wh_vor, 0), NULLIF(a.wb_wh_total, 0))
ELSE NULLIF(a.wb_wh_total, 0) END AS wh_start,
/*
* chg_kw ist die Ladeleistung, die das Fahrzeug selbst meldet -
* gemessen HINTER dem Ladegerät. Die API kennt keine
* Kilowattstunden, deshalb wird über die Zeit integriert, je
* Schritt mit dem Mittel aus vorigem und aktuellem Wert.
*
* Über mehr als 30 Minuten ohne Meldung wäre die Fläche unter
* der Kurve erfunden: die Leistung kann in der Zeit von 4 auf
* 10 kW gestiegen sein, ohne dass eine Zeile davon weiß.
* zu_lang merkt sich das, und die Ladung bekommt dann keinen
* fahrzeugseitigen Wert.
*/
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor BETWEEN 1 AND $luecke
THEN (COALESCE(a.chg_kw, 0) + COALESCE(a.kw_vor, 0)) / 2
* a.dt_vor / 3600
ELSE 0 END AS kwh_teil,
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor > 1800
THEN 1 ELSE 0 END AS zu_lang
FROM (
SELECT s.*, SUM(s.neu) OVER (ORDER BY s.datetime) AS sitzung FROM (
SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
wb_wh_total, pv_kw,
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
AND TIMESTAMPDIFF(MINUTE, LAG(datetime) OVER w, datetime) <= 30
THEN 0 ELSE 1 END AS neu,
/*
* Fahrzeugseitige Energie: chg_kw ist die Ladeleistung, die
* das Fahrzeug selbst meldet - gemessen HINTER dem Ladegerät.
* Die API kennt keine Kilowattstunden, deshalb wird hier über
* die Zeit integriert, je Schritt mit dem Mittel aus vorigem
* und aktuellem Wert (Trapez). Beim Laden mit Wechselstrom
* liegt die Leistung fast konstant an, da ist das genau genug.
*
* Die Bedingung hält die Summe innerhalb einer Ladung: über
* eine Lücke von mehr als 30 Minuten hinweg wäre die Fläche
* unter der Kurve frei erfunden.
*/
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
AND TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime)
BETWEEN 1 AND 1800
THEN (COALESCE(chg_kw, 0) + COALESCE(LAG(chg_kw) OVER w, 0)) / 2
* TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) / 3600
ELSE 0 END AS kwh_teil
LAG(chg_state) OVER w AS st_vor,
TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) AS dt_vor,
LAG(soc) OVER w AS soc_vor,
LAG(wb_wh_total) OVER w AS wh_vor,
LAG(chg_kw) OVER w AS kw_vor
FROM skoda WHERE datetime >= $seit
WINDOW w AS (ORDER BY datetime)) s) t
WHERE chg_state = 'CHARGING'
WINDOW w AS (ORDER BY datetime)) a) b) t
-- Die Ladezeilen und die eine Zeile, mit der die Ladung endet.
WHERE laedt OR weiter
GROUP BY sitzung";
}
@@ -166,6 +213,154 @@ function kapazitaetSchaetzen($mysql)
];
}
/**
* Verlauf einer Ladung: Wallbox-Leistung dicht, Ladestand dazwischen gefüllt.
*
* Das Fahrzeug meldet beim Laden an der Wallbox mitunter nur einmal in der
* Stunde, die Wallbox steht in skoda_ladepunkte im Minutentakt (siehe
* solarLog_skoda_ladepunkte.sql). Der Ladestand zwischen zwei Meldungen wird
* nach der geflossenen Energie aufgefüllt, nicht nach der Zeit:
*
* soc(t) = soc_a + (soc_b - soc_a) * (wh(t) - wh_a) / (wh_b - wh_a)
*
* Das braucht weder Akkukapazität noch Wirkungsgrad. Gegengeprüft am
* 13.09.2026: zwischen 56 % (09:59) und 70 % (11:22) ergibt es für 10:29
* 59,6 % - gemeldet waren 60 %. Nach der Zeit wären es 61,1 % gewesen.
*
* Eine Meldung gilt für den Zeitpunkt, zu dem das Fahrzeug sie erfasst hat
* (cap_chg), nicht für den Abruf - dazwischen können Minuten mit 11 kW
* liegen. Der Zählerstand zu diesem Zeitpunkt kommt deshalb aus dem
* Wallbox-Verlauf selbst und nicht aus der Zeile.
*
* Vor der ersten und nach der letzten Meldung bleibt der Ladestand leer:
* hochgerechnet wird nichts.
*
* Liefert [verlauf, meldungen, wallbox]; ohne Punkte in skoda_ladepunkte
* zwei leere Listen - dann zeichnet die Seite wie früher aus den Zeilen.
*/
function ladeverlauf($mysql, $von, $bis)
{
// Welche Wallbox: die, die im Zeitraum Energie abgegeben hat.
$stmt = $mysql->prepare(
"SELECT wallbox, MAX(wh_total) - MIN(wh_total) AS wh, COUNT(*) AS n
FROM skoda_ladepunkte
WHERE datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
GROUP BY wallbox ORDER BY wh DESC, n DESC LIMIT 1");
if (!$stmt) {
return [[], [], null]; // Tabelle fehlt noch
}
$stmt->bind_param("ss", $von, $bis);
$stmt->execute();
$wahl = $stmt->get_result()->fetch_assoc();
if (!$wahl) {
return [[], [], null];
}
$wallbox = $wahl["wallbox"];
$spalte = $wallbox === "og" ? "wbog_wh_total" : "wb_wh_total";
$stmt = $mysql->prepare(
"SELECT datetime, kw, wh_total, quelle FROM skoda_ladepunkte
WHERE wallbox = ? AND datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
ORDER BY datetime");
$stmt->bind_param("sss", $wallbox, $von, $bis);
$stmt->execute();
$res = $stmt->get_result();
$verlauf = [];
while ($z = $res->fetch_assoc()) {
$verlauf[] = [
"datetime" => $z["datetime"],
"t" => strtotime($z["datetime"]),
"kw" => floatval($z["kw"]),
"wh" => $z["wh_total"] === null ? null : intval($z["wh_total"]),
"quelle" => $z["quelle"],
];
}
if (!$verlauf) {
return [[], [], null];
}
// Zählerstand zu einem beliebigen Zeitpunkt, aus dem Verlauf. Vor dem
// ersten Punkt ruhte die Wallbox - dann gilt der Stand, den die Zeile
// selbst trägt.
$zaehlerBei = function ($t, $ersatz) use ($verlauf) {
$vor = null;
foreach ($verlauf as $p) {
if ($p["wh"] === null) {
continue;
}
if ($p["t"] >= $t) {
if ($vor === null) {
return $ersatz ?: $p["wh"];
}
$spanne = $p["t"] - $vor["t"];
return $spanne > 0
? $vor["wh"] + ($p["wh"] - $vor["wh"]) * ($t - $vor["t"]) / $spanne
: $p["wh"];
}
$vor = $p;
}
return $vor ? $vor["wh"] : $ersatz;
};
// Die Meldungen des Fahrzeugs, die den Verlauf einrahmen: die letzte vor
// dem ersten Punkt, alle dazwischen, die erste nach dem letzten.
$erster = $verlauf[0]["datetime"];
$letzter = end($verlauf)["datetime"];
$stmt = $mysql->prepare(
"(SELECT COALESCE(cap_chg, datetime) AS erfasst, soc, chg_kw, chg_state, $spalte AS wh
FROM skoda WHERE COALESCE(cap_chg, datetime) <= ? AND soc IS NOT NULL
ORDER BY erfasst DESC LIMIT 1)
UNION
(SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
FROM skoda WHERE COALESCE(cap_chg, datetime) > ? AND COALESCE(cap_chg, datetime) < ?
AND soc IS NOT NULL)
UNION
(SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
FROM skoda WHERE COALESCE(cap_chg, datetime) >= ? AND soc IS NOT NULL
ORDER BY 1 LIMIT 1)
ORDER BY erfasst");
$stmt->bind_param("ssss", $erster, $erster, $letzter, $letzter);
$stmt->execute();
$res = $stmt->get_result();
$meldungen = [];
while ($z = $res->fetch_assoc()) {
$t = strtotime($z["erfasst"]);
$meldungen[] = [
"datetime" => $z["erfasst"],
"t" => $t,
"soc" => intval($z["soc"]),
"chg_kw" => $z["chg_kw"] === null ? null : floatval($z["chg_kw"]),
"chg_state"=> $z["chg_state"],
"wh" => $zaehlerBei($t, intval($z["wh"])),
];
}
// Ladestand je Punkt, zwischen den beiden Meldungen, die ihn einrahmen.
foreach ($verlauf as &$p) {
$p["soc"] = null;
for ($i = 1; $p["wh"] !== null && $i < count($meldungen); $i++) {
$a = $meldungen[$i - 1];
$b = $meldungen[$i];
if ($p["t"] < $a["t"] || $p["t"] > $b["t"]) {
continue;
}
$spanne = $b["wh"] - $a["wh"];
$p["soc"] = $spanne > 0
? round($a["soc"] + ($b["soc"] - $a["soc"]) * ($p["wh"] - $a["wh"]) / $spanne, 1)
: floatval($a["soc"]);
break;
}
unset($p["t"]);
}
unset($p);
foreach ($meldungen as &$m) {
unset($m["t"], $m["wh"]);
}
unset($m);
return [$verlauf, $meldungen, $wallbox];
}
/** Median eines nicht leeren Zahlenfeldes. */
function median($werte)
{
@@ -236,6 +431,9 @@ case "kurve":
while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; }
$antwort["von"] = $von;
$antwort["bis"] = $bis;
list($antwort["verlauf"], $antwort["meldungen"], $antwort["wallbox"])
= ladeverlauf($mysql, $von, $bis);
break;
/* ------------------------------------------------------------ gesundheit -- */
@@ -358,6 +556,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."];
+39 -73
View File
@@ -14,28 +14,25 @@
* ein vorausgeladenes Bild oder einen Klick auf eine fremde Seite fernsteuerbar.
*
* ---------------------------------------------------------------------------
* Zwei Schlüssel, zwei Kontingente
* Ein Kontingent für alles
*
* Die API begrenzt auf 20 Anfragen je Stunde **und Schlüssel**, und Befehle
* zählen genauso mit wie Abrufe. Steht in skoda.conf ein eigener
* CMD_API_KEY, hat die Steuerung damit ihr eigenes Kontingent und nimmt dem
* Abruf des Fahrzeugzustands nichts weg. Beide Schlüssel werden in der
* MyŠkoda-App angelegt.
* Die API begrenzt auf 20 Anfragen je Stunde, und Befehle zählen genauso mit
* wie Abrufe. Die Doku schreibt "je Schlüssel" - gemessen stimmt das nicht,
* der Zähler gilt für alle Schlüssel des Kontos zusammen (die Rechnung steht
* bei _LIMIT in gatherSkodaData.py). Mehrere Schlüssel bringen also kein
* zusätzliches Kontingent.
*
* Ohne eigenen Schlüssel greift dieses Skript auf API_KEY zurück - dann
* teilen sich beide ein Kontingent, und weil sie in getrennten Prozessen
* laufen und keinen gemeinsamen Zähler haben, ist es fest aufgeteilt:
* Abruf und Steuerung laufen in getrennten Prozessen ohne gemeinsamen Zähler,
* deshalb ist fest aufgeteilt:
*
* eigener CMD_API_KEY nur API_KEY
* Abruf 18 / Stunde 14 / Stunde
* Befehle 15 / Stunde 4 / Stunde
* Reserve 2 / Stunde 2 / Stunde
* Abruf 14 / Stunde (gatherSkodaData.py)
* Befehle 4 / Stunde
* Reserve 2 / Stunde
*
* gatherSkodaData.py stellt sich selbst um, sobald der zweite Schlüssel in
* skoda.conf auftaucht - dort ist nichts von Hand nachzuziehen.
*
* Der zweite Schlüssel ist kein Freibrief, den Abruf zu beschleunigen: die
* Kachelrichtlinie zielt auf die Last, nicht auf die Buchführung.
* Ein eigener CMD_API_KEY trennt damit nicht die Kontingente, sondern nur,
* wer womit fragt - er lässt sich einzeln widerrufen, ohne den Abruf
* mitzunehmen. Welchen dieses Skript nimmt, entscheidet
* skodaBefehlsSchluessel(): den eigenen, wenn es ihn gibt, sonst API_KEY.
* ---------------------------------------------------------------------------
*
* Nicht angebunden ist die Standheizung. Ihr Start verlangt laut Spec die
@@ -46,24 +43,13 @@
*/
require_once("../helper.php");
// Wo skoda.conf liegt, wie sie aussieht, welcher Schlüssel die Befehle
// schickt und wie viele je Stunde erlaubt sind: alles dort, damit der Reiter
// in den Einstellungen und dieser Endpunkt dieselbe Wahrheit benutzen.
require_once("../restricted/skodaKeys.php");
header('Content-Type: application/json; charset=utf-8');
const API_BASIS = "https://public.api.connect.skoda-auto.cz/api/v1/vehicles/";
const KONFIG = "/volume1/homes/wagner/SolarManager/skoda.conf";
const KENNUNG = "SmartController-SkodaSteuerung/1.0 (+https://nas.el-wa.org/smart)";
/** Befehle je Stunde - siehe Kopfkommentar. Hängt davon ab, ob die
* Steuerung einen eigenen Schlüssel hat oder ihn mit dem Abruf teilt. */
const BEFEHLE_EIGENER_SCHLUESSEL = 15;
const BEFEHLE_GETEILT = 4;
/** Wo der Stundenzähler liegt. Muss für den Webserver beschreibbar sein. */
const ZAEHLER_ORTE = [
"/volume1/web/smart/tiles", // falls per synoacltool freigegeben
"/var/services/tmp/smart-tiles", // sonst
];
/*
* Die erlaubten Befehle. Der Schlüssel kommt aus dem Formular, der Pfad
* niemals - so kann über den Parameter kein beliebiger Endpunkt der API
@@ -93,18 +79,16 @@ if (!isset(BEFEHLE[$name])) {
}
$befehl = BEFEHLE[$name];
$konfig = konfigLesen();
$konfig = skodaBefehlsSchluessel();
if (!$konfig) {
antwort(503, false, "Kein API-Schlüssel hinterlegt - skoda.conf fehlt.");
antwort(503, false, "Kein API-Schlüssel hinterlegt - unter Einstellungen "
. "▸ Fahrzeug lässt sich einer eintragen.");
}
$grenze = $konfig["EIGENER"] ? BEFEHLE_EIGENER_SCHLUESSEL : BEFEHLE_GETEILT;
if (!budgetFrei($grenze)) {
if (!budgetFrei(SKODA_BEFEHLE)) {
antwort(429, false, "Stündliches Kontingent für Befehle erschöpft ("
. $grenze . " je Stunde)."
. ($konfig["EIGENER"] ? "" : " Ein eigener CMD_API_KEY in skoda.conf"
. " würde der Steuerung ein eigenes Kontingent geben.")
. " Später erneut versuchen.");
. SKODA_BEFEHLE . " je Stunde). Es teilt sich die 20 Anfragen der API"
. " mit dem Abruf des Fahrzeugzustands. Später erneut versuchen.");
}
/* ---------------------------------------------------------------- Ausführen */
@@ -121,8 +105,9 @@ if ($name === "klima_start") {
list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf);
if ($status === 202) {
skodaAbrufAnstossen();
antwort(200, true, $befehl["text"] . ". Das Fahrzeug führt den Befehl aus; "
. "in der Anzeige erscheint es beim nächsten Abruf.");
. "in der Anzeige erscheint es in einer halben Minute.");
}
antwort(200, false, fehlertext($status, $inhalt));
@@ -136,39 +121,19 @@ function antwort($code, $ok, $text)
exit;
}
/** API-Schlüssel und VIN aus skoda.conf lesen. Format: NAME=wert je Zeile. */
function konfigLesen()
{
if (!is_readable(KONFIG)) {
return null;
}
$werte = [];
foreach (file(KONFIG, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $zeile) {
$zeile = trim($zeile);
if ($zeile === "" || $zeile[0] === "#" || strpos($zeile, "=") === false) {
continue;
}
list($k, $v) = explode("=", $zeile, 2);
$werte[strtoupper(trim($k))] = trim(trim($v), "\"'");
}
if (empty($werte["API_KEY"]) || empty($werte["VIN"])) {
return null;
}
// Eigener Schluessel fuer die Steuerung, wenn hinterlegt - sonst der
// gemeinsame. Welcher es wurde, entscheidet unten ueber das Budget.
$werte["EIGENER"] = !empty($werte["CMD_API_KEY"]);
$werte["SCHLUESSEL"] = $werte["EIGENER"] ? $werte["CMD_API_KEY"] : $werte["API_KEY"];
return $werte;
}
/**
* Stundenzähler für Befehle. Gleiches Verfahren wie die Bremse in tile.php:
* eine kleine Datei, gegen gleichzeitige Zugriffe mit flock abgesichert.
*
* Das Fenster ist fest: es beginnt mit dem ersten Befehl und läuft eine Stunde.
* Die API zählt gleitend, hier stehen also im Grenzfall zwei Fenster
* nebeneinander - garantiert sind die $grenze Befehle, mehr geht auf das Risiko
* eines 429 von der API selbst.
*/
function budgetFrei($grenze)
{
$ort = null;
foreach (ZAEHLER_ORTE as $kandidat) {
foreach (SKODA_ARBEITSORTE as $kandidat) {
if (is_dir($kandidat) && is_writable($kandidat)) {
$ort = $kandidat;
break;
@@ -180,7 +145,7 @@ function budgetFrei($grenze)
return true;
}
$griff = fopen($ort . "/.befehle.json", "c+");
$griff = fopen($ort . "/" . SKODA_ZAEHLER_DATEI, "c+");
if (!$griff || !flock($griff, LOCK_EX)) {
return true;
}
@@ -207,16 +172,16 @@ function budgetFrei($grenze)
/** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */
function senden($konfig, $pfad, $rumpf)
{
$url = API_BASIS . rawurlencode($konfig["VIN"]) . "/" . $pfad;
$url = SKODA_API . rawurlencode($konfig["vin"]) . "/" . $pfad;
$kopf = [
"X-API-Key: " . $konfig["SCHLUESSEL"],
"X-API-Key: " . $konfig["schluessel"],
"Accept: application/json",
];
$ch = curl_init($url);
$optionen = [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => KENNUNG,
CURLOPT_USERAGENT => SKODA_KENNUNG,
CURLOPT_TIMEOUT => 20,
CURLOPT_CONNECTTIMEOUT => 5,
];
@@ -257,7 +222,8 @@ function fehlertext($status, $inhalt)
switch ($status) {
case 0: return "Die Škoda-API ist nicht erreichbar.";
case 400: return "Die API hat den Befehl abgelehnt." . $detail;
case 401: return "Der API-Schlüssel ist abgelaufen - in der MyŠkoda-App erneuern.";
case 401: return "Der API-Schlüssel ist abgelaufen - in der MyŠkoda-App "
. "erneuern und unter Einstellungen ▸ Fahrzeug eintragen.";
case 403: return "Der Schlüssel darf diesen Befehl nicht ausführen." . $detail;
case 404: return "Fahrzeug nicht gefunden - stimmt die VIN in skoda.conf?";
case 422: return "Das Fahrzeug kann das gerade nicht." . $detail;
+1732 -127
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS `automations` (
`weekdays` tinyint(3) unsigned NOT NULL DEFAULT 127 COMMENT 'Bitmaske: Bit 0 = Montag ... Bit 6 = Sonntag',
`on_vacation` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch in den Ferien ausfuehren',
`on_holiday` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch an Feiertagen ausfuehren',
`next_day` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Wochentage, Ferien und Feiertage gelten fuer morgen (Vorabend). Nachgeruestet mit vorabend.sql im SolarManager',
`force_once` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Am Ende des Zeitraums auf jeden Fall ausfuehren',
`lockout_secs` int(11) NOT NULL DEFAULT 60 COMMENT 'Sperrzeit: so lange nach einer Ausloesung wird nicht erneut geschaltet. Gegen Messwerte, die um die Schwelle pendeln - jedes Ueberschreiten waere sonst eine echte Flanke. Der Editor bietet 0, 60 und 900 an',
`cond_met` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'War die Bedingung beim letzten Durchlauf erfuellt? Nur die steigende Flanke loest aus, sonst wuerde Temperatur groesser 22 im Sekundentakt feuern',
+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,
File diff suppressed because it is too large Load Diff
+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 });
}
});
+19 -15
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,13 +144,24 @@ 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
// haette bedeutet, dass dieselbe Wallbox an zwei Stellen verschieden
// gerundet dasteht.
if (wert.format === "leistung") {
const zahl = Number(text);
//
// "leistung_kw" ist dasselbe fuer Quellen, die schon Kilowatt melden -
// die Wallboxen im SolarManager. Umgerechnet wird hier in Watt, damit
// es eine Regel fuer beide bleibt.
if (wert.format === "leistung" || wert.format === "leistung_kw") {
const zahl = Number(text) * (wert.format === "leistung_kw" ? 1000 : 1);
if (text === "" || isNaN(zahl)) return null;
// Ersetzt wird nur das Trennzeichen: auf der Kachel steht ein
// schmales Leerzeichen, im Rest der Anwendung ein gewoehnliches.
@@ -203,19 +220,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 };
}
+1139
View File
File diff suppressed because it is too large Load Diff
+481 -80
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,350 @@ function planeLiveAnzeige() {
let letzterStand = null; // letzte Zeile aus der Tabelle
function zeichneLive() {
const stand = letzterStand;
/*
* 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 };
// 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";
/* 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"];
// 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);
/* Ladestand, unter dem die Platte rot wird. Reichweitenangst beginnt nicht
bei null, sondern dort, wo eine Fahrt zu planen ist. */
const AKKU_NIEDRIG = 15;
// 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) + " %" : "";
const LADEART = { AC: "Wechselstrom", DC: "Gleichstrom" };
// 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;
}
$("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 restMin = mqZahl("evRemChrgTime") ?? (stand ? Number(stand.chg_rem_min) : null);
$("skodaRestzeit").textContent = laedt ? dauer(restMin) : "";
$("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",
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) : "";
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"];
// Ladezustand als Kennzeichen
const ZUSTAND = {
/* Das Zeichen der Klimakachel sagt, was gerade geschieht, statt nur "Klima" zu
bebildern: Schneeflocke fürs Kühlen, Sonne fürs Heizen, Wind fürs Lüften.
Der Lüfter steht für alles Übrige - aus, fertig, unbekannt. */
const KLIMA_ZEICHEN = {
COOLING: "bi-snow",
HEATING: "bi-thermometer-sun",
HEATING_AUXILIARY: "bi-fire",
VENTILATION: "bi-wind",
COMPLETED: "bi-check2-circle"
};
const KLIMA_ZEICHEN_SONST = "bi-fan";
const LADEZUSTAND = {
CHARGING: ["lädt", "text-bg-success"],
CONSERVING: ["hält Ladung", "text-bg-secondary"],
READY_FOR_CHARGING: ["bereit", "text-bg-info"],
CONNECT_CABLE: ["nicht angesteckt", "text-bg-secondary"],
CHARGING_INTERRUPTED: ["unterbrochen", "text-bg-warning"],
DISCHARGING: ["entlädt", "text-bg-warning"]
};
/* ------------------------------------------------------- kleine Werkzeuge */
/** Zahl aus einem Tabellenfeld; leer, null und "" ergeben null statt 0. */
function feldZahl(wert) {
if (wert === null || wert === undefined || wert === "") return null;
const z = Number(wert);
return Number.isFinite(z) ? z : null;
}
/** Text eines Elements setzen, sofern die Seite es überhaupt hat. */
function setzeText(id, text) {
const el = $(id);
if (el) el.textContent = text;
}
/** Eine Klasse an einem Element setzen oder entfernen. */
function schalteKlasse(id, klasse, an) {
const el = $(id);
if (el) el.classList.toggle(klasse, !!an);
}
/** Genau eine Klasse aus einer Gruppe setzen, die übrigen entfernen. */
function setzeAusGruppe(el, gruppe, aktiv) {
if (!el) return;
gruppe.forEach(k => el.classList.toggle(k, k === aktiv));
}
/** "bis 14:20 Uhr" - der Zeitpunkt hinter der gemeldeten Restladezeit. */
function fertigUm(minuten) {
const m = Number(minuten);
if (!Number.isFinite(m) || m <= 0) return "";
const ende = new Date(Date.now() + m * 60000);
return "bis " + ende.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) + " Uhr";
}
/** y-Kante eines Ladestands auf der Platte: 0 % unten, 100 % oben. */
function akkuKante(prozent) {
const p = Math.max(0, Math.min(100, Number(prozent) || 0));
return AKKU.oben + AKKU.hoehe * (1 - p / 100);
}
/** Ein Rechteck vom Boden der Platte bis zum Ladestand aufziehen. */
function setzeFuellstand(el, prozent) {
if (!el) return;
const y = akkuKante(prozent);
el.setAttribute("y", y.toFixed(1));
el.setAttribute("height", (AKKU.oben + AKKU.hoehe - y).toFixed(1));
}
/* ------------------------------------------------------------- Sammelstelle */
/**
* Alles, was die Fahrzeugkachel zeigt, an einer Stelle zusammengetragen.
* MQTT hat überall Vorrang, die Tabelle ist der Rückfall - sie kann eine
* Stunde alt sein, MQTT ist von eben.
*/
function liveWerte() {
const stand = letzterStand;
const zustand = mqText("evChgState") || (stand ? stand.chg_state : null);
const laedt = zustand === "CHARGING";
// 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) {
const roh = feldZahl(stand.range_m);
if (roh !== null) km = roh > 1500 ? roh / 1000 : roh;
}
// 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 ? feldZahl(stand.chg_kw) : null;
const pvW = mqZahl("P_PV");
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,
/*
* Fährt gerade. Kommt allein aus der Tabelle - der Broker kennt den
* Parkzustand nicht, unter solarManager/ev* stehen nur Ladestand,
* Reichweite und Ladung. Der Sammler fragt das Fahrzeug unterwegs alle
* vier Minuten, die Seite lädt jede Minute nach: die Anzeige hinkt also
* um wenige Minuten hinterher. Für "rollt gerade" reicht das; ein
* Tachometer ist es nicht.
*/
faehrt: !!(stand && stand.park_state === "IN_MOTION")
};
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);
/* ------------------------------------------------------------ Ostergruß */
// 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.
/*
* Ganz selten liegt eine Bananenschale auf der Fahrbahn.
*
* Geprüft wird alle 20 Sekunden, und nur während der Fahrt; die Chance liegt
* bei zwei Prozent. Das ist im Mittel einmal in gut sechzehn Minuten Fahrt -
* selten genug, dass man es kaum erwartet, und häufig genug, dass es
* irgendwann jemand sieht.
*
* Der Auftritt dauert anderthalb Sekunden, danach fällt die Klasse wieder ab.
* Wer Bewegungen abbestellt hat, bekommt ihn gar nicht erst - ein
* schleuderndes Auto ist genau das, was damit gemeint ist.
*/
const BANANE_TAKT = 20000;
const BANANE_CHANCE = 0.02;
const BANANE_DAUER = 1600;
function bananeVielleicht() {
const bild = $("skodaAuto");
if (!bild || !bild.classList.contains("faehrt")) return;
if (bild.classList.contains("banane")) return; // läuft schon
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (Math.random() >= BANANE_CHANCE) return;
bild.classList.add("banane");
setTimeout(function () { bild.classList.remove("banane"); }, BANANE_DAUER);
}
/**
* Fahrt anzeigen: Fahrtwind am Fahrzeug, Abzeichen im Kopf der Kachel.
*
* Zwei Wege für dieselbe Aussage, und beide werden gebraucht. Die Streifen
* sieht man aus dem Augenwinkel, ohne zu lesen - dafür sind sie da. Wer die
* Seite vorgelesen bekommt oder Bewegungen abgeschaltet hat, hat von ihnen
* nichts; deshalb steht es zusätzlich als Wort neben Schloss und Ladezustand.
*/
function zeichneFahrt(w) {
const bild = $("skodaAuto");
if (bild) {
bild.classList.toggle("faehrt", w.faehrt);
// Hält der Wagen mitten im Schleudern an, bleibt sonst eine Schale auf
// der Fahrbahn liegen, die niemand mehr überfährt.
if (!w.faehrt) bild.classList.remove("banane");
}
const abzeichen = $("skodaUnterwegs");
if (abzeichen) {
abzeichen.hidden = !w.faehrt;
}
}
/**
* 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);
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) + "%";
$("skodaQuelleText").textContent = anteil + " % Sonne, " + (100 - anteil) + " % Netz";
box.style.display = "";
} else {
box.style.display = "none";
}
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);
zeichneFahrt(w);
zeichneLadequelle(w);
}
/* ------------------------------------------------------------- Diagramme */
@@ -328,7 +649,10 @@ function baueKurve() {
options: {
maintainAspectRatio: false,
animation: false,
interaction: { mode: "index", intersect: false },
// "nearest" statt "index": Wallbox-Verlauf und Fahrzeugmeldungen haben
// verschiedene Zeitpunkte, der n-te Wert der einen Reihe gehört nicht zum
// n-ten der anderen.
interaction: { mode: "nearest", axis: "x", intersect: false },
scales: {
// 'timestack' ist die Zeitachse, die diese Seite mitbringt
// (js/chartjs-scale-timestack.min.js). Chart.js' eingebautes 'time'
@@ -691,7 +1015,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 +1027,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 +1035,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 +1081,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");
@@ -789,6 +1154,38 @@ async function ladeKurve() {
// Date-Objekten - die nimmt sie stillschweigend nicht an und zeigt
// stattdessen den heutigen Tag.
const zeit = (p) => new Date(String(p.datetime).replace(" ", "T")).getTime();
// Mit Wallbox-Verlauf (skoda_ladepunkte): die Leistung im Minutentakt,
// der Ladestand dazwischen nach der geflossenen Energie aufgefüllt, und
// die echten Meldungen des Fahrzeugs als Punkte darauf - damit sichtbar
// bleibt, was gemessen und was abgeleitet ist. Das Fahrzeug meldet beim
// Laden mitunter nur einmal in der Stunde; eine Linie durch diese Punkte
// allein erfände den Verlauf dazwischen.
if (d.verlauf && d.verlauf.length) {
const imBild = (m) => zeit(m) >= zeit(d.verlauf[0]) - 600000
&& zeit(m) <= zeit(d.verlauf[d.verlauf.length - 1]) + 600000;
const meldungen = (d.meldungen || []).filter(imBild);
kurveChart.data.datasets = [
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
borderWidth: 2, pointRadius: 0, fill: "origin", tension: 0,
data: d.verlauf.map(p => ({ x: zeit(p), y: +p.kw })) },
{ label: "Ladestand, aufgefüllt", yAxisID: "y1", borderColor: "#f0b429", borderWidth: 2,
borderDash: [5, 4], pointRadius: 0, tension: 0, spanGaps: false,
data: d.verlauf.map(p => ({ x: zeit(p), y: p.soc === null ? null : +p.soc })) },
{ label: "Ladestand laut Fahrzeug", yAxisID: "y1", showLine: false,
borderColor: "#f0b429", backgroundColor: "#f0b429", pointRadius: 4, pointHoverRadius: 6,
data: meldungen.map(m => ({ x: zeit(m), y: +m.soc })) },
{ label: "Leistung laut Fahrzeug", yAxisID: "y", showLine: false,
borderColor: "#7aa7c7", backgroundColor: "#7aa7c7", pointRadius: 3, pointStyle: "rectRot",
data: meldungen.filter(m => m.chg_kw !== null && m.chg_state === "CHARGING")
.map(m => ({ x: zeit(m), y: +m.chg_kw })) }
];
kurveChart.update();
return;
}
// Ohne Wallbox-Verlauf - Ladungen von vor der Aufzeichnung - wie bisher
// aus den Zeilen der Fahrzeugtabelle.
kurveChart.data.datasets = [
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
borderWidth: 2, pointRadius: 0, fill: "origin", tension: .25,
@@ -1137,6 +1534,7 @@ function verdrahteBefehle(bereich) {
function alleLaden() {
ladeLive();
ladeStrecke();
ladeLadungen();
ladeKurve();
ladeGesundheit();
@@ -1161,8 +1559,11 @@ 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);
setInterval(bananeVielleicht, BANANE_TAKT);
// Leaflet rechnet die Kachelanordnung selbst neu, sobald es erfährt, dass
// sein Container eine andere Größe hat.
+12 -2
View File
@@ -339,7 +339,14 @@ const solarSVG = {
} else {
setDisplay("heatEG", "none");
}
if (plugev != "no car") {
// solarManager/evPlug kommt als 1 oder 0, wie evPlugOG. Frueher kam
// hier der Text der Wallbox an ("no car", "charging", ...); Number()
// machte daraus NaN, und NaN != "no car" ist immer wahr - das
// Steckersymbol stand dauerhaft da. Den Text weiter zu verstehen
// kostet eine Zeile und haelt die Anzeige richtig, egal ob der
// Manager schon neu gestartet ist oder nicht.
const plugText = mqttData["solarManager/evPlug"];
if (isNaN(plugev) ? (plugText !== undefined && plugText !== "no car") : plugev > 0) {
setDisplay("evPlug", "");
} else {
setDisplay("evPlug", "none");
@@ -593,7 +600,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();
}
+502 -27
View File
@@ -54,6 +54,11 @@ function meshDb()
* danach liegen kann - deshalb dieselben drei Faelle noch einmal mal zwei:
* "+ 00:30" eine halbe Stunde nach Sonnenaufgang, "ab - 00:30" ab einer
* halben Stunde davor, "vor + 00:30" bis eine halbe Stunde danach.
*
* Dieselbe Schreibweise traegt die Verkettung. Das gerechnete Geraet
* "Automatiken" fuehrt jede Automatik als Messwert, ihr Wert ist der
* Zeitpunkt der letzten Ausloesung (Datentyp `elapsed`) - "Wecker Magdalena
* + 00:10" heisst zehn Minuten danach. Ein Minus gibt es dort nicht.
*/
function operatorsForType($type)
{
@@ -67,6 +72,10 @@ function operatorsForType($type)
return ["=", ">=", "<"];
case "deltatime": // Sonnenauf-/-untergang: Wert ist ein Versatz
return ["+", "-", ">=+", ">=-", "<+", "<-"];
case "elapsed": // andere Automatik: Wert ist ein Versatz danach
// Nur die drei Formen "danach". Ein Minus gibt es nicht: vor dem
// Auslöser kann nichts liegen, das erst der Auslöser anstößt.
return ["+", ">=+", "<+"];
case "bool":
return ["="]; // JA/NEIN steht im Wertfeld, nicht im Operator
default:
@@ -84,6 +93,115 @@ function operatorChoices($type)
return $liste;
}
/**
* Die aktuellen Werte zu einer Liste von Messwerten.
*
* Der Editor zeigt neben jeder Bedingung, was der Messwert gerade sagt, und
* holt sich das alle paar Sekunden nach. Bewusst aus actor_states und nicht
* aus MQTT: ueber den Broker kommt nur ein Teil der Geraete: die Jalousien
* haengen an der Tahoma-Box, die gerechneten Werte an gar nichts. Der Runner
* schreibt dagegen jeden Messwert zurueck, egal ueber welchen Weg er ihn
* geholt hat - eine Tabelle, ein Zugriff, alle Geraetearten.
*
* Die Ids kommen aus dem Browser und werden deshalb zu Zahlen gemacht, bevor
* sie in die Abfrage gehen.
*/
function stateValues(array $ids)
{
$ids = array_values(array_unique(array_filter(array_map("intval", $ids))));
if (!$ids) {
return [];
}
$db = meshDb();
$liste = implode(",", $ids);
$res = $db->query("SELECT id, current_value, unit FROM actor_states
WHERE id IN (" . $liste . ")");
$werte = [];
while ($row = $res->fetch_assoc()) {
$werte[intval($row["id"])] = [
"value" => $row["current_value"],
"unit" => $row["unit"],
];
}
return $werte;
}
/**
* Ferien und Feiertage sind dreiwertig, nicht ja/nein.
*
* Zwei Zustaende gab es immer: "egal" (der Tag aendert nichts) und "nie"
* (an solchen Tagen laeuft die Automatik nicht - so ist "werktags" gebaut).
* "Zusaetzlich" ist der dritte und zaehlt wie ein passender Wochentag.
*
* Ohne ihn liess sich "an Wochenenden und Feiertagen" gar nicht schreiben:
* die Wochentagsmaske kennt nur Samstag und Sonntag, und ein Feiertag am
* Dienstag ist eben ein Dienstag. Jetzt genuegt Sa+So angehakt und
* "Feiertage: zusaetzlich".
*
* Ein Verbot schlaegt eine Erweiterung: wer in den Ferien nie laeuft und an
* Feiertagen zusaetzlich, laeuft an einem Feiertag in den Ferien nicht.
* Andersherum liesse sich "nie" nicht mehr verlassen. Der Runner haelt
* dieselbe Reihenfolge ein (tag_passt in autoaction_runner.py).
*/
function kalendertagChoices($was, $wann)
{
// Drei kurze Worte auf einer Achse, von eng nach weit: nie - egal -
// immer. Sie stehen als Schalterreihe neben "Ferien" bzw. "Feiertage",
// die Erklaerung haengt als title daran und der ganze Rahmen steht
// darunter noch einmal als Satz.
//
// Vorher stand die Erklaerung *im* Etikett ("zusaetzlich, auch am
// falschen Wochentag"). Ein Auswahlfeld, dessen Eintraege Saetze sind,
// liest sich wie eine Bedienungsanleitung und zeigt trotzdem nur den
// gewaehlten Satz - man sieht nie, was es sonst noch gibt.
return [
["value" => 0, "label" => "nie",
"title" => "Läuft " . $wann . " nicht."],
["value" => 1, "label" => "egal",
"title" => $was . " ändern nichts."],
["value" => 2, "label" => "immer",
"title" => "Läuft " . $wann . ", auch wenn kein Wochentag angehakt ist."],
];
}
/**
* Vorlagen fuer die Tagesauswahl - die drei Faelle, die man wirklich meint.
*
* "Werktags" heisst nicht nur Mo-Fr, sondern auch "nicht an Feiertagen", und
* "Wochenende" heisst fast immer "Wochenende und Feiertage". Wer das aus
* Wochentagsmaske und zwei Dreifachschaltern von Hand zusammensetzt, vergisst
* den zweiten Teil - deshalb steht er hier mit drin.
*/
function tagVorlagen()
{
return [
["label" => "täglich", "weekdays" => 127, "on_vacation" => 1, "on_holiday" => 1,
"title" => "Jeden Tag, Feiertage eingeschlossen."],
["label" => "werktags", "weekdays" => 31, "on_vacation" => 1, "on_holiday" => 0,
"title" => "Montag bis Freitag, aber nicht an Feiertagen."],
["label" => "Wochenende", "weekdays" => 96, "on_vacation" => 1, "on_holiday" => 2,
"title" => "Samstag und Sonntag, dazu alle Feiertage."],
];
}
/**
* Ein Kalenderwert aus dem Editor - alles Krumme wird zu "egal".
*
* is_numeric davor, und zwar mit Absicht: intval("") ist 0, und 0 heisst hier
* "nie". Eine leere oder verunglueckte Angabe wuerde eine Automatik sonst
* stillschweigend an Feiertagen abschalten - eine Regelaenderung, die niemand
* angeordnet hat und die man erst am naechsten Feiertag bemerkt. "Egal" ist
* die harmlose Vorgabe: der Tag aendert dann nichts.
*/
function kalendertagWert($wert)
{
if (!is_numeric($wert)) {
return 1;
}
$zahl = intval($wert);
return in_array($zahl, [0, 1, 2], true) ? $zahl : 1;
}
/**
* Sperrzeit: so lange nach einer Ausloesung wird nicht erneut geschaltet.
*
@@ -157,6 +275,7 @@ function inputForType($type, $hasOptions)
return "number";
case "time":
case "deltatime":
case "elapsed":
return "time";
case "date":
return "date";
@@ -192,6 +311,14 @@ function operatorLabel($op, $type = "")
return $beschriftung[$op];
}
}
if ($type === "elapsed") {
// Dieselben Wörter wie beim Sonnenstand, damit ein Versatz überall
// gleich gelesen wird: "Wecker + 00:10" sind zehn Minuten danach.
$beschriftung = ["+" => "+", ">=+" => "ab +", "<+" => "vor +"];
if (isset($beschriftung[$op])) {
return $beschriftung[$op];
}
}
return $op === "!=" ? "" : $op;
}
@@ -316,17 +443,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 +468,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 +696,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 +724,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"],
];
}
@@ -522,9 +801,11 @@ function emptyAutomation($floor)
"window_from" => "00:00",
"window_to" => "23:59",
"weekdays" => 127,
"on_vacation" => true,
"on_holiday" => true,
"on_vacation" => 1,
"on_holiday" => 1,
"next_day" => false,
"force_once" => false,
"once_per_day" => false,
"lockout_secs" => 60,
"last_run" => null,
"conditions" => [],
@@ -553,9 +834,15 @@ function loadAutomation($id)
"window_from" => substr($row["window_from"], 0, 5),
"window_to" => substr($row["window_to"], 0, 5),
"weekdays" => intval($row["weekdays"]),
"on_vacation" => (bool)$row["on_vacation"],
"on_holiday" => (bool)$row["on_holiday"],
"on_vacation" => intval($row["on_vacation"]),
"on_holiday" => intval($row["on_holiday"]),
// Vorabend: der Rahmen gilt fuer morgen. Siehe vorabend.sql im
// SolarManager; ?? 0 wie bei once_per_day, falls die Spalte fehlt.
"next_day" => (bool)($row["next_day"] ?? 0),
"force_once" => (bool)$row["force_once"],
// ?? 0, damit der Editor auch vor der Migration noch aufgeht:
// rahmen_erweitern.sql legt die Spalte erst an.
"once_per_day" => (bool)($row["once_per_day"] ?? 0),
"lockout_secs" => intval($row["lockout_secs"]),
"last_run" => $row["last_run"],
"conditions" => [],
@@ -633,15 +920,18 @@ function saveAutomation($data)
if (!$actions) {
throw new InvalidArgumentException("Ohne Aktion haette die Automatik nichts zu tun.");
}
pruefeKreis($data["id"] ?? 0, $conditions);
$id = intval($data["id"] ?? 0);
$enabled = !empty($data["enabled"]) ? 1 : 0;
$windowFrom = normalizeTime($data["window_from"] ?? "00:00", "00:00:00");
$windowTo = normalizeTime($data["window_to"] ?? "23:59", "23:59:00");
$weekdays = intval($data["weekdays"] ?? 127) & 127;
$onVacation = !empty($data["on_vacation"]) ? 1 : 0;
$onHoliday = !empty($data["on_holiday"]) ? 1 : 0;
$onVacation = kalendertagWert($data["on_vacation"] ?? 1);
$onHoliday = kalendertagWert($data["on_holiday"] ?? 1);
$nextDay = !empty($data["next_day"]) ? 1 : 0;
$forceOnce = !empty($data["force_once"]) ? 1 : 0;
$oncePerDay = !empty($data["once_per_day"]) ? 1 : 0;
// Nur die angebotenen Stufen, sonst die Vorgabe. Eine krumme Zahl kaeme
// hier nur aus einer selbstgebauten Anfrage.
$lockout = intval($data["lockout_secs"] ?? 60);
@@ -672,11 +962,12 @@ function saveAutomation($data)
// Zeitstempel, dass er sein Regelwerk neu laden muss.
$stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?,
window_from = ?, window_to = ?, weekdays = ?,
on_vacation = ?, on_holiday = ?, force_once = ?,
lockout_secs = ?, changed = NOW()
on_vacation = ?, on_holiday = ?, next_day = ?, force_once = ?,
once_per_day = ?, lockout_secs = ?, changed = NOW()
WHERE id = ?");
$stmt->bind_param("ssissiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $forceOnce, $lockout, $id);
$stmt->bind_param("ssissiiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $nextDay, $forceOnce,
$oncePerDay, $lockout, $id);
$stmt->execute();
$stmt->close();
$db->query("DELETE FROM automation_conditions WHERE automation_id = " . $id);
@@ -684,10 +975,12 @@ function saveAutomation($data)
} else {
$stmt = $db->prepare("INSERT INTO automations
(name, floor, enabled, window_from, window_to, weekdays,
on_vacation, on_holiday, force_once, lockout_secs)
VALUES (?,?,?,?,?,?,?,?,?,?)");
$stmt->bind_param("ssissiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $forceOnce, $lockout);
on_vacation, on_holiday, next_day, force_once,
once_per_day, lockout_secs)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
$stmt->bind_param("ssissiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $nextDay, $forceOnce,
$oncePerDay, $lockout);
$stmt->execute();
$id = $db->insert_id;
$stmt->close();
@@ -773,9 +1066,186 @@ function normalizeTime($value, $fallback)
return $fallback;
}
function deleteAutomation($id)
/**
* Welcher Messwert des gerechneten Geraets "Automatiken" auf welche Automatik
* zeigt: state_id => automation_id.
*
* Die Zeilen legt der Runner an und fuehrt sie nach (ausloeser_nachfuehren()),
* hier werden sie nur gelesen. Gefuehrt wird nach der Kennung in der URL
* ("auto:15") und nicht nach dem Namen - wer umbenennt, soll die abhaengigen
* Bedingungen behalten.
*/
function ausloeserStates()
{
static $karte = null;
if ($karte !== null) {
return $karte;
}
$karte = [];
$db = meshDb();
$res = $db->query("SELECT s.id, s.url FROM actor_states s
JOIN actors a ON a.id = s.actor_id
WHERE a.url = 'Automatik'");
while ($row = $res->fetch_assoc()) {
if (preg_match('/^auto:(\d+)$/', strval($row["url"]), $t)) {
$karte[intval($row["id"])] = intval($t[1]);
}
}
return $karte;
}
/** automation_id => Liste der Automatiken, die sie ausloesen. */
function automationTriggers()
{
$states = ausloeserStates();
if (!$states) {
return [];
}
$db = meshDb();
$res = $db->query("SELECT automation_id, state_id FROM automation_conditions");
$karte = [];
while ($row = $res->fetch_assoc()) {
$wer = intval($row["automation_id"]);
$davor = $states[intval($row["state_id"])] ?? null;
if ($davor !== null && $davor !== $wer) {
$karte[$wer][$davor] = true;
}
}
return array_map("array_keys", $karte);
}
/** Die Automatiken, die diese hier als Ausloeser benutzen. */
function automationFollowers($id)
{
$id = intval($id);
$liste = [];
foreach (automationTriggers() as $wer => $davor) {
if (in_array($id, $davor, true)) {
$liste[] = $wer;
}
}
if (!$liste) {
return [];
}
$db = meshDb();
$platz = implode(",", array_fill(0, count($liste), "?"));
$stmt = $db->prepare("SELECT id, name, floor, enabled FROM automations
WHERE id IN ($platz) ORDER BY name");
$stmt->bind_param(str_repeat("i", count($liste)), ...$liste);
$stmt->execute();
$res = $stmt->get_result();
$nachfolger = [];
while ($row = $res->fetch_assoc()) {
$nachfolger[] = [
"id" => intval($row["id"]),
"name" => $row["name"],
"floor" => $row["floor"],
"enabled" => (bool)$row["enabled"],
];
}
$stmt->close();
return $nachfolger;
}
/**
* Wuerde diese Automatik sich mittelbar selbst ausloesen?
*
* Ein Kreis (A loest B loest A) ist kein Fehler, den man im Betrieb bemerkt:
* die Sperrzeit begrenzt ihn auf eine Ausloesung je lockout_secs, und im
* Protokoll sieht das aus wie eine Automatik, die halt oft laeuft. Deshalb
* wird er hier abgelehnt und nicht dort geheilt. Der Runner meldet einen
* Kreis trotzdem - falls doch einer an der Datenbank vorbei entsteht.
*/
function pruefeKreis($id, $conditions)
{
$id = intval($id);
if ($id <= 0) {
return; // eine neue Automatik kennt noch niemand
}
$states = ausloeserStates();
$davor = [];
foreach ($conditions as $c) {
$wer = $states[intval($c["state_id"] ?? 0)] ?? null;
if ($wer !== null) {
$davor[] = $wer;
}
}
if (!$davor) {
return;
}
if (in_array($id, $davor, true)) {
throw new InvalidArgumentException(
"Eine Automatik kann sich nicht selbst auslösen.");
}
$kanten = automationTriggers();
$kanten[$id] = $davor;
$offen = $davor;
$gesehen = [];
while ($offen) {
$aktuell = array_pop($offen);
if ($aktuell === $id) {
throw new InvalidArgumentException(
"Das ergäbe einen Kreis: die Automatik würde sich mittelbar "
. "selbst auslösen.");
}
if (isset($gesehen[$aktuell])) {
continue;
}
$gesehen[$aktuell] = true;
foreach ($kanten[$aktuell] ?? [] as $weiter) {
$offen[] = $weiter;
}
}
}
/**
* Loeschen - und was mit den Automatiken geschieht, die davon abhaengen.
*
* Nichts zu tun waere die schlechteste Wahl: `fk_cond_state` steht auf
* ON DELETE CASCADE, mit dem Messwert verschwaende also still die Bedingung
* des Nachfolgers. Bei der LETZTEN Bedingung faengt der Runner das ab (eine
* Automatik ohne Bedingung loest nie aus), bei einer von zweien niemand: aus
* "zehn Minuten nach dem Wecker UND es ist hell" wuerde ein blosses "es ist
* hell", und der Rollladen fuehre ab morgen jeden Tag bei Sonnenaufgang hoch.
*
* abhaengen die Bedingung wird entfernt und der Nachfolger pausiert.
* Eine pausierte Automatik mit sichtbarer Luecke ist besser
* als eine stille Regelaenderung.
* mitloeschen der Nachfolger geht mit, und dessen Nachfolger auch.
*/
function deleteAutomation($id, $modus = "abhaengen", &$gesehen = [])
{
$id = intval($id);
if (isset($gesehen[$id])) {
return; // Schutz gegen einen Kreis, den es nicht geben sollte
}
$gesehen[$id] = true;
$db = meshDb();
$nachfolger = automationFollowers($id);
$eigeneStates = array_keys(ausloeserStates(), $id, true);
if ($nachfolger && $modus === "mitloeschen") {
foreach ($nachfolger as $n) {
deleteAutomation($n["id"], "mitloeschen", $gesehen);
}
} elseif ($nachfolger && $eigeneStates) {
$platz = implode(",", array_fill(0, count($eigeneStates), "?"));
foreach ($nachfolger as $n) {
$args = array_merge([$n["id"]], $eigeneStates);
$stmt = $db->prepare("DELETE FROM automation_conditions
WHERE automation_id = ? AND state_id IN ($platz)");
$stmt->bind_param(str_repeat("i", count($args)), ...$args);
$stmt->execute();
$stmt->close();
$stmt = $db->prepare("UPDATE automations SET enabled = 0, changed = NOW()
WHERE id = ?");
$stmt->bind_param("i", $n["id"]);
$stmt->execute();
$stmt->close();
}
}
$stmt = $db->prepare("DELETE FROM automations WHERE id = ?");
$stmt->bind_param("i", $id);
$stmt->execute();
@@ -818,6 +1288,7 @@ function listAutomations($floor)
$namesState = stateNames();
$namesCommand = commandNames();
$labelsParam = parameterLabels();
$kanten = automationTriggers();
$list = [];
foreach ($ids as $id) {
@@ -827,6 +1298,10 @@ function listAutomations($floor)
}
$auto["conditionText"] = describeConditions($auto["conditions"], $namesState);
$auto["actionText"] = describeActions($auto["actions"], $namesCommand, $labelsParam);
// Woran diese Automatik haengt - die Uebersicht rueckt Nachfolger
// unter ihren Ausloeser ein, statt sie alphabetisch dazwischen zu
// streuen.
$auto["triggers"] = $kanten[$id] ?? [];
$list[] = $auto;
}
return $list;
+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,
+26 -3
View File
@@ -21,6 +21,7 @@ from modules.mqtt_module import MQTTModule
from modules.shelly_module import ShellyModule
from modules.gartenwasser_module import GartenwasserModule
from modules.solarmanager_module import SolarManagerModule
from modules.wetterstation_module import WetterstationModule
from modules.logic_module import LogicModule #nicht Vergessen neue Module auch unten in die Liste "modules" einzufügen!!
# Logging
@@ -166,6 +167,13 @@ class Config:
return False
return self._get_bool('solarmanager', 'enable', False)
# WETTERSTATION
@property
def wetterstation_enable(self) -> bool:
if not self.config.has_section('wetterstation'):
return False
return self._get_bool('wetterstation', 'enable', False)
# MQTT
@property
@@ -431,7 +439,14 @@ class DatabaseManager:
-- Wert zufaellig einmal aenderte.
current_value = COALESCE(VALUES(current_value), current_value),
possible_values = VALUES(possible_values),
unit = VALUES(unit),
-- Dieselbe Ueberlegung wie bei current_value: ein Modul,
-- das keine Einheit mitliefert, meint "weiss ich nicht"
-- und nicht "loesch die vorhandene". Ohne COALESCE hat
-- ein Suchlauf am 10.09.2026 den Sonnensensoren ihr
-- "Lux" genommen, weil das Tahoma-Modul keine Einheiten
-- kennt - im Automatik-Editor stand danach "877" ohne
-- Angabe, wovon.
unit = COALESCE(VALUES(unit), unit),
url = VALUES(url),
value_path = VALUES(value_path)
"""
@@ -504,7 +519,14 @@ class DatabaseManager:
-- Siehe insert_actor: NULL heisst "kein Startwert",
-- nicht "Messwert loeschen".
current_value = COALESCE(VALUES(current_value), current_value),
unit = VALUES(unit),
-- Dieselbe Ueberlegung wie bei current_value: ein Modul,
-- das keine Einheit mitliefert, meint "weiss ich nicht"
-- und nicht "loesch die vorhandene". Ohne COALESCE hat
-- ein Suchlauf am 10.09.2026 den Sonnensensoren ihr
-- "Lux" genommen, weil das Tahoma-Modul keine Einheiten
-- kennt - im Automatik-Editor stand danach "877" ohne
-- Angabe, wovon.
unit = COALESCE(VALUES(unit), unit),
url = VALUES(url),
value_path = VALUES(value_path),
possible_values = VALUES(possible_values)
@@ -589,7 +611,8 @@ def main():
ShellyModule(config),
LogicModule(config),
GartenwasserModule(config),
SolarManagerModule(config)
SolarManagerModule(config),
WetterstationModule(config)
]
# Jedes Modul durchlaufen
@@ -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,70 @@ class ShellyModule(BaseModule):
"""Prüft ob Shelly aktiviert ist"""
return self.config.shelly_enable
# Welche Komponenten ihren Zustand von selbst an den Broker schicken.
#
# "status_ntf" am Geraet heisst nicht, dass jede Komponente etwas sendet -
# es heisst nur, dass gesendet werden darf. Nachgemessen am Pro 3EM: unter
# Power_EG/# kommen ausschliesslich em:0, emdata:0 und online. Die
# Temperatur der Endstufe (temperature:0) steht zwar im RPC-Status, wird
# aber nie veroeffentlicht - ein Messwert, der auf dieses Topic zeigte,
# bliebe fuer immer auf seinem letzten Wert stehen.
#
# Deshalb eine ausdrueckliche Liste statt "alles, was MQTT kann". Wer eine
# Komponente ergaenzen will, hoert vorher nach:
# mosquitto_sub -t '<praefix>/#' -v
# und traegt sie hier ein, wenn sie tatsaechlich sendet. Alles andere
# bleibt beim Abfragen - langsamer, aber richtig.
MQTT_KOMPONENTEN = {"em", "switch"}
@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 - und
ebenso jede Komponente, die zwar am Gerät hängt, aber nichts sendet
(siehe MQTT_KOMPONENTEN).
"""
if not praefix or komponente not in ShellyModule.MQTT_KOMPONENTEN:
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 +347,12 @@ 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}: {', '.join(sorted(self.MQTT_KOMPONENTEN))}"
f" ueber MQTT ({praefix}/status/...), der Rest ueber HTTP")
# Switches als Aktoren
switch_count = sum(1 for key in status.keys() if key.startswith('switch:'))
@@ -271,14 +368,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 +387,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 +395,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 +519,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
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Wetterstation Module
Die eigene Wetterstation am Haus - fest beschrieben, nicht gesucht
KEINE Datenbank-Operationen!
"""
import logging
from typing import List, Dict, Tuple
from modules.base_module import BaseModule
logger = logging.getLogger(__name__)
# Wurzel aller Topics. wsMQTTbridge.py im SolarManager hängt am Websocket der
# Station (ws://192.168.179.42/ws) und legt jedes Feld der Nachricht einzeln
# unter diese Wurzel - retained, deshalb hat ein frisch verbundener Leser
# sofort alle Werte.
#
# Zwei Eigenheiten der Brücke, die man kennen muss:
#
# * Die trägen Werte gehen nicht bei jeder Nachricht raus. Die Brücke wirft
# tempAmb, hum, qff, dewpt, iaq, avgWindspeed und avgWindDeg aus jeder
# Nachricht heraus außer jeder einundzwanzigsten. Wind und Böe kommen
# dagegen immer. Für eine Automatik macht das keinen Unterschied - der
# Runner hält den zuletzt gesehenen Wert -, für die Fehlersuche schon:
# eine Außentemperatur, die minutenlang steht, ist kein Ausfall.
#
# * Der Zeitstempel kommt von der Station selbst, nicht vom Broker.
WURZEL = "weatherStation"
# Was von der Station als Messwert im Editor auftaucht.
#
# Nicht dabei sind zwei Topics. "IP" ist keine Messung. Und "QNH [hPa]" ist
# der auf Meereshöhe gerechnete Druck nach Standardatmosphäre - er steht bei
# uns gut hundert Hektopascal neben qff (1133 gegen 1023) und ist offenbar
# nicht auf die Stationshöhe eingestellt. qff rechnet mit der tatsächlichen
# Temperatur und ist der Wert, den auch die Wetterkachel auf der Startseite
# zeigt. Zwei Drücke nebeneinander, von denen einer falsch ist, wären im
# Editor eine Falle.
#
# Die Windgeschwindigkeiten stehen in km/h - so beschriftet sie
# restricted/weather_cards.html, und so passen die Zahlen zur Historie in
# solarLog.weatherStation.
#
# Windrichtung und Böenrichtung sind Grad im Uhrzeigersinn ab Nord, wie beim
# Kompass: 0 = aus Norden, 90 = aus Osten, 180 = aus Süden, 270 = aus Westen.
# Eine Bedingung darauf ist unhandlich, weil Nord bei 0 und bei 360 liegt -
# für "Wind aus Westen" schreibt man zwei Bedingungen (> 225 und < 315).
MESSWERTE = [
# (Name im Editor, Topic, Typ, Einheit)
("Außentemperatur", "tempAmb", "float", "°C"),
("Luftfeuchte", "hum", "float", "%"),
("Taupunkt", "dewpt", "float", "°C"),
("Luftdruck", "qff", "float", "hPa"),
("Wind", "windspeed", "float", "km/h"),
("Wind im Mittel", "avgWindspeed", "float", "km/h"),
("Böe", "maxgust", "float", "km/h"),
("Windrichtung", "windDeg", "float", "°"),
("Böenrichtung", "gustDeg", "float", "°"),
("Luftgüte", "iaq", "float", None),
# Kein Wert zum Vergleichen, sondern zum Nachsehen: im Editor steht er
# hinter dem Namen und verrät, ob die Brücke noch läuft. Genauso hält es
# der Regen mit seinem "Stand".
("Stand", "Timestamp", "string", None),
]
class WetterstationModule(BaseModule):
"""
Wetterstation - Implementiert BaseModule Interface
Wie das Gartenwasser-, das Logic- und das SolarManager-Modul sucht dieses
nichts, sondern schreibt ein festes Gerät hin. Die Station meldet sich
nicht per Home-Assistant-Discovery; das MQTT-Modul findet sie also nicht.
Warum sie überhaupt hineingehört: für den Hitzeschutz ist die
Außentemperatur die bessere Bedingung als die Raumtemperatur. Wenn es
drinnen schon warm ist, ist es zum Verschatten zu spät - die Wärme steht
dann bereits im Raum und muss wieder heraus. Draußen sieht man sie
kommen. Und Wind gab es hier bisher gar nicht: ohne ihn lässt sich für
Markise und Sonnensegel kein Sturmschutz bauen.
Alles hier ist zum Lesen. Zu schalten gibt es an einer Wetterstation
nichts.
"""
def is_enabled(self) -> bool:
"""Prüft ob das Wetterstation-Modul aktiviert ist"""
return self.config.wetterstation_enable
def discover(self) -> Tuple[List[Dict], List[Dict]]:
"""
Erzeugt das eine Gerät aus MESSWERTE - als Sensor, ohne Kommandos.
Returns:
Tuple (actors, sensors)
"""
logger.info("\n" + "=" * 60)
logger.info("WETTERSTATION WIRD ERZEUGT")
logger.info("=" * 60)
states = [{
"name": name,
"url": WURZEL + "/" + topic,
"type": typ,
"unit": einheit,
} for name, topic, typ, einheit in MESSWERTE]
geraet = {
"type": "Wetter",
"name": "Wetterstation",
# Die URL eines Geräts ist das Topic seines ersten Messwerts, wie
# beim SolarManager. Sie muss nur eindeutig sein und mit mqtt://
# beginnen, damit der Runner den MQTT-Transport wählt; abonniert
# wird über die Topics der Messwerte.
"url": "mqtt://" + states[0]["url"],
"states": states,
}
logger.info("1 Gerät mit %d Messwerten", len(states))
return [], [geraet]
+28 -13
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%">
<!-- ============================================ 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"><?= $title ?></h3>
<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="row-cols-1 row-cols-md-1 g-4 p-2" id="<?= $id ?>">
<div class="card-body statistik">
<div id="jahresvergleich"></div>
</div>
</div>
<!-- /.card -->
</div>
<?php endforeach; ?>
</div>
<!-- /.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>
+442
View File
@@ -0,0 +1,442 @@
<?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, die Quelle
* liefert Watt; "leistung_kw" dasselbe fuer Quellen, die
* schon in Kilowatt melden (die Wallboxen im SolarManager)
* einheit sonst die Einheit, die hinter der Zahl steht
* 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;
/**
* Die Formate, die selbst zwischen W und kW skalieren - je nachdem, in welcher
* Einheit die Quelle meldet. Die meisten Zähler schicken Watt; die Wallboxen
* im SolarManager (evPower, evPowerOG) schicken Kilowatt, und als Watt gelesen
* stand bei 1 kW Ladeleistung "1 W" auf der Kachel.
*/
const KACHEL_LEISTUNG = ["leistung", "leistung_kw"];
/**
* Vorschläge für das Symbol vor einer Zahl.
*
* 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 (in_array($wert["format"] ?? "", KACHEL_LEISTUNG, true)) {
$sauber["format"] = $wert["format"];
} 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;
// 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 .= "<li class='nav-item' role='presentation'>"
. "<button class='nav-link" . ($erster ? " active" : "") . "' type='button'"
. " data-bs-toggle='tab' data-bs-target='#raumtab-" . $form . "' role='tab'>"
. "<i class='bi " . $titel[1] . "'></i>"
. "<span class='ms-1'>" . htmlspecialchars($titel[0]) . "</span>"
. "</button></li>";
$erster = false;
}
$html .= "</ul>";
}
$html .= "<div class='tab-content'>";
$erster = true;
foreach ($vorhanden as $form => $titel) {
$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"]);
}
// ===========================================================================
+135 -23
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,10 +17,13 @@
* 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
* ("leistung" skaliert selbst zwischen W und kW, "leistung_kw"
* ebenso fuer Quellen, die schon Kilowatt melden) oder dem Paar
* einheit und stellen (Nachkommastellen). Fehlt der Eintrag, sind
* es die drei Thermostatwerte - Soll, Ist, Feuchte - wie bisher.
* Eine leere Liste heisst: kein Messwert, die Kachel trägt nur
@@ -33,6 +41,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,
@@ -120,7 +132,7 @@ $rooms = [
// die man sich vor dem Carport stellt: laedt gerade jemand?
["floor" => "AG", "mqtt" => "Carport OG", "id" => "carport_og", "x" => 72, "y" => 154,
"werte" => [
["topic" => "solarManager/evPowerOG", "format" => "leistung",
["topic" => "solarManager/evPowerOG", "format" => "leistung_kw",
"icon" => "ev-station"],
]],
// Veranda und Terrasse gibt es zweimal, je Wohnpartei eine. In der
@@ -148,7 +160,7 @@ $rooms = [
// restricted/config.php. Ohne Kuerzel im Topic ist das die EG-Wallbox.
["floor" => "AG", "mqtt" => "Garage", "id" => "garage", "x" => 131, "y" => 135,
"werte" => [
["topic" => "solarManager/evPower", "format" => "leistung",
["topic" => "solarManager/evPower", "format" => "leistung_kw",
"icon" => "ev-station"],
]],
// Der Garten ist kein Bauteil wie die anderen, aber ein Ort: hier haengen
@@ -191,25 +203,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 +281,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;
}
+153 -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,26 @@
* die Felder dort muessen dieselben sein.
*/
require_once(__DIR__ . "/costs.php");
require_once(__DIR__ . "/kacheln.php");
require_once(__DIR__ . "/reiter.php");
// Nur wegen SKODA_APP_LINK - der Weg zu den Schlüsseln gehört neben das Feld,
// in das sie eingetragen werden, und der Link soll nur an einer Stelle stehen.
require_once(__DIR__ . "/skodaKeys.php");
/*
* Die Reiter. Reihenfolge ist Anzeigereihenfolge; der erste ist der offene,
* 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"],
["id" => "fahrzeug", "titel" => "Fahrzeug", "symbol" => "bi-ev-front"],
];
// "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 +75,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 +126,123 @@ 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 -->
<!-- =================================================== Fahrzeug ==== -->
<!-- Die Zugangsschlüssel der MyŠkoda-Anbindung. Sie laufen ab und
müssen dann getauscht werden - vorher hieß das: per SSH auf die NAS
und skoda.conf im Editor öffnen.
Die Zeilen zeichnet js/solar/settings.js aus dem, was
ajax/settings.php?action=skoda-keys liefert. Was dort NICHT
mitkommt, sind die Schlüssel selbst: die Seite steht im Heimnetz
ohne Anmeldung offen, und ein solcher Schlüssel steuert das
Fahrzeug von überall auf der Welt. Zu sehen ist nur, ob einer
hinterlegt ist und auf welche vier Zeichen er endet. -->
<?= reiterFlaecheAuf("fahrzeug", $reiterOpt) ?>
<div class="row">
<div class="col-12 col-xxl-8">
<div class="card mb-4" id="skodaKarte">
<div class="card-header pb-2 pt-2">
<h3 class="card-title"><i class="bi bi-key me-2"></i>Zugang zum Fahrzeug</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" id="skodaNeuLaden" title="Neu laden">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</div>
</div>
<div class="card-body">
<p class="text-secondary small mb-3">
Die Schlüssel werden in der MyŠkoda-App erzeugt, gelten nur für die
dort ausgewählten Fahrzeuge und laufen nach einiger Zeit ab. Ein neuer
wird hier eingetragen und sofort einmal ausprobiert was die API
ablehnt, landet gar nicht erst in der Konfiguration.
</p>
<p class="mb-3">
<a class="btn btn-sm btn-outline-primary"
href="<?= SKODA_APP_LINK ?>" target="_blank" rel="noopener">
<i class="bi bi-box-arrow-up-right me-1"></i>Schlüssel in der MyŠkoda-App verwalten
</a>
</p>
<div id="skodaListe">
<p class="text-secondary small mb-0">Wird geladen </p>
</div>
</div>
</div>
</div>
</div>
<?= reiterFlaecheZu() ?><!-- /Fahrzeug -->
</div><!-- /tab-content -->
</div>
</div>
<!--end::App Content-->
+640 -179
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,663 @@
<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">
<!-- Nur unterwegs sichtbar; zeichneFahrt() blendet es ein. -->
<span class="badge text-bg-info" id="skodaUnterwegs" hidden>unterwegs</span>
<span class="badge text-bg-secondary" id="skodaSchloss"></span>
</div>
</div>
<div class="card-body d-flex flex-column align-items-center pt-2">
<svg id="skodaAuto" class="skoda-auto" viewBox="0 0 224 500"
role="img" aria-label="Draufsicht des Fahrzeugs mit dem Zustand von Türen, Fenstern, Klappen und Licht">
<!-- Räder. Vorderachse bei 860 mm, Hinterachse bei 3625 mm
ab Bugspitze - der Radstand von 2765 mm maßstäblich. -->
<g class="rad">
<rect x="5" y="68" width="21" height="58" rx="9"/>
<rect x="198" y="68" width="21" height="58" rx="9"/>
<rect x="5" y="362" width="21" height="58" rx="9"/>
<rect x="198" y="362" width="21" height="58" rx="9"/>
</g>
<!-- Karosserie -->
<path class="karosserie" d="M112 6
C150 6, 198 16, 204 50 C209 90, 212 160, 212 250
C212 340, 209 410, 204 450 C198 484, 150 494, 112 494
C74 494, 26 484, 20 450 C15 410, 12 340, 12 250
C12 160, 15 90, 20 50 C26 16, 74 6, 112 6 Z"/>
<!-- Tech-Deck Face: die glänzend schwarze Blende über die
volle Bugbreite, die den Enyaq seit der Überarbeitung
kennzeichnet. Von oben sieht man ihre Oberkante. -->
<path class="techdeck" d="M112 7
C150 7, 197 17, 203 49 L194 53 C187 27, 148 18, 112 18
C76 18, 37 27, 30 53 L21 49 C27 17, 74 7, 112 7 Z"/>
<!-- Scheinwerfer, vier Elemente je Seite wie beim Original,
dazu die Rückleuchten als durchgehendes Band. -->
<g id="teil-lights" class="teil teil-licht" data-teil="lights">
<rect x="36" y="30" width="9" height="13" rx="4"/>
<rect x="48" y="27" width="9" height="13" rx="4"/>
<rect x="60" y="25" width="9" height="13" rx="4"/>
<rect x="72" y="24" width="9" height="13" rx="4"/>
<rect x="143" y="24" width="9" height="13" rx="4"/>
<rect x="155" y="25" width="9" height="13" rx="4"/>
<rect x="167" y="27" width="9" height="13" rx="4"/>
<rect x="179" y="30" width="9" height="13" rx="4"/>
<rect class="rueck" x="40" y="462" width="52" height="13" rx="6"/>
<rect class="rueck" x="132" y="462" width="52" height="13" rx="6"/>
<title>Beleuchtung</title>
</g>
<!-- Motorhaube mit angedeuteter Mittelsicke -->
<g id="teil-bonnet" class="teil" tabindex="0" data-teil="bonnet">
<path d="M31 152 L193 152 L189 62 C169 40, 55 40, 35 62 Z"/>
<title>Motorhaube</title>
</g>
<path class="sicke" d="M112 60 L112 150"/>
<!-- Windschutzscheibe, Dach, Heckscheibe -->
<path class="scheibe" d="M34 156 L190 156 L178 200 L46 200 Z"/>
<rect class="dach" x="46" y="200" width="132" height="136" rx="10"/>
<!-- Panoramaglasdach: beim Enyaq nahezu die ganze Dachfläche -->
<g id="teil-sunroof" class="teil" tabindex="0" data-teil="sunroof">
<rect x="62" y="206" width="100" height="120" rx="8"/>
<title>Panoramadach</title>
</g>
<!-- Dachreling -->
<g class="reling">
<rect x="48" y="204" width="9" height="134" rx="4"/>
<rect x="167" y="204" width="9" height="134" rx="4"/>
</g>
<path class="scheibe" d="M46 336 L178 336 L190 382 L34 382 Z"/>
<rect class="spoiler" x="52" y="330" width="120" height="9" rx="4"/>
<!-- Heckklappe -->
<g id="teil-trunk" class="teil" tabindex="0" data-teil="trunk">
<path d="M34 384 L190 384 L186 458 C168 478, 56 478, 38 458 Z"/>
<title>Heckklappe</title>
</g>
<!-- Türen, jeweils mit Seitenfenster darin -->
<g id="teil-doors" class="teil" data-teil="doors">
<g tabindex="0"><rect x="12" y="202" width="23" height="74" rx="6"/><title>Tür vorn links</title></g>
<g tabindex="0"><rect x="12" y="278" width="23" height="66" rx="6"/><title>Tür hinten links</title></g>
<g tabindex="0"><rect x="189" y="202" width="23" height="74" rx="6"/><title>Tür vorn rechts</title></g>
<g tabindex="0"><rect x="189" y="278" width="23" height="66" rx="6"/><title>Tür hinten rechts</title></g>
</g>
<g id="teil-windows" class="teil teil-fenster" data-teil="windows">
<rect x="17" y="210" width="13" height="58" rx="4"/>
<rect x="17" y="286" width="13" height="50" rx="4"/>
<rect x="194" y="210" width="13" height="58" rx="4"/>
<rect x="194" y="286" width="13" height="50" rx="4"/>
<title>Seitenfenster</title>
</g>
<!-- Außenspiegel -->
<g class="spiegel">
<rect x="1" y="194" width="16" height="10" rx="5"/>
<rect x="207" y="194" width="16" height="10" rx="5"/>
</g>
<!-- ============================================================
Steuerung: die beiden anklickbaren Stellen.
Bedient wird das, worum es geht - der Ladeanschluss für
das Laden, der Innenraum für Klima und Lüftung. Beide
tragen einen Ring als Hinweis darauf, dass sie mehr sind
als Anzeige, und öffnen ein Modal mit den Befehlen.
Sie stehen bewusst NICHT in der Zustandszeile darunter:
die zeigt, was ist. Diese beiden ändern etwas.
============================================================ -->
<!-- Ladeanschluss. Beim Enyaq sitzt die Klappe am hinteren
rechten Kotflügel; sie pulst, solange geladen wird. -->
<g id="teil-plug" class="teil teil-stecker steuer" tabindex="0"
role="button" data-steuer="laden" aria-label="Laden steuern">
<circle class="ring" cx="203" cy="408" r="17"/>
<circle cx="203" cy="408" r="11"/>
<path d="M198 402 l6 5 -4 0 4 6"/>
<title>Laden steuern</title>
</g>
<!-- Innenraum, auf der Windschutzscheibe: Klima und Lüftung. -->
<g id="steuer-klima" class="steuer" tabindex="0"
role="button" data-steuer="klima" aria-label="Klima und Lüftung steuern">
<circle class="ring" cx="112" cy="178" r="19"/>
<circle class="flaeche" cx="112" cy="178" r="13"/>
<path class="glyphe" d="M112 170 v10 M108 174 h8 M105 185 q7 -4 14 0"/>
<title>Klima und Lüftung steuern</title>
</g>
</svg>
<div class="skoda-teile" id="skodaTeile"></div>
<p class="skoda-hinweis">
Ladeanschluss und Innenraum antippen, um zu steuern
</p>
</div>
</div>
</div>
<!-- Batterie, Reichweite, Ladung -->
<div class="col-xxl-8 col-lg-7 d-flex align-items-stretch">
<div class="card mb-4 p-0 w-100">
<div class="card-header pb-0 pt-1">
<h3 class="card-title">Batterie und Ladung</h3>
<div class="card-tools">
<span class="badge text-bg-secondary" id="skodaLadeZustand"></span>
</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="-35.2 -8 294.4 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"/>
<!-- Ein Zipfel einer Bananenschale, von oben gesehen und
um den eigenen Ansatz im Ursprung gezeichnet. Die
Schale setzt ihn dreimal um je 120 Grad versetzt
ein - wie das Lüfterrad, nur krummer. -->
<path id="bananenZipfel"
d="M0 0 C 1 -3.2, 4.5 -6.5, 7.5 -5.2
C 9.5 -4.2, 8 -1.2, 3.5 0.6 Z"/>
</defs>
<!-- Die Straße, über die der Wagen fährt. Ganz zuerst
gezeichnet, damit alles andere darauf liegt.
Markiert wie eine deutsche Landstraße: rechts die
durchgezogene Fahrbahnbegrenzung, links die Leitlinie
mit 6 m Strich auf 12 m Lücke.
Gezeichnet ist die Straße im Viertel des
Fahrzeugmaßstabs. Maßstäblich (105 Einheiten je Meter,
aus 4,65 m Länge auf 488 Einheiten) wären die Striche
630 lang und 12,6 breit - dann füllen sie die Ränder
und ziehen den Blick vom Wagen weg. Vier Mal kleiner
bleibt das Verhältnis 1:2 erhalten, die Markierung
liest sich weiter als Leitlinie, und sie bleibt
Beiwerk statt Hauptsache:
links Leitlinie 157,5 Strich, 315 Lücke
rechts Fahrbahnbegrenzung, durchgezogen
beide 3,15 breit
Die Fahrbahn ist breiter als der Wagen, aber nicht so
breit wie eine echte Spur: die Zeichenfläche misst 294
Einheiten, gut ein Drittel mehr als das Fahrzeug, und
zwischen Rad und Markierung bleibt Platz für den
Fahrtwind.
Die Leitlinie wandert um genau eine Strich-Lücken-
Länge (472,5); danach steht das Muster wieder so da wie
zuvor, und die Schleife ist unsichtbar. 437 Einheiten
je Sekunde entsprechen in diesem Maßstab gut 60 km/h -
die Striche ziehen im selben Takt vorbei wie zuvor,
nur ruhiger. -->
<g class="strasse" aria-hidden="true">
<rect class="belag" x="-35.2" y="-8" width="294.4" height="516"/>
<line class="leitlinie" x1="-23.7" y1="-520" x2="-23.7" y2="1020"/>
<line class="begrenzung" x1="247.7" y1="-8" x2="247.7" y2="508"/>
</g>
<!-- Ostergruß: ganz selten liegt eine Bananenschale auf
der Fahrbahn, und der Wagen kommt kurz ins Schleudern.
Ausgelöst wird das in js/solar/skodaMQTT.js, gezeichnet
wird es hier.
Sie liegt in der linken Radspur (x 29, die Räder stehen
zwischen 16,5 und 41,5): unter dem Reifen hat sie ihre
Wirkung, und dass der Wagen danach nach rechts
ausbricht, passt zur Seite, auf der er den Halt
verliert.
Im Markup steht sie zwischen Straße und Fahrzeug, wird
also von der Karosserie verdeckt, sobald der Wagen
darüberrollt - so, wie es sich gehört. -->
<g class="bananenschale" aria-hidden="true">
<g class="schale" transform="translate(29 0) scale(1.4)">
<use href="#bananenZipfel"/>
<use href="#bananenZipfel" transform="rotate(120)"/>
<use href="#bananenZipfel" transform="rotate(240)"/>
<circle class="strunk" r="1.6"/>
</g>
</g>
<!-- Fahrtwind: zeigt an, dass das Fahrzeug gerade rollt.
Die Streifen laufen von vorn nach hinten, also im Bild
von oben nach unten - das Auto zeigt nach oben.
Je eine Spur läuft links und rechts am Wagen vorbei,
zwei weitere ziehen über ihn hinweg - der Wind macht
am Blech ja nicht halt. Die seitlichen liegen zwischen
Rad und Fahrbahnmarkierung: auf derselben Linie wie
die Begrenzung sähen sie aus wie eine zweite,
gestrichelte Markierung statt wie Luft. Die über dem Fahrzeug sind
heller und dünner, damit sie wie Luft darüber wirken
und nicht wie ein Riss im Lack; die Mitte bleibt frei,
dort stehen Ladestand und Reichweite.
Wenige Streifen mit ungleichem Takt: die Bewegung
kommt jetzt vor allem von der Straße darunter, der
Wind setzt nur noch einen Akzent.
Sichtbar nur, solange das SVG die Klasse "faehrt"
trägt (siehe zeichneFahrt()); aria-hidden, weil der
Zustand als Abzeichen im Kopf schon dasteht. -->
<g id="fahrtwind" class="fahrtwind" aria-hidden="true">
<line class="boe b1" x1="0" y1="-120" x2="0" y2="-60"/>
<line class="boe b6" x1="224" y1="-120" x2="224" y2="-70"/>
</g>
<!-- Alles ab hier ist das Fahrzeug selbst, zusammengefasst
in einer Gruppe: nur so kann es als Ganzes schleudern,
ohne die Straße mitzudrehen. -->
<!-- Alles ab hier ist das Fahrzeug selbst, zusammengefasst
in einer Gruppe: nur so kann es als Ganzes schleudern,
ohne die Straße mitzudrehen.
Die Malreihenfolge ist die Markup-Reihenfolge, und die
ist hier durchgehend als Schichtung gedacht - von unten
nach oben:
1. Fahrwerk Räder, Anhängerkupplung
2. Blech Karosserie, Tech-Deck, Haube, Dach,
Türen, Reling, Spiegel
3. Glas+Licht Scheinwerfer, Rückleuchten, Wind-
schutzscheibe, Seitenfenster, Heck-
klappe samt Scheibe, darüber der
Heckspoiler
4. Akku die Antriebsbatterie über allem
Das ist keine Ansicht von oben, sondern ein Schnitt-
bild: unten das, was am Boden läuft, oben das, worum
es auf dieser Seite geht. Weil jede Lage nur getönt
ist, bleibt darunter alles sichtbar.
Die beiden Knöpfe (Klima, Ladeanschluss) stehen ganz
am Schluss, noch über dem Akku. Sie sind kein Teil des
Fahrzeugs, sondern Bedienung, und müssen deckend und
anklickbar bleiben. -->
<g id="wagen">
<!-- ===== 1. Fahrwerk =================================== -->
<!-- Räder. Vorderachse bei 860 mm, Hinterachse bei 3625 mm
ab Bugspitze - der Radstand von 2765 mm maßstäblich.
Auch der Reifen selbst ist es jetzt: 235/50 R20, das
sind 744 mm Durchmesser und 235 mm Breite, also 78 auf
25 Einheiten. Die Ecken sind nur leicht gebrochen - von
oben ist eine Lauffläche ein Rechteck, keine Kapsel. Gezeichnet waren 58 auf 21 - ein Viertel
zu klein. Das Rad ist im Bild der einzige Gegenstand
mit bekanntem Mass; ist es zu klein, wirkt alles andere
zu gross, allen voran die Schnauze.
Und sie stehen dort, wo die Spur sie hinstellt: 1585 mm
vorn, 1575 mm hinten, macht Radmitten bei x 29 und 195.
Der Unterschied zwischen den Achsen ist ein Zentimeter
und liegt unter dem, was hier ein Bildpunkt auflöst -
beide Achsen stehen deshalb gleich.
Damit liegen die Reifen INNERHALB des Umrisses, wie am
Auto auch: die Karosserie ist 1879 mm breit, Spur plus
Reifenbreite sind 1820 mm. Vorher standen sie acht
Einheiten über die Flanke hinaus, eine Spur von 1,82 m
statt 1,59 m.
Zu sehen sind sie trotzdem, obwohl sie ganz unten
liegen: die Karosserie ist durchscheinend gezeichnet.
Von oben saehe man sie in Wirklichkeit gar nicht; hier
gilt dieselbe Freiheit wie bei der Akkuplatte, die auch
durch den Boden zu sehen ist. -->
<g class="rad">
<rect x="16.5" y="62" width="25" height="78" rx="5"/>
<rect x="182.5" y="62" width="25" height="78" rx="5"/>
<rect x="16.5" y="352" width="25" height="78" rx="5"/>
<rect x="182.5" y="352" width="25" height="78" rx="5"/>
</g>
<!-- Anhängerkupplung: Hals und Kugel, mehr sieht man von
oben nicht. Sie steht hinten heraus, so wie am Auto -
die Kugel sitzt rund neun Zentimeter hinter der
Heckkante. Dass das nicht wie ein Loch im Umriss
aussieht, liegt an der Farbe: sie ist schwarz und
damit erkennbar ein Anbauteil, kein fehlendes Blech. -->
<g class="kupplung">
<rect x="109" y="480" width="6" height="19" rx="3"/>
<circle cx="112" cy="501" r="4.5"/>
</g>
<!-- ===== 2. Blech ====================================== -->
<!-- Karosserie.
Beide Enden sind stumpf: der Enyaq ist ein SUV, keine
Zigarre. Vorn schliesst eine Kante von 70 bis 154 ab,
hinten eine von 56 bis 168 - hinten breiter, wie am
Auto. Die Flanken bleiben bis
kurz vor den Schluss auf voller Breite (212 bei y=250,
noch 206 bei y=452), dann runden zwei Ecken auf eine
gerade Heckkante von 56 bis 168. Vorher lief das Heck
spiegelbildlich zur Front spitz zusammen - von oben
genau die Form, die ein Fliessheck hat. -->
<path class="karosserie" d="M153.4 6
C184.9 9, 200.7 24, 203.6 52 C207.5 90, 210.5 160, 210.5 250
C210.5 340, 209.5 415, 204.6 452
C201.6 476, 190.8 494, 167.2 494
L56.8 494
C33.2 494, 22.4 476, 19.4 452
C14.5 415, 13.5 340, 13.5 250
C13.5 160, 16.5 90, 20.4 52
C23.3 24, 39.1 9, 70.6 6
L153.4 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="M19 52
C 23 26, 40 13, 70 10 L 154 10 C 184 13, 201 26, 205 52
C 199 33, 180 21, 154 20 L 70 20 C 44 21, 25 33, 19 52 Z"/>
<!-- Motorhaube mit angedeuteter Mittelsicke -->
<g id="teil-bonnet" class="teil" tabindex="0" data-teil="bonnet">
<path d="M31 146 L193 146 L190 66 C176 48, 48 48, 34 66 Z"/>
<title>Motorhaube</title>
</g>
<path class="sicke" d="M112 62 L112 142"/>
<!-- Windschutzscheibe, Dach, Heckscheibe.
Die Stationen stammen aus der Seitenansicht des
Fahrzeugs, umgerechnet mit 105 Einheiten je Meter:
Scheibenfuss bei 1,37 m (y 150), Dachanfang bei 2,15 m
(232), Dachende bei 4,0 m (428). Der Dachanfang liegt
damit etwa auf halber Laenge der Vordertuer - genau da,
wo die A-Saeule im Foto ans Dach stoesst. Gezeichnet
war er vorher am Tuerende.
Der Scheibenfuss liegt VOR der vorderen Tuerfuge
(1,46 m), nicht dahinter: am Auto beginnt die Scheibe
schon ueber dem Kotfluegel, die A-Saeule setzt erst
weiter hinten an. Damit endet die Haube bei 1,33 m
statt bei 1,58 m - die Schnauze wird um einen
Vierteilmeter kuerzer, ohne dass sich am Ueberhang
etwas aendert.
Das Dach durfte erst so weit nach hinten, seit
Heckscheibe und Heckklappe eine gemeinsame Flaeche
sind: als getrennte Schaltflaeche waere die Klappe
hier nur noch drei Zentimeter hoch und nicht mehr zu
treffen. -->
<rect class="dach" x="46" y="232" width="132" height="196" 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 und steht deshalb
direkt hinter ihm im Markup.
Die Holme sitzen ein Stück innerhalb der Dachkanten,
wie am Auto. Hinten enden sie vor dem Spoiler, nicht
an ihm: am Auto laufen die Holme über dem Radlauf aus,
der Spoiler sitzt ein Stück dahinter auf der Dachkante.
Der steht weiter unten, bei den Scheiben. -->
<g class="reling">
<line x1="53" y1="240" x2="53" y2="404"/>
<line x1="171" y1="240" x2="171" y2="404"/>
</g>
<!-- Türen, jeweils mit Seitenfenster darin.
Die vordere beginnt dicht hinter dem Vorderrad: ihre
Fuge liegt bei 1,52 m, das Rad endet bei 1,24 m - ein
knapper Vierteilmeter Blech dazwischen, so wie am Auto.
Vorher begann sie erst bei 1,75 m und liess dort eine
halbe Handbreit Luft, die es nicht gibt.
Die Fuge zwischen den Türen liegt bei 2,59 m, gemessen
an der B-Säule. Die hintere Tür reicht am Auto noch
über den Radlauf hinaus bis etwa 3,5 m; gezeichnet
endet sie am Radlauf, denn weiter hinten läge ihr Blech
im Bild auf dem Rad. -->
<g id="teil-doors" class="teil" data-teil="doors">
<g tabindex="0"><rect x="12" y="159" width="23" height="119" rx="6"/><title>Tür vorn links</title></g>
<g tabindex="0"><rect x="12" y="280" width="23" height="72" rx="6"/><title>Tür hinten links</title></g>
<g tabindex="0"><rect x="189" y="159" width="23" height="119" rx="6"/><title>Tür vorn rechts</title></g>
<g tabindex="0"><rect x="189" y="280" width="23" height="72" rx="6"/><title>Tür hinten rechts</title></g>
</g>
<!-- Außenspiegel. Sie sitzen am Fuß der A-Säule, also am
vorderen Ende der Tür (1,62 m) und nicht in deren
Mitte - von oben ist der Spiegel das erste, was nach
der Windschutzscheibe kommt. -->
<g class="spiegel">
<rect x="-0.7" y="176" width="16" height="10" rx="5"/>
<rect x="208.7" y="176" width="16" height="10" rx="5"/>
</g>
<!-- ===== 3. Glas und Licht ============================= -->
<!-- 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="30" y="29" width="9" height="13" rx="4"/>
<rect x="42" y="26" width="9" height="13" rx="4"/>
<rect x="54" y="25" width="9" height="13" rx="4"/>
<rect x="66" y="24" width="9" height="13" rx="4"/>
<rect x="149" y="24" width="9" height="13" rx="4"/>
<rect x="161" y="25" width="9" height="13" rx="4"/>
<rect x="173" y="26" width="9" height="13" rx="4"/>
<rect x="185" y="29" width="9" height="13" rx="4"/>
<!-- Die Rueckleuchten sitzen in den Heckecken und greifen
von der Klappe auf die Flanke ueber - so wie am Auto,
wo das Band um die Ecke laeuft. Weiter innen sahen
sie aus wie zwei Griffe in der Klappe. -->
<rect class="rueck" x="28" y="468" width="62" height="12" rx="6"/>
<rect class="rueck" x="134" y="468" width="62" height="12" rx="6"/>
<title>Beleuchtung</title>
</g>
<!-- Windschutzscheibe. Ihre Stationen stehen oben beim
Dach; hier liegt sie, weil Glas ueber dem Blech kommt.
Sie beginnt bei 1,37 m, also vor der vorderen Tuerfuge
bei 1,46 m, und endet mit dem Dachanfang auf halber
Laenge der Vordertuer. -->
<path class="scheibe" d="M34 150 L190 150 L178 232 L46 232 Z"/>
<!-- Seitenfenster. Sie sitzen in den Tueren, liegen im
Bild aber ueber ihnen: Glas ist die obere Lage.
Das vordere reicht bis dicht an die A-Saeule heran
(1,66 m) - am Auto beginnt es gleich hinter dem
Spiegeldreieck. Gezeichnet war es 16 Einheiten kuerzer
und liess vorn ein Stueck Blech stehen, das es nicht
gibt. -->
<g id="teil-windows" class="teil teil-fenster" data-teil="windows">
<rect x="17" y="180" width="13" height="92" rx="4"/>
<rect x="17" y="286" width="13" height="60" rx="4"/>
<rect x="194" y="180" width="13" height="92" rx="4"/>
<rect x="194" y="286" width="13" height="60" rx="4"/>
<title>Seitenfenster</title>
</g>
<!-- Heckklappe samt Heckscheibe: eine Flaeche, ein Zustand.
Die Scheibe sitzt in der Klappe und geht mit ihr auf,
also ist sie kein eigenes Teil - sie behaelt nur ihr
Aussehen (siehe .teil > .scheibe im Stylesheet).
Die Scheibe steht am Enyaq steil und wird nach unten
schmaler; lief sie nach hinten breiter aus, las sich
das von oben wie ein Fliessheck. -->
<g id="teil-trunk" class="teil" tabindex="0" data-teil="trunk">
<path class="scheibe" d="M46 428 L178 428 L172 460 L52 460 Z"/>
<path d="M52 462 L172 462 C180 470, 184 478, 183 481
C165 490, 59 490, 41 481 C40 478, 44 470, 52 462 Z"/>
<title>Heckklappe</title>
</g>
<!-- Heckspoiler. Er liegt über der Heckscheibe, nicht
darunter: am Auto sitzt er auf der Dachkante und
damit vor dem Glas. Ein Blatt über dieser Kante, in
der Mitte breit, zu den Enden schmaler - dort aber
stumpf abgeschnitten, nicht spitz zulaufend. Zwei
Spitzen gaben ihm die Form eines Blattes; am Auto
hört er schlicht auf. -->
<path class="spoiler" d="M42 427 C 74 420, 150 420, 182 427
L182 433 C 150 440, 74 440, 42 433 Z"/>
<!-- ===== 4. Antriebsbatterie =========================== -->
<!-- ========================================================
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.
Sie ist die oberste Lage des Fahrzeugs - siehe die
Schichtung oben am Anfang von #wagen. Das geht nur
auf, weil die Platte über dem Radstand liegt und in
x nur von 46 bis 178 reicht: Lichter, Fenster, Türen
und Heckklappe liegen alle davor, dahinter oder
daneben, keines davon verschwindet unter 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>
<!-- ===== Bedienung ===================================== -->
<!-- ========================================================
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.
Er steht auf deren Mitte (150 bis 232, also 191).
Darunter beginnt bei 246 der Ladestand; die 31
Einheiten dazwischen sind der Grund, warum er nicht
tiefer sitzt - sonst lesen sich Knopf und Zahl als
ein Block. -->
<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="196" r="19"/>
<circle class="flaeche" cx="112" cy="196" 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 196)">
<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>
</g><!-- /#wagen -->
<!-- Der Teil des Fahrtwinds, der über das Fahrzeug zieht.
Eigene Gruppe am Ende des Bildes, weil die Reihenfolge
im Markup die Malreihenfolge ist - weiter oben lägen
die Streifen unter der Karosserie und wären nicht zu
sehen. pointer-events: none steht im Stylesheet: sie
liegen über den beiden Knöpfen und dürfen deren
Berührungen nicht abfangen. -->
<g class="fahrtwind fahrtwind-oben" aria-hidden="true">
<line class="boe ueber b7" x1="62" y1="-120" x2="62" y2="-65"/>
<line class="boe ueber b10" x1="163" y1="-120" x2="163" y2="-60"/>
</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>
<!-- 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>
+546
View File
@@ -0,0 +1,546 @@
<?php
/**
* Die Zugangsschlüssel der MyŠkoda-Anbindung.
*
* Eine Stelle für alles, was skoda.conf betrifft: wo die Datei liegt, wie sie
* gelesen wird, was davon die Oberfläche sehen darf und wie ein neuer
* Schlüssel hineinkommt. Vorher wusste ajax/skodaCmd.php das für sich allein;
* mit dem Reiter in den Einstellungen wären es zwei Fassungen geworden, die
* sich beim ersten Formatwechsel unterscheiden.
*
* Dieselbe Datei liest gatherSkodaData.py im SolarManager. Dass das Format
* dort ein zweites Mal steht, lässt sich nicht vermeiden - zwei Sprachen,
* zwei Repositorien. skoda.conf.example daneben ist die gemeinsame
* Beschreibung, und sie ist die Stelle, an der ein neues Feld zuerst
* auftauchen sollte.
*
* ---------------------------------------------------------------------------
* Ein Schlüssel geht nie an den Browser zurück
*
* Die Seite steht im Heimnetz ohne Anmeldung offen (isLocal() in helper.php),
* und so ein Schlüssel steuert das Fahrzeug von überall auf der Welt - Laden,
* Klimatisierung, Standort. Wer ihn abliest, braucht das Heimnetz danach nicht
* mehr. Die Maske bekommt deshalb nur zu sehen, ob ein Schlüssel hinterlegt
* ist und auf welche vier Zeichen er endet; das genügt, um zwei
* auseinanderzuhalten, und taugt zu nichts sonst.
*
* Geschrieben wird nur in eine Richtung: ein neuer Wert kommt herein, der alte
* kommt nie heraus.
* ---------------------------------------------------------------------------
*/
/** Die Datei mit den Zugangsdaten. Liegt außerhalb des Webverzeichnisses; sie
* gehört zum SolarManager, der sie ebenfalls liest.
*
* Mit define() davor umzuhängen - ein Prüflauf arbeitet dann auf einer Kopie
* und nicht auf den echten Zugangsdaten. */
defined("SKODA_KONFIG") || define("SKODA_KONFIG", "/volume1/homes/wagner/SolarManager/skoda.conf");
/** Basis der öffentlichen API. Ein GET darauf prüft einen Schlüssel. */
const SKODA_API = "https://public.api.connect.skoda-auto.cz/api/v1/vehicles/";
const SKODA_KENNUNG = "SmartController-Skoda/1.0 (+https://nas.el-wa.org/smart)";
/** Wo die Schlüssel herkommen - der Link gehört in die Maske. */
const SKODA_APP_LINK = "https://go.skoda.eu/api-keys";
/** Befehle je Stunde.
*
* Das Kontingent der API - 20 je Stunde - gilt gemessen fürs ganze Konto und
* nicht je Schlüssel (die Rechnung steht bei _LIMIT in gatherSkodaData.py).
* Abruf und Steuerung laufen in getrennten Prozessen ohne gemeinsamen
* Zähler, also ist fest aufgeteilt: 14 für den Abruf, 4 für Befehle, 2
* Reserve. Ein eigener CMD_API_KEY ändert daran nichts mehr. */
const SKODA_BEFEHLE = 4;
/** Verzeichnisse für kleine Merkzettel zwischen Weboberfläche und Manager.
* Das erste, das der Webserver beschreiben darf, gewinnt. Dieselbe Liste in
* derselben Reihenfolge steht als _ANSTOSS_ORTE in gatherSkodaData.py. */
const SKODA_ARBEITSORTE = [
"/volume1/web/smart/tiles", // falls per synoacltool freigegeben
"/var/services/tmp/smart-tiles", // sonst
];
/** Name der Anstoßdatei - siehe skodaAbrufAnstossen(). */
const SKODA_ANSTOSS_DATEI = ".skoda_abruf";
/** Name des Stundenzählers für Befehle - siehe budgetFrei() in skodaCmd.php. */
const SKODA_ZAEHLER_DATEI = ".befehle.json";
/** Abstand zweier Abrufe beim Laden und während der Fahrt. Dieselbe Zahl
* steht als _I_LADEN in gatherSkodaData.py; hier nur, um sie anzuzeigen.
* Weitere Schlüssel machen sie nicht kleiner - siehe SKODA_BEFEHLE. */
const SKODA_TAKT = 260;
/**
* Die Schlüssel, die skoda.conf kennen darf, in Anzeigereihenfolge.
*
* Als Funktion und nicht als Konstante, weil jeder Eintrag seinen Satz
* Erklärung mitbringt - die steht in der Maske und soll nicht dort noch
* einmal getippt werden.
*/
function skodaFelder()
{
return [
[
"feld" => "API_KEY",
"titel" => "Abruf",
"pflicht" => true,
"zweck" => "Holt den Fahrzeugzustand. Ohne ihn bleibt die "
. "Fahrzeugseite leer.",
],
[
"feld" => "API_KEY2",
"titel" => "Abruf, zweiter Schlüssel",
"pflicht" => false,
"zweck" => "Freiwillig, und kein dichterer Abruf: das Kontingent "
. "gilt gemessen fürs ganze Konto. Was er bringt, ist "
. "Ausfallsicherheit läuft einer ab oder wird er "
. "widerrufen, fragt der Abruf mit dem nächsten weiter.",
],
[
"feld" => "API_KEY3",
"titel" => "Abruf, dritter Schlüssel",
"pflicht" => false,
"zweck" => "Wie der zweite. Mehr als drei liest die "
. "Konfiguration nicht.",
],
[
"feld" => "CMD_API_KEY",
"titel" => "Befehle",
"pflicht" => false,
"zweck" => "Für Laden, Klimatisierung und Lüftung. Ohne ihn "
. "schickt die Weboberfläche ihre Befehle mit dem "
. "ersten Schlüssel. Ein eigener lässt sich einzeln "
. "widerrufen, ohne den Abruf mitzunehmen.",
],
];
}
/* ----------------------------------------------------------------- Lesen -- */
/**
* skoda.conf als Zuweisungen lesen. Format: NAME=wert, eine je Zeile.
*
* Liefert null, wenn die Datei fehlt oder nicht lesbar ist - das ist der
* normale Zustand einer Installation ohne Fahrzeug und kein Fehler.
*/
function skodaKonfigLesen()
{
if (!is_readable(SKODA_KONFIG)) {
return null;
}
$zeilen = file(SKODA_KONFIG, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($zeilen === false) {
return null;
}
$werte = [];
foreach ($zeilen as $zeile) {
$zeile = trim($zeile);
if ($zeile === "" || $zeile[0] === "#" || strpos($zeile, "=") === false) {
continue;
}
list($k, $v) = explode("=", $zeile, 2);
$werte[strtoupper(trim($k))] = trim(trim($v), "\"'");
}
return $werte;
}
/**
* Welcher Schlüssel die Befehle schickt und mit welcher VIN.
*
* Der eigene, wenn es ihn gibt - sonst der des Abrufs. Liefert null, solange
* nichts hinterlegt ist.
*/
function skodaBefehlsSchluessel()
{
$werte = skodaKonfigLesen();
if (!$werte || empty($werte["API_KEY"]) || empty($werte["VIN"])) {
return null;
}
$eigener = !empty($werte["CMD_API_KEY"]);
return [
"vin" => $werte["VIN"],
"schluessel" => $eigener ? $werte["CMD_API_KEY"] : $werte["API_KEY"],
"eigener" => $eigener,
];
}
/** Die letzten vier Zeichen, mehr darf die Maske nicht wissen. */
function skodaMaske($wert)
{
$wert = (string)$wert;
return strlen($wert) > 4 ? "" . substr($wert, -4) : "";
}
/**
* Wann der erste Schlüssel abläuft, laut der letzten Antwort des Fahrzeugs.
*
* gatherSkodaData.py schreibt bei jedem Abruf mit, was der Header
* X-API-Key-Expires-At sagt - bei mehreren Schlüsseln das früheste Datum.
* Genauer geht es von hier aus nicht, ohne die API zu fragen, und dafür ist
* die Auskunft zu unwichtig.
*/
function skodaAblaufAusHistorie()
{
$datum = null;
try {
$mysql = new mysqli($GLOBALS["mysql_server"], $GLOBALS["mysql_solarUser"],
$GLOBALS["mysql_solarPass"], $GLOBALS["mysql_solarDB"]);
if ($mysql->connect_errno) {
return null;
}
$res = $mysql->query("SELECT key_expires FROM skoda "
. "WHERE key_expires IS NOT NULL ORDER BY id DESC LIMIT 1;");
if ($res && ($zeile = $res->fetch_assoc())) {
$datum = $zeile["key_expires"];
}
$mysql->close();
} catch (Throwable $e) {
return null; // ohne Historie eben ohne Datum
}
return $datum;
}
/**
* Der Stand für die Maske: was hinterlegt ist, was daraus folgt.
*
* Ohne Schlüsselwerte - siehe den Kopfkommentar.
*/
function skodaStand()
{
$werte = skodaKonfigLesen();
$da = is_array($werte);
$felder = [];
$abruf = 0;
foreach (skodaFelder() as $f) {
$wert = $da && !empty($werte[$f["feld"]]) ? $werte[$f["feld"]] : "";
$f["gesetzt"] = $wert !== "";
$f["maske"] = $wert !== "" ? skodaMaske($wert) : "";
$felder[] = $f;
if ($wert !== "" && $f["feld"] !== "CMD_API_KEY") {
$abruf++;
}
}
return [
"datei" => SKODA_KONFIG,
"da" => $da,
"schreibbar" => $da && is_writable(SKODA_KONFIG),
"vin" => $da && !empty($werte["VIN"]) ? skodaMaske($werte["VIN"]) : "",
"felder" => $felder,
"abruf" => $abruf,
// Was gilt - die Zahlen, nach denen man sonst im Quelltext sucht.
"takt" => SKODA_TAKT,
"befehle" => SKODA_BEFEHLE,
"ablauf" => $da ? skodaAblaufAusHistorie() : null,
"link" => SKODA_APP_LINK,
];
}
/* ------------------------------------------------------------- Schreiben -- */
/**
* Prüft einen Schlüssel, indem einmal das Fahrzeug abgefragt wird.
*
* Lieber eine Anfrage jetzt als ein Tippfehler, der erst Minuten später als
* Zeile im Log auffällt. Sie kostet eine der 20 Anfragen, die der neue
* Schlüssel in seiner ersten Stunde hat - die er ohnehin frisch mitbringt.
*
* Zurück kommt, was die API sagt, und ihr Ablaufdatum: [ok, status, ablauf,
* text].
*/
function skodaSchluesselPruefen($schluessel, $vin)
{
$ch = curl_init(SKODA_API . rawurlencode($vin));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_USERAGENT => SKODA_KENNUNG,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => [
"X-API-Key: " . $schluessel,
"Accept: application/json",
],
]);
$inhalt = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$kopfEnde = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
if ($inhalt === false) {
return ["ok" => false, "status" => 0, "ablauf" => null,
"text" => "Die Škoda-API ist nicht erreichbar."];
}
$ablauf = null;
foreach (explode("\n", substr($inhalt, 0, $kopfEnde)) as $zeile) {
if (stripos($zeile, "X-API-Key-Expires-At:") === 0) {
$ablauf = trim(substr($zeile, strlen("X-API-Key-Expires-At:")));
}
}
switch ($status) {
case 200:
return ["ok" => true, "status" => 200, "ablauf" => $ablauf, "text" => ""];
case 401:
return ["ok" => false, "status" => 401, "ablauf" => null,
"text" => "Die API kennt diesen Schlüssel nicht - abgelaufen "
. "oder vertippt."];
case 403:
return ["ok" => false, "status" => 403, "ablauf" => null,
"text" => "Der Schlüssel gilt nicht für dieses Fahrzeug. In "
. "der MyŠkoda-App muss es beim Anlegen ausgewählt sein."];
case 404:
return ["ok" => false, "status" => 404, "ablauf" => null,
"text" => "Fahrzeug nicht gefunden - stimmt die VIN in "
. SKODA_KONFIG . "?"];
case 429:
// Kein Urteil über den Schlüssel: er ist in Ordnung, nur gerade
// ausgeschöpft. Das darf den Tausch nicht verhindern.
return ["ok" => true, "status" => 429, "ablauf" => null,
"text" => "Kontingent gerade erschöpft - nicht geprüft, "
. "aber übernommen."];
default:
return ["ok" => true, "status" => $status, "ablauf" => $ablauf,
"text" => "Unerwartete Antwort (HTTP " . $status . ") - nicht "
. "geprüft, aber übernommen."];
}
}
/**
* Ein Schlüssel, wie er in einer Zeile NAME=wert stehen darf.
*
* Entscheidend ist, dass kein Zeilenumbruch hineinkommt: mit einem ließen sich
* weitere Zuweisungen unterschieben, etwa eine fremde VIN. Sonst wird bewusst
* wenig verlangt - welche Form Škoda seinen Schlüsseln morgen gibt, steht
* nicht in unserer Hand.
*/
function skodaWertPruefen($feld, $wert)
{
$wert = trim((string)$wert);
if ($wert === "") {
throw new InvalidArgumentException($feld . ": kein Wert angegeben.");
}
if (preg_match('/\s/', $wert)) {
throw new InvalidArgumentException($feld . ": ein Schlüssel enthält keine "
. "Leerzeichen oder Zeilenumbrüche - versehentlich zu viel kopiert?");
}
if (!preg_match('/^[\x21-\x7e]{8,256}$/', $wert)) {
throw new InvalidArgumentException($feld . ": das sieht nicht nach einem "
. "Schlüssel aus (8 bis 256 Zeichen, keine Sonderzeichen).");
}
return $wert;
}
/**
* skoda.conf neu schreiben.
*
* $aenderungen: Feld => neuer Wert, oder Feld => null zum Entfernen.
*
* Die Datei bleibt, wie sie ist: jede Zeile, die nicht gemeint ist, geht
* unverändert durch - Kommentare, VIN, DB_PASSWORD, Leerzeilen. Ein Feld, das
* noch nicht vorkommt, wird angehängt.
*
* Geschrieben wird daneben und dann umbenannt. Das Umbenennen ist der einzige
* Augenblick, in dem sich die Datei ändert; wer sie gerade liest - der Manager
* alle paar Minuten, skodaCmd.php bei jedem Befehl - sieht entweder den alten
* oder den neuen Stand, nie einen halben.
*/
function skodaKonfigSchreiben(array $aenderungen)
{
$roh = @file(SKODA_KONFIG);
if ($roh === false) {
throw new RuntimeException("Die Datei " . SKODA_KONFIG . " ist nicht lesbar.");
}
$offen = $aenderungen; // was noch nicht untergebracht ist
$neu = [];
foreach ($roh as $zeile) {
$blank = trim($zeile);
$name = "";
if ($blank !== "" && $blank[0] !== "#" && strpos($blank, "=") !== false) {
$name = strtoupper(trim(explode("=", $blank, 2)[0]));
}
if ($name === "" || !array_key_exists($name, $offen)) {
$neu[] = rtrim($zeile, "\r\n");
continue;
}
if ($offen[$name] !== null) {
$neu[] = $name . "=" . $offen[$name];
} // null: Zeile fällt weg
unset($offen[$name]);
}
foreach ($offen as $name => $wert) {
if ($wert !== null) {
$neu[] = $name . "=" . $wert;
}
}
// Rechte der alten Datei übernehmen: der Manager läuft unter einem anderen
// Benutzer und muss sie weiter lesen können.
$rechte = @fileperms(SKODA_KONFIG);
$rechte = $rechte ? ($rechte & 0777) : 0666;
$vorlaeufig = SKODA_KONFIG . ".neu." . getmypid();
if (@file_put_contents($vorlaeufig, implode("\n", $neu) . "\n") === false) {
throw new RuntimeException("Konnte neben " . SKODA_KONFIG . " nicht "
. "schreiben - fehlen dem Webserver die Rechte auf das Verzeichnis?");
}
@chmod($vorlaeufig, $rechte);
if (!@rename($vorlaeufig, SKODA_KONFIG)) {
@unlink($vorlaeufig);
throw new RuntimeException("Konnte " . SKODA_KONFIG . " nicht ersetzen.");
}
}
/**
* Neue Schlüssel prüfen und speichern.
*
* $neu: Feld => Wert. Leere Werte bedeuten "unverändert" und fallen hier
* schon weg - ein leeres Feld in der Maske soll nichts löschen.
* $loeschen: Felder, die verschwinden sollen.
*
* Erst prüfen, dann schreiben: ein Schlüssel, den die API ablehnt, kommt gar
* nicht erst in die Datei. Sonst stünde der Abruf still, bis es jemand im Log
* bemerkt.
*/
function skodaSchluesselSpeichern(array $neu, array $loeschen = [])
{
$stand = skodaStand();
if (!$stand["da"]) {
throw new RuntimeException("Die Datei " . SKODA_KONFIG . " gibt es nicht. "
. "Ohne sie läuft auch der Abruf nicht - sie gehört zum SolarManager.");
}
$erlaubt = array_column(skodaFelder(), "feld");
$werte = skodaKonfigLesen();
$vin = $werte["VIN"] ?? "";
if ($vin === "") {
throw new RuntimeException("In " . SKODA_KONFIG . " steht keine VIN - "
. "ohne sie lässt sich kein Schlüssel prüfen.");
}
$aenderungen = [];
foreach ($neu as $feld => $wert) {
$feld = strtoupper((string)$feld);
if (!in_array($feld, $erlaubt, true)) {
throw new InvalidArgumentException("Unbekanntes Feld: " . $feld);
}
if (trim((string)$wert) === "") {
continue; // leer heißt: so lassen
}
$aenderungen[$feld] = skodaWertPruefen($feld, $wert);
}
/*
* Derselbe Schlüssel zweimal bringt kein zweites Kontingent - die API
* zählt den Schlüssel, nicht die Zeile. gatherSkodaData.py wirft ein
* Duplikat beim Lesen weg; hier fiele es nur als "zwei Schlüssel" auf,
* die den Abruf dann doch nicht beschleunigen.
*/
foreach ($aenderungen as $feld => $wert) {
if ($wert === null) {
continue;
}
foreach ($erlaubt as $anderes) {
if ($anderes === $feld) {
continue;
}
$schon = array_key_exists($anderes, $aenderungen)
? $aenderungen[$anderes] : ($werte[$anderes] ?? "");
if ($schon !== null && $schon !== "" && hash_equals((string)$schon, $wert)) {
throw new InvalidArgumentException($feld . ": dieser Schlüssel steht "
. "schon als " . $anderes . " da. Ein zweites Mal derselbe bringt "
. "kein zweites Kontingent - in der MyŠkoda-App einen neuen anlegen.");
}
}
}
foreach ($loeschen as $feld) {
$feld = strtoupper((string)$feld);
if (!in_array($feld, $erlaubt, true)) {
throw new InvalidArgumentException("Unbekanntes Feld: " . $feld);
}
if ($feld === "API_KEY") {
throw new InvalidArgumentException("Ohne den ersten Schlüssel gibt es "
. "keinen Abruf mehr. Er lässt sich ersetzen, aber nicht entfernen.");
}
$aenderungen[$feld] = null;
}
if (!$aenderungen) {
throw new InvalidArgumentException("Nichts einzutragen.");
}
/*
* Ab hier wird das Netz befragt, und das kann dauern. helper.php hat die
* Sitzung geöffnet und hält sie gesperrt, solange diese Anfrage läuft -
* ohne das hier stünde der ganze Browser so lange still, auch die Kacheln
* in einem anderen Reiter.
*/
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
$meldungen = [];
foreach ($aenderungen as $feld => $wert) {
if ($wert === null) {
continue;
}
$urteil = skodaSchluesselPruefen($wert, $vin);
if (!$urteil["ok"]) {
throw new InvalidArgumentException($feld . ": " . $urteil["text"]);
}
if ($urteil["text"] !== "") {
$meldungen[] = $feld . ": " . $urteil["text"];
} elseif ($urteil["ablauf"]) {
$meldungen[] = $feld . " angenommen, gültig bis "
. skodaDatumKurz($urteil["ablauf"]) . ".";
} else {
$meldungen[] = $feld . " angenommen.";
}
}
skodaKonfigSchreiben($aenderungen);
/*
* Der Manager liest skoda.conf von selbst neu, sobald sie sich geändert
* hat - aber erst, wenn der nächste Abruf ohnehin ansteht, und beim Parken
* sind das 20 Minuten. Derselbe Anstoß wie nach einem Befehl holt ihn auf
* eine halbe Minute heran; dann greift ein neuer Schlüssel sofort.
*/
skodaAbrufAnstossen();
return ["meldungen" => $meldungen, "stand" => skodaStand()];
}
/* ---------------------------------------------------------------- Beiwerk - */
/** Ein API-Datum kurz und deutsch. Unlesbares bleibt, wie es ist. */
function skodaDatumKurz($iso)
{
$zeit = strtotime((string)$iso);
return $zeit ? date("d.m.Y", $zeit) : (string)$iso;
}
/**
* Dem Abruf im solarManager Bescheid geben, dass sich gleich etwas ändert.
*
* Berührt wird eine leere Datei; gatherSkodaData.py sieht bei jedem seiner
* Durchläufe auf ihre Änderungszeit und holt den nächsten Abruf auf
* _NACH_BEFEHL Sekunden danach vor. Ein stat alle drei Sekunden ist billiger
* als eine Verbindung, die dafür offen bleiben müsste, und es geht auch dann
* nichts verloren, wenn einer der beiden Prozesse gerade neu startet.
*
* Kein Fehler, wenn es nicht klappt: der Befehl ist beim Fahrzeug, nur die
* Anzeige zieht dann später nach.
*/
function skodaAbrufAnstossen()
{
foreach (SKODA_ARBEITSORTE as $ort) {
if (is_dir($ort) && is_writable($ort)) {
@touch($ort . "/" . SKODA_ANSTOSS_DATEI);
return;
}
}
}
+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;
+40
View File
@@ -0,0 +1,40 @@
-- Verlauf der Wallboxen waehrend einer Ladung.
--
-- Das Fahrzeug meldet seinen Stand beim Laden an der Wallbox mitunter nur
-- einmal in der Stunde (Tabelle skoda). Fuer die Summe einer Ladung genuegt
-- das - Energie vom Wallbox-Zaehler, Ladestand von der Zeile davor und danach
-- -, fuer den Verlauf nicht: am 13.09.2026 meldete es um 10:29 noch 5,7 kW und
-- um 11:22 dann 7,7 kW, waehrend die Wallbox seit 10:54 mit 11 kW lief.
--
-- Die Wallboxen dagegen stehen alle drei Sekunden zur Verfuegung. In EnergyFlow
-- landen sie als Fuenf-Minuten-Werte, aber EnergyFlow wird nach zwoelf Monaten
-- ausgeduennt (solarlog-rollup.sh thin) - eine Ladekurve vom letzten Sommer
-- liesse sich dann nicht mehr zeichnen. Deshalb haelt gatherSkodaData.py die
-- Werte hier fest, solange eine Wallbox Strom liefert, und diese Tabelle wird
-- nicht ausgeduennt. Bei 60 Sekunden Abstand sind das rund 180 Zeilen je
-- Ladung, im Jahr grob 25.000.
--
-- Der Gesamtzaehler steht in jeder Zeile mit. Zwischen zwei Meldungen des
-- Fahrzeugs laesst sich der Ladestand damit nach der geflossenen Energie
-- auffuellen, ohne Akkukapazitaet oder Wirkungsgrad zu kennen:
--
-- soc(t) = soc_a + (soc_b - soc_a) * (wh(t) - wh_a) / (wh_b - wh_a)
--
-- Beide Wallboxen werden festgehalten, auch wenn die Auswertung heute nur die
-- im Carport kennt: laedt das Fahrzeug einmal im Obergeschoss, soll der
-- Verlauf nicht fehlen.
CREATE TABLE IF NOT EXISTS `skoda_ladepunkte` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`datetime` datetime NOT NULL COMMENT 'Ortszeit',
`wallbox` enum('carport','og') NOT NULL COMMENT 'carport = Wattpilot, og = go-e',
`kw` decimal(6,3) NOT NULL COMMENT 'Ladeleistung laut Wallbox; 0 = Ende der Ladung',
`wh_total` bigint(20) unsigned DEFAULT NULL COMMENT 'Gesamtzaehler der Wallbox; NULL = nicht gelesen',
`pv_kw` decimal(7,3) DEFAULT NULL COMMENT 'PV-Leistung im selben Augenblick',
`grid_kw` decimal(7,3) DEFAULT NULL COMMENT 'Netz, positiv = Bezug',
`quelle` enum('manager','energyflow') NOT NULL DEFAULT 'manager'
COMMENT 'energyflow = nachgetragen aus Fuenf-Minuten-Werten, wh_total dann gerechnet',
PRIMARY KEY (`id`),
KEY `datetime` (`datetime`),
KEY `wallbox` (`wallbox`, `datetime`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;