Compare commits

..
6 Commits
Author SHA1 Message Date
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
8 changed files with 2234 additions and 409 deletions
+141 -6
View File
@@ -314,7 +314,9 @@ Antwort selbst.
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.
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`,
@@ -334,7 +336,7 @@ klein: Diagramme füllen bzw. Logzeilen nachladen und einfärben.
| 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`, `POST save\|delete\|toggle` | Automatik-Editor und -Übersicht |
| `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) |
@@ -360,7 +362,7 @@ Kennzahlen, `energyHistory.php` wählt die Quelle nach Zeitraum,
|---|---|---|
| `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()`, `geraeteListe()`, `geraetLoeschen()`, `saveRooms()` |
| `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()` |
@@ -524,20 +526,115 @@ 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, Logic); die Datenbanklogik liegt
allein im Hauptskript.
Discovery, Gartenwasser, SolarManager-Werte, Wetterstation, Logic); die
Datenbanklogik liegt allein im Hauptskript.
Zwei Eigenschaften, die man kennen muss:
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.
@@ -561,11 +658,49 @@ Zwei Eigenschaften, die man kennen muss:
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.
+286 -81
View File
@@ -10,7 +10,9 @@
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik
* GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen"
* 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
*/
@@ -45,7 +47,12 @@ 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))]);
@@ -59,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"];
@@ -74,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 = [];
@@ -83,16 +177,41 @@ 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";
}
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
@@ -101,13 +220,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>";
@@ -141,6 +290,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(),
@@ -172,90 +324,143 @@ $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">
<!-- 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)
</button>
<div class="alert alert-secondary mt-3 mb-0 py-2 small" id="condSummary"></div>
</div>
</div>
</div>
<!--
Drei Baender statt eines Akkordeons: Wenn - Wann - Dann.
<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">
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> oder stattdessen …
</button>
</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>
<!--
.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">
<label for="tSpanFrom">Von</label>
</div>
<div class="form-floating">
<span class="input-group-text">bis</span>
<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">
<label class="form-check-label" for="runOnce">
Am Ende des Zeitraums auf jeden Fall ausführen, falls der Auslöser nie zutraf
</label>
<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>
</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 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
</button>
<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-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>
</div>
</div>
</div>
</div>
<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> und noch etwas tun
</button>
</div>
</div>
</form>
+277 -19
View File
@@ -29,7 +29,22 @@
--ton-sonne-rgb: 240, 180, 41;
--ton-gut: #4caf7d;
--ton-schlecht: #e06060;
--ton-radius: .7rem;
/* Zwei Radien statt einem. Der grosse gilt fuer Flaechen (Karten, Modale,
Eingabefelder), der kleine fuer alles, was in einer Reihe steht -
Schalter, kleine Knoepfe, Abzeichen. Bootstrap kennt beide, benutzte
bisher aber nur den grossen; der kleine stand auf seiner Vorgabe von
.25rem und wirkte daneben eckig. */
--ton-radius: .875rem;
--ton-radius-klein: .5rem;
/* Die Anzeigeschrift. Poppins liegt seit jeher unter assets/fonts und trug
bisher nur die Etagenknoepfe im Aussenplan - eine ganze Schrift fuer vier
Buchstaben. Als Variable traegt sie jetzt alle Ueberschriften: waermer
und eine Spur weniger technisch als die Source Sans 3 des Fliesstextes,
ohne dass eine zweite Datei geladen wird. Zahlenkolonnen und Fliesstext
bleiben unberuehrt - Poppins hat keine Tabellenziffern. */
--font-anzeige: Poppins, system-ui, -apple-system, "Segoe UI", sans-serif;
/* ... und dieselben Werte dort, wo Bootstrap sie sucht. */
--bs-body-bg: var(--ton-grund);
@@ -43,11 +58,25 @@
--bs-tertiary-bg: var(--ton-flaeche-2);
--bs-border-color: var(--ton-linie-satt);
--bs-border-radius: var(--ton-radius);
--bs-border-radius-sm: var(--ton-radius-klein);
--bs-link-color: var(--ton-sonne);
--bs-link-hover-color: #ffc94d;
--bs-heading-color: var(--ton-schrift);
}
/* Ueberschriften in der Anzeigeschrift. Bewusst nur Ueberschriften: der
Fliesstext und alle Zahlenkolonnen bleiben bei der Schrift des Themes,
weil Poppins keine Tabellenziffern mitbringt und Messwerte sonst
springen wuerden. */
h1, h2, h3, h4, h5, h6,
.h1, .h2, .h3, .h4, .h5, .h6,
.card-title,
.modal-title {
font-family: var(--font-anzeige);
font-weight: 600;
letter-spacing: -.01em;
}
/* Der Rumpf traegt zwei Grundfarben: die Seite und die Leisten drumherum.
AdminLTE setzt sie ueber eigene Klassen, die hier auf denselben Ton
gebracht werden - sonst steht eine Karte auf drei verschiedenen Grautoenen. */
@@ -655,21 +684,242 @@ input[type='range']::-webkit-slider-runnable-track {
}
}
/* Editor der Automatiken. Eine Bedingungszeile traegt Geraet, Messwert,
Vergleich, Wert, Einheit und den Papierkorb nebeneinander - auf einem
Handy bleiben davon "Therm OG Bad" und "te" uebrig, weil die Felder in
der input-group einfach mitschrumpfen. Unter 576 px bekommen deshalb
Geraet und Messwert je eine eigene Zeile; Vergleich, Wert und Einheit
sind kurz genug, um zusammen in die naechste zu passen. */
@media (max-width: 575.98px) {
/* Die input-group-Klasse muss mit in den Selektor: solar.css wird vor
AdminLTE geladen, und Bootstraps ".input-group > .form-select" haette
sonst dasselbe Gewicht und wuerde als spaeteres Blatt gewinnen. */
.input-group.auto-zeile > .auto-geraet,
.input-group.auto-zeile > .auto-messwert {
flex: 1 1 100%;
width: auto;
/* --------------------------------------------------------------------------
Drei Klassen, die der Automatik-Editor gebraucht hat - hier, nicht dort
Sie stehen absichtlich in der gemeinsamen Datei und nicht als Sonderfall
im Editor. Zwei davon sind allgemein genug, um anderswo zu taugen; die
dritte ist eine echte Ausnahme und als einzelne Klasse auch als solche
erkennbar.
-------------------------------------------------------------------------- */
/* Ein Auswahlfeld, das wie Text aussieht, bis man es anfaehrt.
Eine Bedingung hat sechs Bestandteile, aber nur zwei davon traegt man
wirklich ein: Vergleich und Wert. Standen alle sechs als gleich schwere
Kaesten nebeneinander, sagte nichts mehr, worauf es ankommt. Die
gepunktete Linie sagt "anklickbar", ohne den Platz und das Gewicht eines
Eingabefelds zu nehmen.
Bewusst allgemein: dieselbe Not haben das Raum-Modal und die
Kachel-Einstellungen, wo ebenfalls jede Kleinigkeit in einem Kasten
steht. Taugt fuer <select> wie fuer <button>. */
.feld-als-text {
appearance: none;
-webkit-appearance: none;
max-width: 100%;
padding: 0 0 1px;
border: 0;
border-bottom: 1px dashed var(--ton-linie-satt);
border-radius: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
}
.feld-als-text:hover,
.feld-als-text:focus-visible {
border-bottom-color: var(--bs-link-color);
color: var(--bs-emphasis-color);
outline: 0;
}
/* Ein Messwert, wie er in diesem Moment dasteht.
Steht neben einer Bedingung und beantwortet die Frage, die man wirklich
hat: "warum laeuft die nicht?". Das ist die Ausnahme unter den dreien -
Bedingungen gibt es sonst nirgends auf der Seite. */
.wert-jetzt {
font-size: .8125rem;
color: var(--ton-schrift-3);
font-variant-numeric: tabular-nums;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.wert-jetzt b { font-weight: 400; color: var(--ton-schrift-2); }
.wert-jetzt.erfuellt b { color: var(--ton-gut); }
/* Erfuellt oder noch nicht. Bewusst kein Rot: eine Bedingung, die gerade
nicht zutrifft, ist der Normalfall und kein Fehler. */
.punkt {
display: inline-block;
width: .7rem;
height: .7rem;
border: 1.5px solid var(--ton-schrift-3);
border-radius: 50%;
}
.punkt.an {
border-color: var(--ton-gut);
background: var(--ton-gut);
}
/* Atmet leise, wo etwas mitlaeuft - ein Wort "live" daneben braucht es dann
nicht. */
.punkt.atmet { animation: punkt-atmen 2.4s ease-in-out infinite; }
@keyframes punkt-atmen {
0%, 100% { opacity: 1; }
50% { opacity: .35; }
}
@media (prefers-reduced-motion: reduce) {
.punkt.atmet { animation: none; }
}
/* --------------------------------------------------------------------------
Editor der Automatiken: die Bedingungszeile
Frueher eine input-group aus sechs gleich schweren Kaesten. Jetzt ein
Raster, in dem Geraet und Messwert uebereinander stehen (das Geraet
beantwortet "woher", nicht "was") und nur Vergleich und Wert noch wie
Eingabefelder aussehen.
Auf schmalen Schirmen bricht die Zeile in zwei: oben, worum es geht,
unten der Vergleich. Frueher schrumpften die Felder stattdessen mit, bis
von "Thermostat OG Bad" ein "Therm" uebrig war.
-------------------------------------------------------------------------- */
.auto-zeile {
display: grid;
grid-template-columns: 1.75rem minmax(0, 1fr) 5.25rem 8rem 8.5rem .75rem 1.75rem;
grid-template-areas: "sym was op wert jetzt punkt weg";
gap: .625rem;
align-items: center;
padding: .45rem .55rem;
border-radius: var(--ton-radius-klein);
}
.auto-zeile:nth-of-type(even) { background: rgba(255, 255, 255, .022); }
.auto-zeile > .auto-symbol { grid-area: sym; color: var(--ton-schrift-2); }
.auto-zeile > .auto-was { grid-area: was; min-width: 0; }
.auto-zeile > .auto-op { grid-area: op; }
.auto-zeile > .auto-wert { grid-area: wert; }
.auto-zeile > .wert-jetzt { grid-area: jetzt; }
.auto-zeile > .punkt { grid-area: punkt; }
.auto-zeile > .auto-weg { grid-area: weg; }
.auto-zeile > .auto-werte { grid-area: werte; }
/* Die Aktionszeile: dasselbe Raster, andere Spalten. Statt Vergleich, Wert
und laufendem Messwert steht dort ein Block mit den Parametern des
Kommandos - mal keiner ("Auf"), mal zwei ("Position+Neigung"). */
.auto-zeile.tat {
grid-template-columns: 1.75rem minmax(0, 1fr) auto 1.75rem;
grid-template-areas: "sym was werte weg";
}
/* Eine Zeile ohne Geraet: dann gibt es nur eines zu tun, und das darf die
ganze Breite haben. Vergleich, Wert und der laufende Messwert gibt es in
diesem Zustand noch gar nicht - ihre Spalten stuenden sonst nur leer
herum, waehrend die eine Schaltflaeche in eine schmale Spalte gequetscht
waere. */
.auto-zeile.leer { grid-template-areas: "sym was was was was punkt weg"; }
/* Geraet klein und matt ueber dem Messwert - und trotzdem anklickbar. */
.auto-was { display: flex; flex-direction: column; gap: .05rem; }
/* Nur in der Bedingungszeile: die Aktionszeile benutzt .auto-geraet
weiterhin als vollwertige Auswahlflaeche und darf davon nichts
abbekommen. */
/* Nicht in der leeren Zeile: dort traegt derselbe Knopf zusaetzlich
.feld-als-text und ist die einzige Handlung, die es gibt - klein und matt
waere er dort genau die Falle, die er vorher war. Die Klasse .auto-geraet
behaelt er trotzdem, weil daran haengt, WAS er ist: der Klick-Horcher
erkennt ihn daran. Verhalten und Aussehen sind absichtlich zwei
verschiedene Klassen. */
.auto-was > .auto-geraet:not(.feld-als-text) {
align-self: start;
max-width: 100%;
padding: 0;
border: 0;
background: none;
color: var(--ton-schrift-3);
font-size: .72rem;
line-height: 1.3;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
}
.auto-was > .auto-geraet:not(.feld-als-text):hover,
.auto-was > .auto-geraet:not(.feld-als-text):focus-visible {
color: var(--bs-link-color);
outline: 0;
}
.auto-messwert { align-self: start; }
/* Der Papierkorb erscheint erst beim Ueberfahren. Acht rote Knoepfe im
Ruhezustand zogen alle Aufmerksamkeit auf die seltenste Handlung.
Wo es kein Ueberfahren gibt, steht er dauerhaft da. */
.auto-zeile > .auto-weg {
padding: 0;
border: 0;
background: none;
color: var(--ton-schrift-3);
opacity: 0;
transition: opacity .15s ease, color .15s ease;
}
.auto-zeile:hover > .auto-weg,
.auto-zeile:focus-within > .auto-weg { opacity: 1; }
.auto-zeile > .auto-weg:hover { color: var(--ton-schlecht); }
@media (hover: none) {
.auto-zeile > .auto-weg { opacity: 1; }
}
/* Ein Segment je Bedingung, im Kopf des Bandes und ueber jeder Gruppe.
Eine Zahl allein ("2 von 4") liest man, die Reihe sieht man. */
.auto-segment {
width: .8rem;
height: .3rem;
border-radius: .15rem;
background: var(--ton-linie-satt);
}
.auto-segment.an { background: var(--ton-gut); }
/* Was der Browser nicht entscheiden kann, bleibt hohl - siehe ZEITARTEN in
autoActionFuncs.js. */
.auto-segment.offen {
background: none;
box-shadow: inset 0 0 0 1px var(--ton-linie-satt);
}
@media (max-width: 767.98px) {
.auto-zeile {
grid-template-columns: 1.75rem 5.25rem minmax(0, 1fr) .75rem 1.75rem;
/* Drei Zeilen statt zwei: der laufende Wert quetschte sich sonst mit
dem Wertfeld in dieselbe Reihe und blieb als "gerad…" stehen. Auf
einem Handy ist Hoehe billig und Breite teuer. */
grid-template-areas:
"sym was was punkt weg"
"sym op wert wert wert"
"sym jetzt jetzt jetzt jetzt";
row-gap: .35rem;
}
.auto-zeile.leer { grid-template-areas: "sym was was punkt weg"; }
.auto-zeile.tat {
grid-template-columns: 1.75rem minmax(0, 1fr) 1.75rem;
grid-template-areas:
"sym was weg"
"sym werte werte";
}
.auto-zeile.tat.leer { grid-template-areas: "sym was weg"; }
}
/* Raum-Modal. Ein Kommando mit mehreren Parametern ("Position+Neigung")
@@ -1280,11 +1530,19 @@ input[type='range']::-webkit-slider-runnable-track {
.skoda-auto.banane .bananenschale { display: none; }
}
.skoda-auto .rad rect { fill: rgba(18, 20, 24, 0.9); }
/* Die Reifen liegen als unterste Lage unter der ganzen Karosserie (siehe die
Schichtung in restricted/skoda.php) und sind schwarz wie am Auto. Unter zwei
getoenten Flaechen bleibt davon nicht viel uebrig, vorn unter der Haube fast
nichts - das ist so gewollt. Von oben saehe man die Reifen ohnehin nicht;
sie stehen im Bild, damit die Achsen einen Ort haben, nicht damit man sie
liest. */
.skoda-auto .rad rect { fill: rgba(14, 16, 19, 0.95); }
.skoda-auto .spiegel rect { fill: rgba(var(--skoda-blech), 0.55); }
/* Anhaengerkupplung: dunkler als das Blech, sie ist ja auch nicht lackiert. */
/* Die Kupplung ist schwarz und bleibt es: sie steht hinten heraus und liegt
dort auf dem Hintergrund, nicht unter dem Blech - sie braucht die
Aufhellung der Reifen also nicht. */
.skoda-auto .kupplung rect,
.skoda-auto .kupplung circle { fill: rgba(28, 31, 35, 0.95); }
.skoda-auto .kupplung circle { fill: rgba(10, 12, 14, 0.95); }
/* Dachspoiler und Reling sind an diesem Fahrzeug schwarz, nicht lackiert -
und nur dadurch heben sie sich vom Dach ab, auf dem sie liegen. Vorher
hatten sie den Ton des Blechs und verschwanden darin. */
@@ -2327,7 +2585,7 @@ input[type='range']::-webkit-slider-runnable-track {
}
.floorBtn text {
font: 600 34px Poppins, system-ui, sans-serif;
font: 600 34px var(--font-anzeige);
fill: #9aa0a6;
opacity: .8;
transition: fill .25s ease, opacity .25s ease;
File diff suppressed because it is too large Load Diff
+332 -14
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;
}
@@ -674,9 +801,10 @@ 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,
"force_once" => false,
"once_per_day" => false,
"lockout_secs" => 60,
"last_run" => null,
"conditions" => [],
@@ -705,9 +833,12 @@ 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"]),
"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" => [],
@@ -785,15 +916,17 @@ 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);
$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);
@@ -825,10 +958,11 @@ function saveAutomation($data)
$stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?,
window_from = ?, window_to = ?, weekdays = ?,
on_vacation = ?, on_holiday = ?, force_once = ?,
lockout_secs = ?, changed = NOW()
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("ssissiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $forceOnce, $oncePerDay,
$lockout, $id);
$stmt->execute();
$stmt->close();
$db->query("DELETE FROM automation_conditions WHERE automation_id = " . $id);
@@ -836,10 +970,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, force_once, once_per_day,
lockout_secs)
VALUES (?,?,?,?,?,?,?,?,?,?,?)");
$stmt->bind_param("ssissiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $forceOnce, $oncePerDay,
$lockout);
$stmt->execute();
$id = $db->insert_id;
$stmt->close();
@@ -925,9 +1061,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();
@@ -970,6 +1283,7 @@ function listAutomations($floor)
$namesState = stateNames();
$namesCommand = commandNames();
$labelsParam = parameterLabels();
$kanten = automationTriggers();
$list = [];
foreach ($ids as $id) {
@@ -979,6 +1293,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
@@ -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
@@ -165,6 +166,13 @@ class Config:
if not self.config.has_section('solarmanager'):
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
@@ -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
@@ -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]
+211 -162
View File
@@ -208,8 +208,82 @@
<!-- 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
@@ -232,40 +306,6 @@
C23.3 24, 39.1 9, 70.6 6
L153.4 6 Z"/>
<!-- 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: die
Karosserie ist durchscheinend gezeichnet - und sie
stehen im Markup hinter ihr, sonst schluckt das Blech
sie doch. 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>
<!-- 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
@@ -282,6 +322,85 @@
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">
@@ -302,45 +421,55 @@
<title>Beleuchtung</title>
</g>
<!-- Motorhaube mit angedeuteter Mittelsicke -->
<g id="teil-bonnet" class="teil" tabindex="0" data-teil="bonnet">
<path d="M31 172 L193 172 L190 66 C176 48, 48 48, 34 66 Z"/>
<title>Motorhaube</title>
<!-- 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>
<path class="sicke" d="M112 62 L112 168"/>
<!-- Windschutzscheibe, Dach, Heckscheibe.
<!-- 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 Stationen stammen aus der Seitenansicht des
Fahrzeugs, umgerechnet mit 105 Einheiten je Meter:
Scheibenfuss bei 1,6 m (y 172), 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. Vorher begann das Dach
schon bei 1,85 m und endete bei 3,15 m - das Glashaus
sass ein knappes Dreiviertel Meter zu weit vorn und war
zu kurz, und mit ihm Reling und Spoiler.
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>
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. -->
<path class="scheibe" d="M34 176 L190 176 L178 232 L46 232 Z"/>
<rect class="dach" x="46" y="232" width="132" height="196" rx="10"/>
<!-- 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"/>
<!-- Kein Panoramadach: dieses Fahrzeug hat ein geschlossenes
Blechdach. Die Zustandszeile kennt es deshalb auch nicht
(TEILE in js/solar/skodaMQTT.js). -->
<!-- Dachreling. Sie liegt AUF dem Dach: nach innen gerückt,
nicht auf dessen Seitenkanten, und sie bleibt zwischen
den Scheiben - vorn hinter der Windschutzscheibe, hinten
am Spoiler. Läge sie bündig auf den Kanten, ergäbe sie
mit Platte und Spoiler ein geschlossenes Rechteck, und
das sieht aus wie ein Fensterrahmen. Die runden Enden
kommen von der Strichstärke, nicht aus der Geometrie. -->
<!-- ===== 4. Antriebsbatterie =========================== -->
<!-- ========================================================
Antriebsbatterie - der Ladestand selbst.
@@ -350,19 +479,12 @@
also im Bild von unten nach oben - dieselbe Richtung,
in der jeder einen Füllstand liest.
Die Reihenfolge ist Absicht und gilt ohne Ausnahme:
die Platte liegt ÜBER allem, was nur Blech und Glas ist
- Karosserie, Haube, beide Scheiben, Dach, Reling,
Spoiler -, und UNTER allem, was Zustand zeigt oder
bedient wird: Türen, Fenster, Klappen, Licht und die
beiden Knöpfe. Eine offene Klappe muss sichtbar
bleiben, auch wenn die Batterie darunter liegt - der
Zustand hat Vorrang vor der Anzeige.
Deshalb steht dieser Block hier und nicht weiter oben:
stand er vor der Heckscheibe, lag die Platte über der
Windschutzscheibe, aber unter der Heckscheibe, und die
Relingstäbe schwammen auf ihr.
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().
@@ -431,81 +553,7 @@
<title id="akkuTitel">Antriebsbatterie</title>
</g>
<!-- Dachreling und Heckspoiler stehen erst hier, nach der
Akkuplatte: sie liegen auf dem Dach, die Platte liegt
im Fahrzeugboden. Weiter oben im Markup lagen sie unter
der halbdurchsichtigen Platte und verblassten darin -
von den Relingholmen war fast nichts mehr zu sehen.
Die Holme sitzen ein Stück innerhalb der Dachkanten,
wie am Auto, und laufen zwischen den beiden Scheiben.
Der Spoiler ist ein Blatt über der hinteren Dachkante:
in der Mitte breit, zu den Enden auslaufend - so sitzt
er auf der Kante, statt sie nachzuzeichnen. -->
<g class="reling">
<line x1="53" y1="240" x2="53" y2="424"/>
<line x1="171" y1="240" x2="171" y2="424"/>
</g>
<path class="spoiler" d="M42 430 C 74 420, 150 420, 182 430
C 150 440, 74 440, 42 430 Z"/>
<!-- 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>
<!-- Anhängerkupplung: Hals und Kugel, mehr sieht man von
oben nicht. Sie bleibt innerhalb der Heckkante - stand
sie darüber hinaus, las sich der Umriss an dieser
Stelle wie unterbrochen. -->
<g class="kupplung">
<rect x="109" y="474" width="6" height="13" rx="3"/>
<circle cx="112" cy="489" r="4"/>
</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>
<g id="teil-windows" class="teil teil-fenster" data-teil="windows">
<rect x="17" y="196" width="13" height="76" rx="4"/>
<rect x="17" y="286" width="13" height="60" rx="4"/>
<rect x="194" y="196" width="13" height="76" rx="4"/>
<rect x="194" y="286" width="13" height="60" rx="4"/>
<title>Seitenfenster</title>
</g>
<!-- Außenspiegel -->
<g class="spiegel">
<rect x="-0.7" y="196" width="16" height="10" rx="5"/>
<rect x="208.7" y="196" width="16" height="10" rx="5"/>
</g>
<!-- ===== Bedienung ===================================== -->
<!-- ========================================================
Steuerung: die beiden anklickbaren Stellen.
@@ -540,18 +588,19 @@
</g>
<!-- Innenraum, auf der Windschutzscheibe: Klima und Lüftung.
Er sitzt im oberen Drittel der Scheibe, nicht in deren
Mitte: darunter steht der Ladestand, und zwischen Knopf
und Zahl braucht es Luft, sonst lesen sich beide als
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="206" r="19"/>
<circle class="flaeche" cx="112" cy="206" r="13"/>
<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 206)">
<g class="luefter" transform="translate(112 196)">
<g class="blaetter">
<use href="#klimaFluegel"/>
<use href="#klimaFluegel" transform="rotate(120)"/>