diff --git a/README.md b/README.md index c9aab0c..c15fce5 100644 --- a/README.md +++ b/README.md @@ -336,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\|followers`, `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) | @@ -362,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()`, `deleteAutomation()`, `automationFollowers()`, `pruefeKreis()`, `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()` | @@ -526,6 +526,37 @@ 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 @@ -556,8 +587,43 @@ Drei Stellen wissen trotzdem davon: 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, -was beim Pausieren geschieht — steht in +der Rahmen, was beim Pausieren geschieht — steht in [`autoActions/README.md`](../../homes/wagner/SolarManager/autoActions/README.md) im SolarManager-Repo. @@ -565,10 +631,10 @@ im SolarManager-Repo. `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. @@ -592,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. diff --git a/ajax/AutoAction.php b/ajax/AutoAction.php index 4d2c332..ab48a20 100644 --- a/ajax/AutoAction.php +++ b/ajax/AutoAction.php @@ -11,6 +11,7 @@ * GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen" * POST ?action=save JSON-Rumpf, legt an oder ueberschreibt * 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 */ @@ -72,6 +73,16 @@ 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"]; @@ -166,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 @@ -254,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(), @@ -285,90 +324,143 @@ $nutzdaten = json_encode([ -
-
-

- -

-
-
- -
- -
-
-
-
+ + +
+
+ + Wenn + … das alles zusammenkommt + + + +
+
+ +
+ +
+
+ +
+ + +
+ + + +
+
+
+
+
+
+ Ferien +
+
+
+ Feiertage +
+
+
+ +
+ +
+
+
+ von - -
-
+ bis -
- Aktive Wochentage: -
-
-
- - -
-
- - -
-
-
- Sperrzeit nach dem Auslösen - -
-
-
- - +
+
+ Sperre +
-
-
-
-

- -

-
-
-
- +
+ + +
+
+ +
+ +
+
+ + Dann + … passiert das hier +
+
+
+ +
+
diff --git a/css/solar.css b/css/solar.css index 204dea4..2def652 100644 --- a/css/solar.css +++ b/css/solar.css @@ -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 " - + ""; - feld.querySelector("input").onchange = e => { + schalter(ziel, "checkbox", "day" + i, null, tag, WOCHENTAGE_LANG[i], + !!(autoModel.weekdays & (1 << i)), e => { if (e.target.checked) autoModel.weekdays |= (1 << i); else autoModel.weekdays &= ~(1 << i); - }; - ziel.appendChild(feld); + renderRahmen(); + }); }); } +/** Ferien und Feiertage: nie - egal - immer. */ +function renderKalendertage(zielId, auswahl, feld) { + const ziel = document.getElementById(zielId); + ziel.innerHTML = ""; + auswahl.forEach(o => { + schalter(ziel, "radio", zielId + "-" + o.value, zielId, o.label, o.title, + autoModel[feld] === o.value, () => { + autoModel[feld] = o.value; + renderRahmen(); + }); + }); +} + +/** + * Die drei Vorlagen setzen den ganzen Tagesrahmen, nicht nur die Wochentage: + * "werktags" heisst auch "nicht an Feiertagen". + */ +function renderTagVorlagen() { + const ziel = document.getElementById("tagVorlagen"); + ziel.innerHTML = ""; + autoTagVorlagen.forEach(v => { + const knopf = document.createElement("button"); + knopf.type = "button"; + // Die Vorlage, die gerade zutrifft, steht gedrueckt da. Sonst sagen die + // drei Knoepfe nur, was man tun koennte, und nie, wo man steht - und + // "werktags" ist immerhin drei Einstellungen auf einmal. + const passt = autoModel.weekdays === v.weekdays + && autoModel.on_vacation === v.on_vacation + && autoModel.on_holiday === v.on_holiday; + knopf.className = "btn btn-outline-secondary" + (passt ? " active" : ""); + knopf.textContent = v.label; + knopf.title = v.title; + knopf.onclick = () => { + // Kein stopPropagation noetig - und es haette auch nichts genutzt: die + // Vorlagen stehen bewusst ausserhalb des Elements mit data-bs-toggle, + // siehe den Kommentar an der Kopfzeile in ajax/AutoAction.php. + autoModel.weekdays = v.weekdays; + autoModel.on_vacation = v.on_vacation; + autoModel.on_holiday = v.on_holiday; + renderRahmen(); + }; + ziel.appendChild(knopf); + }); +} + +/** "montags, mittwochs und freitags" */ +function aufzaehlung(teile) { + if (teile.length < 2) return teile[0] || ""; + return teile.slice(0, -1).join(", ") + " und " + teile[teile.length - 1]; +} + +/** + * Die Wochentagsmaske als Wort. Drei und mehr aufeinanderfolgende Tage + * werden zusammengezogen ("montags bis freitags"), zwei nicht - "montags und + * dienstags und samstags" haette sonst zwei verschiedene "und" nebeneinander. + */ +function tageText(maske) { + if (maske === 127) return "täglich"; + const laeufe = []; + for (let i = 0; i < 7; i++) { + if (!(maske & (1 << i))) continue; + const letzter = laeufe[laeufe.length - 1]; + if (letzter && letzter[1] === i - 1) letzter[1] = i; + else laeufe.push([i, i]); + } + const teile = []; + laeufe.forEach(([a, b]) => { + if (b - a >= 2) teile.push(WOCHENTAGE_LANG[a] + " bis " + WOCHENTAGE_LANG[b]); + else for (let i = a; i <= b; i++) teile.push(WOCHENTAGE_LANG[i]); + }); + return aufzaehlung(teile); +} + +/** + * Der ganze Tagesrahmen als Satz. + * + * Das ist der eigentliche Gewinn gegenueber den Schaltern: man muss das + * Bedienelement nicht entziffern, sondern liest, was dabei herausgekommen + * ist. Leerer Rueckgabewert heisst "laeuft nie" - dann steht statt des + * Satzes eine Warnung da, wie beim fehlenden Ausloeser. + */ +function rahmenText() { + const tage = autoModel.weekdays; + const dazu = []; + const verbote = []; + if (autoModel.on_vacation === 2) dazu.push("an allen Ferientagen"); + if (autoModel.on_holiday === 2) dazu.push("an allen Feiertagen"); + if (autoModel.on_vacation === 0) verbote.push("in den Ferien"); + if (autoModel.on_holiday === 0) verbote.push("an Feiertagen"); + + if (!tage && !dazu.length) return ""; + + let satz; + if (!tage) { + satz = "Läuft nur " + aufzaehlung(dazu); + } else { + satz = "Läuft " + tageText(tage); + // Bei sieben angehakten Tagen traegt "dazu an allen Feiertagen" nichts + // bei - die sind schon dabei. + if (dazu.length && tage !== 127) satz += ", dazu " + aufzaehlung(dazu); + } + if (verbote.length) satz += ", aber nicht " + aufzaehlung(verbote); + return satz + "."; +} + +/** + * Der Rahmen in einer Zeile, fuer den zusammengefalteten Zustand. + * + * Solange nichts von der Vorgabe abweicht, steht dort matt "täglich · rund + * um die Uhr" und man liest darueber hinweg. Weicht etwas ab, wird die Zeile + * hell - dann steht dort etwas, das man wissen sollte. + */ +function rahmenKurzText() { + const teile = [tageText(autoModel.weekdays) || "an keinem Tag"]; + if (autoModel.on_vacation === 0) teile.push("nicht in den Ferien"); + if (autoModel.on_vacation === 2) teile.push("dazu Ferientage"); + if (autoModel.on_holiday === 0) teile.push("nicht an Feiertagen"); + if (autoModel.on_holiday === 2) teile.push("dazu Feiertage"); + teile.push(autoModel.window_from === "00:00" && autoModel.window_to === "23:59" + ? "rund um die Uhr" + : autoModel.window_from + "–" + autoModel.window_to); + if (autoModel.lockout_secs > 0) { + teile.push("Sperre " + (autoModel.lockout_secs >= 60 + ? Math.floor(autoModel.lockout_secs / 60) + " Min." + : autoModel.lockout_secs + " Sek.")); + } + if (autoModel.once_per_day) teile.push("nur einmal am Tag"); + if (autoModel.force_once) teile.push("am Ende auf jeden Fall"); + return teile.join(" · "); +} + +function rahmenIstVorgabe() { + return autoModel.weekdays === 127 + && autoModel.on_vacation === 1 + && autoModel.on_holiday === 1 + && autoModel.window_from === "00:00" + && autoModel.window_to === "23:59" + && !autoModel.once_per_day + && !autoModel.force_once; +} + +/** Schalter neu zeichnen und die beiden Zusammenfassungen nachziehen. */ +function renderRahmen() { + renderTagVorlagen(); + renderWeekdays(); + renderKalendertage("ferienGroup", autoFerien, "on_vacation"); + renderKalendertage("feiertagGroup", autoFeiertage, "on_holiday"); + const text = rahmenText(); + const satz = document.getElementById("rahmenSummary"); + // Ohne waehlbaren Tag laeuft die Automatik nie - dann ist der Satz doch + // eine Warnung und faerbt sich entsprechend. + satz.className = "callout py-2 px-3 mt-0 mb-3 " + + (text ? "callout-primary" : "callout-warning"); + satz.innerHTML = text + ? "" + text + "" + : "Kein Tag ausgewählt – die Automatik würde nie laufen."; + + const kurz = document.getElementById("rahmenKurz"); + if (kurz) { + kurz.textContent = rahmenKurzText(); + kurz.className = rahmenIstVorgabe() ? "text-body-secondary" : "text-body"; + } +} + function fuelleFormular() { const sperre = document.getElementById("lockoutSecs"); optionListe(sperre, autoLockouts.map(o => ({ value: o.value, text: o.label })), @@ -619,12 +1177,13 @@ function fuelleFormular() { } document.getElementById("tSpanFrom").value = autoModel.window_from; document.getElementById("tSpanTo").value = autoModel.window_to; - document.getElementById("actFerien").checked = autoModel.on_vacation; - document.getElementById("actFeier").checked = autoModel.on_holiday; document.getElementById("runOnce").checked = autoModel.force_once; - renderWeekdays(); + document.getElementById("oncePerDay").checked = autoModel.once_per_day; + renderRahmen(); + rahmenFelderVerdrahten(); renderConditions(); renderActions(); + werteTaktStarten(); } /** Die Felder, die direkt am Formular haengen, ins Modell zurueckholen. */ @@ -633,18 +1192,50 @@ function leseFormular() { autoModel.floor = document.getElementById("autoFloor").value; autoModel.window_from = document.getElementById("tSpanFrom").value; autoModel.window_to = document.getElementById("tSpanTo").value; - autoModel.on_vacation = document.getElementById("actFerien").checked; - autoModel.on_holiday = document.getElementById("actFeier").checked; + // weekdays, on_vacation und on_holiday stehen schon im Modell: die + // Schalter schreiben beim Klicken direkt hinein, weil der Satz darunter + // sofort nachziehen muss. autoModel.force_once = document.getElementById("runOnce").checked; + autoModel.once_per_day = document.getElementById("oncePerDay").checked; autoModel.lockout_secs = Number(document.getElementById("lockoutSecs").value); } +/** + * Die Felder im aufgeklappten Rahmen aendern die Kurzfassung im Kopf mit. + * Ohne das stuende dort "rund um die Uhr", waehrend darunter 08:45 steht. + */ +function rahmenFelderVerdrahten() { + ["tSpanFrom", "tSpanTo", "lockoutSecs", "runOnce", "oncePerDay"].forEach(id => { + const feld = document.getElementById(id); + if (feld) feld.addEventListener("change", () => { leseFormular(); renderRahmen(); }); + }); + + // Zugeklappt steht in der Kopfzeile, was gilt; aufgeklappt stehen dort die + // drei Vorlagen. Beides gleichzeitig waere eine volle Zeile aus + // Kleinteilen - genau die Unruhe, die der zusammengefaltete Rahmen + // vermeiden soll. + const koerper = document.getElementById("rahmenBody"); + if (!koerper) return; + const umschalten = offen => { + [["rahmenKurz", offen], ["rahmenAendern", offen], + ["tagVorlagen", !offen], ["rahmenZu", !offen]].forEach(([id, versteckt]) => { + const el = document.getElementById(id); + if (el) el.hidden = versteckt; + }); + }; + koerper.addEventListener("show.bs.collapse", () => umschalten(true)); + koerper.addEventListener("hide.bs.collapse", () => umschalten(false)); +} + // --- Modal --------------------------------------------------------------- function loadAutomatic() { const daten = JSON.parse(document.getElementById("autoActionData").textContent); autoDevices = daten.devices; autoLockouts = daten.lockouts; + autoFerien = daten.ferien || []; + autoFeiertage = daten.feiertage || []; + autoTagVorlagen = daten.tagvorlagen || []; autoRooms = daten.rooms || []; const a = daten.automation; @@ -660,6 +1251,7 @@ function loadAutomatic() { on_vacation: a.on_vacation, on_holiday: a.on_holiday, force_once: a.force_once, + once_per_day: a.once_per_day, lockout_secs: a.lockout_secs, groups: [], actions: a.actions.map(x => ({ command_id: x.command_id, params: Object.assign({}, x.params) })) @@ -701,8 +1293,12 @@ function openAutoActionModal(params) { // her, damit die anderen Modals unveraendert aussehen. const dialog = document.querySelector("#modalEV .modal-dialog"); dialog.classList.add("modal-xl"); - document.getElementById("modalEV").addEventListener("hidden.bs.modal", - () => dialog.classList.remove("modal-xl"), { once: true }); + document.getElementById("modalEV").addEventListener("hidden.bs.modal", () => { + dialog.classList.remove("modal-xl"); + // Sonst fragte der geschlossene Editor weiter im Achtsekundentakt nach + // Werten, die niemand mehr sieht. + werteTaktStoppen(); + }, { once: true }); const modalBodyElement = document.getElementById("modal-body"); modalBodyElement.innerHTML = loadingHTML("Wird geladen..."); @@ -743,6 +1339,7 @@ function submitAutoAction() { on_vacation: autoModel.on_vacation, on_holiday: autoModel.on_holiday, force_once: autoModel.force_once, + once_per_day: autoModel.once_per_day, lockout_secs: autoModel.lockout_secs, conditions: [], actions: autoModel.actions diff --git a/restricted/automations.php b/restricted/automations.php index d200754..cf0ba4b 100644 --- a/restricted/automations.php +++ b/restricted/automations.php @@ -93,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. * @@ -692,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" => [], @@ -723,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" => [], @@ -810,9 +923,10 @@ function saveAutomation($data) $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); @@ -844,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); @@ -855,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();